Files
markdown_to_skillstown/onlineacademy-playwright-runner.mjs
T
kodi 410f37a008 feat(oa): add training-dir workflow and save flow for OnlineAcademy runner
Introduce a simpler training-based workflow for the OnlineAcademy Playwright runner.

Changes included:
- support --training-dir and automatic loading of content.md and training.json
- support --env-file so OA credentials no longer need manual shell sourcing
- resolve asset paths relative to the training directory
- improve human-readable review and execute output with step-by-step progress
- keep the browser open after execute when no save flag is used
- add optional --save flow that clicks 'Opslaan als' and then chooses 'Concept'
- add a concrete user guide for preparing and running training imports
- update handover documentation to reflect the current repo structure and workflow
- align the repo around trainings/<name>/content.md, training.json and assets/
- remove reliance on older pilot/test material in the documented main flow
2026-05-09 11:29:35 +02:00

1384 lines
45 KiB
JavaScript

import fs from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";
import { chromium } from "playwright";
import {
parseMarkdownToCourseModel,
validateCourseModel,
} from "./markdown-to-onlineacademy-json.mjs";
const SUPPORTED_UI_BLOCKS = new Set([
"text",
"heading",
"quote",
"image",
"multiple-choice",
"multiple-response",
"open-question",
"table",
]);
const MAX_QUESTION_LENGTH = 350;
const MAX_FEEDBACK_LENGTH = 999;
const MAX_MULTI_RESPONSE_ANSWERS = 10;
const MAX_TABLE_COLUMNS = 5;
const MAX_TABLE_ROWS = 20;
function parseEnvText(source) {
const values = {};
for (const rawLine of source.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
if (!match) {
continue;
}
const [, key, rawValue] = match;
let value = rawValue.trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
values[key] = value;
}
return values;
}
async function loadEnvFile(envFilePath) {
const absolutePath = path.resolve(envFilePath);
const source = await fs.readFile(absolutePath, "utf8");
const values = parseEnvText(source);
for (const [key, value] of Object.entries(values)) {
if (!process.env[key]) {
process.env[key] = value;
}
}
return absolutePath;
}
function logInfo(message) {
console.log(`[INFO] ${message}`);
}
function logStep(message) {
console.log(`\n[STAP] ${message}`);
}
function logWarn(message) {
console.log(`[LET OP] ${message}`);
}
function summarizePlan(plan) {
const chapterCount = plan.chapters.length;
const pageCount = plan.chapters.reduce((total, chapter) => total + chapter.pages.length, 0);
const blockCount = plan.chapters.reduce(
(total, chapter) =>
total + chapter.pages.reduce((pageTotal, page) => pageTotal + page.blocks.length, 0),
0
);
return { chapterCount, pageCount, blockCount };
}
function describeValidationIssues(validationIssues) {
if (validationIssues.length === 0) {
return "Geen validatieproblemen gevonden.";
}
return `${validationIssues.length} validatieprobleem/problemen gevonden. Zie validation.json voor details.`;
}
function shortenText(value, maxLength = 60) {
const normalized = String(value ?? "").replace(/\s+/g, " ").trim();
if (!normalized) {
return "";
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 3)}...`;
}
function summarizeBlock(block) {
switch (block.type) {
case "text":
return shortenText(block.data.title || block.data.content || "tekstblok");
case "heading":
return shortenText(block.data.title || "kopblok");
case "quote":
return shortenText(block.data.quote || "quoteblok");
case "image":
return shortenText(block.data.title || block.data.caption || block.data.sourcePath || "afbeelding");
case "table":
return shortenText(block.data.title || "tabel");
case "multiple-choice":
case "multiple-response":
case "open-question":
return shortenText(block.data.question || block.type);
default:
return shortenText(block.type);
}
}
function resolveTrainingAssetPath(baseDir, assetPath) {
if (path.isAbsolute(assetPath)) {
return assetPath;
}
return path.resolve(baseDir, assetPath);
}
function buildFriendlyErrorMessage(error, options) {
const message = error instanceof Error ? error.message : String(error);
const failureArtifact = path.join(options.outputDir, "failure-state.png");
if (message.includes("OA_EMAIL") || message.includes("OA_PASSWORD")) {
return [
"De runner kon niet inloggen omdat de credentials ontbreken.",
`Controleer ${path.resolve(options.envFile)} en kijk of OA_EMAIL en OA_PASSWORD zijn ingevuld.`,
].join("\n");
}
if (message.includes("Kon env-bestand niet laden")) {
return [
"Het env-bestand kon niet worden gelezen.",
`Controleer of ${path.resolve(options.envFile)} bestaat en leesbaar is.`,
].join("\n");
}
if (message.includes("Geen url gevonden")) {
return [
"De URL van de training ontbreekt.",
`Controleer ${options.trainingConfigPath ?? "training.json"} en vul daar de sleutel "url" in.`,
].join("\n");
}
if (
message.includes("locator('[data-testid=\"page-button\"]')") ||
message.includes('locator(\'[data-testid="page-button"]\')') ||
message.includes('[data-testid="page-button"]')
) {
return [
"De browser heeft de OnlineAcademy-editor niet volledig herkend.",
"Verwachting: na het openen en inloggen moet links de hoofdstuk/pagina-navigatie zichtbaar worden.",
"Wat waarschijnlijk misging: de pagina is nog niet volledig geladen, de URL opent niet de editor, of er staat nog een tussenstap open na het inloggen.",
`Controleer de browser en kijk ook naar ${failureArtifact}.`,
].join("\n");
}
if (message.includes('[data-testid="editor-main"]')) {
return [
"De editor is niet zichtbaar geworden.",
"Verwachting: het hoofdcanvas van de editor moet in beeld komen.",
`Controleer de browser en kijk ook naar ${failureArtifact}.`,
].join("\n");
}
return [
"De run is gestopt door een fout.",
`Technische melding: ${message}`,
`Controleer de browser en kijk naar ${failureArtifact}.`,
].join("\n");
}
function parseArgs(argv) {
const options = {
trainingDir: null,
markdownPath: null,
targetUrl: null,
envFile: ".env",
execute: false,
save: false,
pauseAtEnd: false,
headed: true,
slowMo: 250,
outputDir: path.resolve("artifacts/oa-runner"),
};
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--training-dir") {
options.trainingDir = argv[++i];
} else if (arg === "--markdown") {
options.markdownPath = argv[++i];
} else if (arg === "--url") {
options.targetUrl = argv[++i];
} else if (arg === "--env-file") {
options.envFile = argv[++i];
} else if (arg === "--execute") {
options.execute = true;
} else if (arg === "--save") {
options.save = true;
} else if (arg === "--pause-at-end") {
options.pauseAtEnd = true;
} else if (arg === "--headless") {
options.headed = false;
} else if (arg === "--slowmo") {
options.slowMo = Number(argv[++i]);
} else if (arg === "--output-dir") {
options.outputDir = path.resolve(argv[++i]);
} else {
throw new Error(`Onbekend argument: ${arg}`);
}
}
if (options.trainingDir) {
const resolvedTrainingDir = path.resolve(options.trainingDir);
options.trainingDir = resolvedTrainingDir;
options.markdownPath = path.join(resolvedTrainingDir, "content.md");
options.trainingConfigPath = path.join(resolvedTrainingDir, "training.json");
if (options.outputDir === path.resolve("artifacts/oa-runner")) {
options.outputDir = path.resolve("artifacts", path.basename(resolvedTrainingDir));
}
}
if (!options.markdownPath) {
throw new Error(
"Gebruik --training-dir <map> of --markdown <bestand.md>."
);
}
return options;
}
async function applyTrainingConfig(options) {
if (!options.trainingConfigPath) {
if (!options.targetUrl) {
throw new Error(
"Gebruik --url <onlineacademy-edit-url> of werk met --training-dir <map> met training.json."
);
}
return options;
}
const rawConfig = await fs.readFile(options.trainingConfigPath, "utf8");
const config = JSON.parse(rawConfig);
const targetUrl = options.targetUrl ?? config.url;
if (!targetUrl) {
throw new Error(
`Geen url gevonden. Vul "url" in in ${options.trainingConfigPath} of geef --url mee.`
);
}
return {
...options,
targetUrl,
trainingConfig: config,
};
}
function buildPlan(courseModel) {
return {
training: courseModel.title,
chapters: courseModel.chapters.map((chapter) => ({
title: chapter.title,
pages: chapter.pages.map((page) => ({
title: page.title,
blocks: page.blocks.map((block) => ({
type: block.type,
summary:
block.data.title ??
block.data.question ??
block.data.quote ??
block.data.caption ??
block.data.content?.slice(0, 80) ??
"",
})),
})),
})),
};
}
async function ensureDir(dir) {
await fs.mkdir(dir, { recursive: true });
}
async function writeJson(filePath, value) {
await fs.writeFile(filePath, JSON.stringify(value, null, 2), "utf8");
}
async function capture(page, outputDir, name) {
await page.screenshot({
path: path.join(outputDir, `${name}.png`),
fullPage: true,
});
}
async function waitForUserReview() {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
return;
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
await new Promise((resolve) => {
rl.question("Reviewpauze actief. Druk op Enter om de browser te sluiten. ", resolve);
});
rl.close();
}
async function clickSaveButton(page) {
const candidates = [
page.locator(".save-button button").first(),
page.locator("button:has(.button__text:text-is('Opslaan als'))").first(),
page.locator("button").filter({ hasText: "Opslaan als" }).first(),
];
let clicked = false;
for (const candidate of candidates) {
const isVisible = await candidate.isVisible().catch(() => false);
if (!isVisible) {
continue;
}
await candidate.click();
clicked = true;
break;
}
if (!clicked) {
throw new Error("Knop 'Opslaan als' niet gevonden of niet zichtbaar.");
}
await page.waitForLoadState("networkidle").catch(() => {});
await page.waitForTimeout(1000);
const conceptCandidates = [
page.getByRole("button", { name: /^Concept$/i }).first(),
page.getByRole("menuitem", { name: /^Concept$/i }).first(),
page.locator("button").filter({ hasText: /^Concept$/ }).first(),
page.locator("[role='menuitem']").filter({ hasText: /^Concept$/ }).first(),
page.locator("text=Concept").first(),
];
let conceptClicked = false;
for (const candidate of conceptCandidates) {
const isVisible = await candidate.isVisible().catch(() => false);
if (!isVisible) {
continue;
}
await candidate.click();
conceptClicked = true;
break;
}
if (!conceptClicked) {
throw new Error("Keuze 'Concept' niet gevonden of niet zichtbaar na 'Opslaan als'.");
}
await page.waitForLoadState("networkidle").catch(() => {});
await page.waitForTimeout(2000);
}
async function loginIfNeeded(page, email, password) {
await page.waitForLoadState("networkidle").catch(() => {});
const passwordField = page.locator('input[type="password"]');
if ((await passwordField.count()) === 0) {
logInfo("Geen loginformulier gevonden. Waarschijnlijk bestaat er al een geldige sessie.");
return;
}
const emailField = page.locator('input[type="email"], input[name="email"]');
if ((await emailField.count()) > 0 && (await emailField.first().isVisible().catch(() => false))) {
await emailField.first().fill(email);
}
await passwordField.first().fill(password);
const submitButton = page.locator('button[type="submit"]');
await submitButton.first().click();
await page
.waitForURL((url) => !url.href.startsWith("https://identity.onlineacademy.nl"), {
timeout: 10000,
})
.catch(async () => {
await passwordField.first().press("Enter").catch(() => {});
await page.waitForURL(
(url) => !url.href.startsWith("https://identity.onlineacademy.nl"),
{ timeout: 10000 }
);
});
await page.waitForLoadState("networkidle").catch(() => {});
}
async function verifyEditor(page) {
await page.waitForLoadState("networkidle").catch(() => {});
const editorMain = page.locator('[data-testid="editor-main"], .page__content, .page-blocks').first();
await editorMain.waitFor({ state: "visible", timeout: 15000 });
const leftSidebar = page.locator(".sidebar-left, [class*='sidebar-left']").first();
await leftSidebar.waitFor({ state: "visible", timeout: 15000 });
const blockPanel = page
.locator('[data-testid="drag-block-text"], .content-block, .dragblock-group__title, .sidebar-right')
.first();
await blockPanel.waitFor({ state: "visible", timeout: 15000 });
const pageCreateButton = page
.locator(".sidebar-left-actions button")
.filter({ hasText: "Pagina" })
.first();
const hasPageCreateButton = await pageCreateButton.isVisible().catch(() => false);
const pageListItem = page.locator(".sidebar-left .editor-chapter-wrapper, .sidebar-left [data-testid='page-button']").first();
const hasPageListItem = await pageListItem.isVisible().catch(() => false);
if (!hasPageCreateButton && !hasPageListItem) {
throw new Error(
"Editor gevonden, maar geen paginanavigatie of paginaknop zichtbaar."
);
}
}
async function getCurrentTrainingTitle(page) {
const titleArea = page.locator(".page-header textarea").first();
if ((await titleArea.count()) > 0) {
return (await titleArea.inputValue().catch(() => "")) || "";
}
return "";
}
async function clickUnique(locator, name) {
const count = await locator.count();
if (count !== 1) {
throw new Error(`${name} verwacht exact 1 element, gevonden: ${count}`);
}
await locator.click();
}
async function fillUnique(locator, value, name) {
const count = await locator.count();
if (count !== 1) {
throw new Error(`${name} verwacht exact 1 element, gevonden: ${count}`);
}
await locator.fill(value);
}
async function createPage(page, pageTitle) {
const pageButton = page
.locator(".sidebar-left-actions button")
.filter({ hasText: "Pagina" });
await clickUnique(pageButton, "Pagina knop");
const pageTitleArea = page.locator(".page-header textarea").first();
await pageTitleArea.waitFor({ state: "visible", timeout: 10000 });
await pageTitleArea.fill(pageTitle);
}
async function renameCurrentPage(page, pageTitle) {
const pageTitleArea = page.locator(".page-header textarea").first();
await pageTitleArea.waitFor({ state: "visible", timeout: 10000 });
await pageTitleArea.fill(pageTitle);
}
async function clearCurrentPageBlocks(page) {
for (let attempts = 0; attempts < 50; attempts += 1) {
const blocks = blockCards(page);
const count = await blocks.count();
if (count === 0) {
return;
}
const deleteButton = blocks
.nth(0)
.locator("button")
.filter({ hasText: "Verwijderen" })
.first();
if ((await deleteButton.count()) === 0) {
return;
}
await deleteButton.click({ force: true });
await page.waitForTimeout(300);
}
}
async function renameChapterByIndex(page, chapterIndex, chapterTitle) {
const chapterWrapper = page.locator(".sidebar-left .editor-chapter-wrapper").nth(chapterIndex);
await chapterWrapper.waitFor({ state: "visible", timeout: 10000 });
await chapterWrapper.locator(".chapter-header, .page__title, .input-container").first().click({
force: true,
});
await page.waitForTimeout(250);
const chapterInput = chapterWrapper
.locator("input[type='text'], textarea, [contenteditable='true']")
.filter({ visible: true })
.first();
await chapterInput.waitFor({ state: "visible", timeout: 10000 });
const tagName = await chapterInput.evaluate((element) => element.tagName);
if (tagName === "INPUT" || tagName === "TEXTAREA") {
await chapterInput.fill(chapterTitle);
} else {
await chapterInput.click();
await page.keyboard.press("Meta+A").catch(() => {});
await page.keyboard.press("Control+A").catch(() => {});
await page.keyboard.type(chapterTitle);
}
await page.waitForTimeout(500);
}
async function createChapter(page, chapterTitle) {
const chapterButton = page
.locator(".sidebar-left-actions button")
.filter({ hasText: "Nieuw hoofdstuk" });
const chapterWrappers = page.locator(".sidebar-left .editor-chapter-wrapper");
const beforeCount = await chapterWrappers.count();
await clickUnique(chapterButton, "Nieuw hoofdstuk knop");
await page.waitForTimeout(500);
const afterCount = await chapterWrappers.count();
const targetIndex = afterCount > beforeCount ? afterCount - 1 : Math.max(0, afterCount - 1);
const targetWrapper = chapterWrappers.nth(targetIndex);
await targetWrapper.locator(".chapter-header, .page__title, .input-container").first().click({
force: true,
});
await page.waitForTimeout(250);
const sidebarChapterInput = targetWrapper
.locator("input[type='text'], textarea, [contenteditable='true']")
.filter({ visible: true })
.first();
await sidebarChapterInput.waitFor({ state: "visible", timeout: 10000 });
const tagName = await sidebarChapterInput.evaluate((element) => element.tagName);
if (tagName === "INPUT" || tagName === "TEXTAREA") {
await sidebarChapterInput.fill(chapterTitle);
} else {
await sidebarChapterInput.click();
await page.keyboard.press("Meta+A").catch(() => {});
await page.keyboard.press("Control+A").catch(() => {});
await page.keyboard.type(chapterTitle);
}
await page.waitForTimeout(500);
}
function blockCards(page) {
return page.locator(
".page__content .page-blocks > *," +
" .page__content .content-block-instance," +
" .page__content .content-block"
);
}
async function snapshotBlocks(page) {
return page.evaluate(() => {
const elements = Array.from(
document.querySelectorAll(
".page__content .page-blocks > *, .page__content .content-block-instance, .page__content .content-block"
)
);
return elements.map((element, index) => ({
index,
key:
element.getAttribute("data-block-id") ??
element.getAttribute("data-testid") ??
element.id ??
null,
textSample: (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120),
}));
});
}
async function waitForNewBlockCount(page, previousCount) {
const locator = blockCards(page);
await page.waitForFunction(
(expected) => {
const elements = document.querySelectorAll(
".page__content .content-block-instance, .page__content .content-block"
);
return elements.length > expected;
},
previousCount,
{ timeout: 10000 }
);
return locator.count();
}
async function findInsertedBlock(page, beforeSnapshot) {
const afterSnapshot = await snapshotBlocks(page);
const beforeKeys = new Set(
beforeSnapshot.map((entry) => `${entry.index}|${entry.key ?? ""}|${entry.textSample}`)
);
for (const entry of afterSnapshot) {
const signature = `${entry.index}|${entry.key ?? ""}|${entry.textSample}`;
if (!beforeKeys.has(signature)) {
return blockCards(page).nth(entry.index);
}
}
if (afterSnapshot.length === beforeSnapshot.length + 1) {
return blockCards(page).nth(afterSnapshot.length - 1);
}
throw new Error("Nieuw ingevoegd blok niet eenduidig gevonden.");
}
async function dragBlockIntoCanvas(page, blockTestId) {
const source = page.locator(`[data-testid="${blockTestId}"]`);
const sourceCount = await source.count();
if (sourceCount !== 1) {
throw new Error(`Blokbron ${blockTestId} niet uniek gevonden: ${sourceCount}`);
}
const editorMain = page.locator('[data-testid="editor-main"]');
const sourceBox = await source.boundingBox();
const targetBox = await editorMain.boundingBox();
if (!sourceBox || !targetBox) {
throw new Error(`Geen bruikbare drag/drop-coordinaten voor ${blockTestId}`);
}
const existingCount = await blockCards(page).count();
let dropX = targetBox.x + targetBox.width * 0.5;
let dropY = targetBox.y + Math.min(220, targetBox.height * 0.35);
if (existingCount > 0) {
const lastBlock = blockCards(page).nth(existingCount - 1);
const lastBlockBox = await lastBlock.boundingBox();
if (lastBlockBox) {
dropX = lastBlockBox.x + lastBlockBox.width * 0.5;
dropY = Math.min(
lastBlockBox.y + lastBlockBox.height - 8,
targetBox.y + targetBox.height - 16
);
}
}
await page.mouse.move(
sourceBox.x + sourceBox.width / 2,
sourceBox.y + sourceBox.height / 2
);
await page.mouse.down();
await page.mouse.move(dropX, dropY, { steps: 20 });
await page.mouse.up();
}
async function fillTextBlock(insertedBlock, block) {
const titles = insertedBlock.locator('input[type="text"]').filter({ visible: true });
const editors = insertedBlock.locator(".ql-editor").filter({ visible: true });
const titleCount = await titles.count();
const editorCount = await editors.count();
if (block.data.title && titleCount > 0) {
await titles.nth(0).fill(block.data.title);
}
if (editorCount === 0) {
throw new Error("Geen rich text editor gevonden voor tekstblok.");
}
await editors.nth(0).click();
await editors.nth(0).fill("");
await editors.nth(0).type(
block.data.content
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim()
);
}
async function fillHeadingBlock(insertedBlock, block) {
const titles = insertedBlock.locator('input[type="text"]').filter({ visible: true });
const count = await titles.count();
if (count === 0) {
throw new Error("Geen titelveld gevonden voor kopblok.");
}
await titles.nth(0).fill(block.data.title);
}
async function fillQuoteBlock(insertedBlock, block) {
const fields = insertedBlock
.locator("input[type='text'], textarea")
.filter({ visible: true });
const count = await fields.count();
if (count < 2) {
throw new Error("Onvoldoende tekstvelden gevonden voor quoteblok.");
}
await fields.nth(0).fill(block.data.quote);
await fields.nth(1).fill(block.data.author ?? "");
}
async function fillImageBlock(page, insertedBlock, block, contentBaseDir) {
if (!block.data.sourcePath) {
throw new Error("Afbeeldingsblok mist bronbestand.");
}
const absolutePath = resolveTrainingAssetPath(contentBaseDir, block.data.sourcePath);
await fs.access(absolutePath).catch(() => {
throw new Error(
`Afbeelding niet gevonden: ${block.data.sourcePath}. Verwacht pad: ${absolutePath}`
);
});
const fileInput = insertedBlock.locator("input[type='file']").first();
if ((await fileInput.count()) === 0) {
throw new Error("Geen file input gevonden voor afbeeldingsblok.");
}
await fileInput.setInputFiles(absolutePath);
await page.waitForTimeout(1000);
const visibleFields = insertedBlock
.locator("input[type='text'], textarea")
.filter({ visible: true });
const values = [block.data.title ?? "", block.data.caption ?? "", block.data.alt ?? ""].filter(
(value) => value !== ""
);
for (let i = 0; i < values.length; i += 1) {
if (i >= (await visibleFields.count())) {
break;
}
await visibleFields.nth(i).fill(values[i]);
}
}
async function setToggleState(toggleLocator, enabled) {
if ((await toggleLocator.count()) === 0) {
return false;
}
const current = await toggleLocator.evaluate((element) => {
if (element instanceof HTMLInputElement && element.type === "checkbox") {
return element.checked;
}
const ariaChecked = element.getAttribute("aria-checked");
if (ariaChecked === "true") {
return true;
}
if (ariaChecked === "false") {
return false;
}
return element.classList.contains("checked") || element.classList.contains("active");
});
if (current !== enabled) {
await toggleLocator.click({ force: true });
await toggleLocator.page().waitForTimeout(250);
}
return true;
}
async function clickControlByText(insertedBlock, pattern) {
const matched = await insertedBlock.evaluate((element, sourcePattern) => {
const regex = new RegExp(sourcePattern, "i");
const controls = Array.from(element.querySelectorAll("button, [role='button'], label, div"));
for (const control of controls) {
const text = (control.textContent ?? "").replace(/\s+/g, " ").trim();
if (!regex.test(text)) {
continue;
}
const target =
control.closest("button, [role='button']") ??
control.querySelector("button, [role='button']") ??
control;
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
return true;
}
return false;
}, pattern.source);
if (!matched) {
throw new Error(`Control met label ${pattern} niet gevonden in tabelblok.`);
}
}
async function fillTableBlock(page, insertedBlock, block) {
const rows = block.data.rows ?? [];
const rowCount = rows.length;
const columnCount = rows.reduce((max, row) => Math.max(max, row.length), 0);
if (rowCount === 0 || columnCount === 0) {
throw new Error("Tabelblok moet minimaal 1 rij en 1 kolom hebben.");
}
if (rowCount > MAX_TABLE_ROWS) {
throw new Error(`Tabelblok mag maximaal ${MAX_TABLE_ROWS} rijen hebben.`);
}
if (columnCount > MAX_TABLE_COLUMNS) {
throw new Error(`Tabelblok mag maximaal ${MAX_TABLE_COLUMNS} kolommen hebben.`);
}
const debugInfo = await insertedBlock.evaluate((element) => ({
controls: Array.from(element.querySelectorAll("button, label, [role='button'], th, td")).map(
(node) => ({
tag: node.tagName,
text: (node.textContent ?? "").replace(/\s+/g, " ").trim(),
ariaLabel: node.getAttribute("aria-label"),
title: node.getAttribute("title"),
rect: {
x: node.getBoundingClientRect().x,
y: node.getBoundingClientRect().y,
width: node.getBoundingClientRect().width,
height: node.getBoundingClientRect().height,
},
})
),
inputs: Array.from(element.querySelectorAll("input, textarea")).map((field) => ({
tag: field.tagName,
type: field.getAttribute("type"),
placeholder: field.getAttribute("placeholder"),
value: field.value ?? "",
visible:
window.getComputedStyle(field).display !== "none" &&
window.getComputedStyle(field).visibility !== "hidden",
})),
}));
console.log("TABLE_DEBUG", JSON.stringify(debugInfo, null, 2));
const visibleFields = insertedBlock
.locator("input[type='text'], textarea")
.filter({ visible: true });
if ((await visibleFields.count()) > 0 && block.data.title) {
await visibleFields.nth(0).fill(block.data.title);
}
const topHeaderToggle = insertedBlock
.getByRole("switch", { name: /top header|bovenste header/i })
.first();
const leftHeaderToggle = insertedBlock
.getByRole("switch", { name: /left header|linker header/i })
.first();
await setToggleState(topHeaderToggle, Boolean(block.data.topHeader)).catch(() => {});
await setToggleState(leftHeaderToggle, Boolean(block.data.leftHeader)).catch(() => {});
const headerCells = () =>
insertedBlock.locator("textarea[placeholder='Header'], textarea[placeholder='header']").filter({ visible: true });
const bodyCells = () =>
insertedBlock
.locator("textarea:not([placeholder='Header']):not([placeholder='header'])")
.filter({ visible: true });
const addColumnControl = async () => {
const button = insertedBlock.locator("button").filter({ hasText: /^Add column$/ }).first();
if ((await button.count()) === 0) {
throw new Error("Geen plusknop voor kolom toevoegen gevonden.");
}
await button.click({ force: true });
};
const addRowControl = async () => {
const button = insertedBlock.locator("button").filter({ hasText: /^Rij toevoegen$/ }).first();
if ((await button.count()) === 0) {
throw new Error("Geen plusknop voor rij toevoegen gevonden.");
}
await button.click({ force: true });
};
for (let attempts = 0; attempts < MAX_TABLE_COLUMNS + 2; attempts += 1) {
const currentColumns = (await headerCells().count()) || columnCount;
console.log(`TABLE_COLUMNS before attempt ${attempts + 1}: ${currentColumns}/${columnCount}`);
if (currentColumns >= columnCount) {
break;
}
await addColumnControl();
await page.waitForTimeout(250);
console.log(`TABLE_COLUMNS after attempt ${attempts + 1}: ${await headerCells().count()}`);
}
for (let attempts = 0; attempts < MAX_TABLE_ROWS + 2; attempts += 1) {
const currentColumns = (await headerCells().count()) || columnCount;
const currentRows = currentColumns > 0 ? Math.ceil((await bodyCells().count()) / currentColumns) : 0;
console.log(`TABLE_ROWS before attempt ${attempts + 1}: ${currentRows}/${rowCount - (block.data.topHeader ? 1 : 0)}`);
if (currentRows >= rowCount - (block.data.topHeader ? 1 : 0)) {
break;
}
await addRowControl();
await page.waitForTimeout(250);
const nextColumns = (await headerCells().count()) || columnCount;
const nextRows = nextColumns > 0 ? Math.ceil((await bodyCells().count()) / nextColumns) : 0;
console.log(`TABLE_ROWS after attempt ${attempts + 1}: ${nextRows}`);
}
const headers = headerCells();
const headerCount = await headers.count();
if (block.data.topHeader) {
for (let columnIndex = 0; columnIndex < Math.min(columnCount, headerCount); columnIndex += 1) {
await headers.nth(columnIndex).fill(rows[0][columnIndex] ?? "");
}
}
const body = bodyCells();
const bodyCount = await body.count();
const dataRows = block.data.topHeader ? rows.slice(1) : rows;
for (let rowIndex = 0; rowIndex < dataRows.length; rowIndex += 1) {
for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
const flatIndex = rowIndex * columnCount + columnIndex;
if (flatIndex >= bodyCount) {
return;
}
await body.nth(flatIndex).fill(dataRows[rowIndex][columnIndex] ?? "");
}
}
}
function questionTextareas(insertedBlock) {
return insertedBlock.locator("textarea").filter({ visible: true });
}
function visibleTextareas(insertedBlock) {
return insertedBlock.locator("textarea").filter({ visible: true });
}
function visibleInputs(insertedBlock) {
return insertedBlock.locator("input").filter({ visible: true });
}
async function answerFieldIndexes(insertedBlock) {
return visibleTextareas(insertedBlock).evaluateAll((elements) =>
elements
.map((element, index) => ({
index,
placeholder: element.getAttribute("placeholder") ?? "",
}))
.filter((item) => /antwoord/i.test(item.placeholder))
.map((item) => item.index)
);
}
async function answerFieldCount(insertedBlock) {
return (await answerFieldIndexes(insertedBlock)).length;
}
async function answerFieldLocator(insertedBlock, answerIndex) {
const indexes = await answerFieldIndexes(insertedBlock);
const fieldIndex = indexes[answerIndex];
if (fieldIndex === undefined) {
throw new Error(`Antwoordveld ${answerIndex + 1} niet gevonden.`);
}
return visibleTextareas(insertedBlock).nth(fieldIndex);
}
async function answerControlIndexes(insertedBlock, controlType) {
return visibleInputs(insertedBlock).evaluateAll((elements, expectedType) =>
elements
.map((element, index) => ({
index,
type: element.getAttribute("type") ?? "",
}))
.filter((item) => item.type === expectedType)
.map((item) => item.index),
controlType
);
}
async function answerControlLocator(insertedBlock, answerIndex, controlType) {
const indexes = await answerControlIndexes(insertedBlock, controlType);
const controlIndex = indexes[answerIndex];
if (controlIndex === undefined) {
throw new Error(
`${controlType === "checkbox" ? "Checkbox" : "Radiobutton"} voor antwoord ${
answerIndex + 1
} niet gevonden.`
);
}
return visibleInputs(insertedBlock).nth(controlIndex);
}
async function answerControlCount(insertedBlock, controlType) {
return (await answerControlIndexes(insertedBlock, controlType)).length;
}
function normalizedHtmlText(html) {
return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}
async function clickAnswerRemove(page, inputLocator) {
const box = await inputLocator.boundingBox();
if (!box) {
throw new Error("Geen coordinaten gevonden voor antwoordverwijdering.");
}
await page.mouse.click(box.x + box.width + 22, box.y + box.height / 2);
}
async function ensureAnswerInputCount(page, insertedBlock, expectedCount) {
const addButton = insertedBlock
.locator("button, [role='button']")
.filter({ hasText: "Antwoord toevoegen" })
.first();
for (let attempts = 0; attempts < expectedCount + 8; attempts += 1) {
const currentCount = await answerFieldCount(insertedBlock);
if (currentCount === expectedCount) {
return;
}
if (currentCount > expectedCount) {
await clickAnswerRemove(page, await answerFieldLocator(insertedBlock, currentCount - 1));
await page.waitForTimeout(250);
continue;
}
await addButton.click();
await page.waitForTimeout(250);
}
const finalCount = await answerFieldCount(insertedBlock);
throw new Error(
`Onvoldoende antwoordvelden gevonden na uitbreiden. Verwacht: ${expectedCount}, gevonden: ${finalCount}.`
);
}
async function selectChoiceAnswer(insertedBlock, answerIndex, controlType) {
const control = await answerControlLocator(insertedBlock, answerIndex, controlType);
await control.check({ force: true });
}
async function clearChoiceAnswers(insertedBlock, controlType) {
const count = await answerControlCount(insertedBlock, controlType);
for (let i = 0; i < count; i += 1) {
const control = await answerControlLocator(insertedBlock, i, controlType);
if (controlType === "checkbox") {
await control.uncheck({ force: true });
}
}
}
function questionField(insertedBlock) {
return insertedBlock
.locator("textarea:not([placeholder*='Antwoord']):not([placeholder*='antwoord'])")
.filter({ visible: true })
.first();
}
function feedbackEditors(insertedBlock) {
return insertedBlock.locator(".ql-editor").filter({ visible: true });
}
async function fillQuestionAndFeedback(insertedBlock, block, emptyQuestionError) {
const field = questionField(insertedBlock);
if ((await field.count()) === 0) {
throw new Error(emptyQuestionError);
}
const question = block.data.question?.trim() ?? "";
if (question.length > MAX_QUESTION_LENGTH) {
throw new Error(`Vraag is langer dan ${MAX_QUESTION_LENGTH} karakters.`);
}
await field.fill(question);
const feedback = block.data.feedback ? normalizedHtmlText(block.data.feedback) : "";
if (feedback.length > MAX_FEEDBACK_LENGTH) {
throw new Error(`Toelichting is langer dan ${MAX_FEEDBACK_LENGTH} karakters.`);
}
const editors = feedbackEditors(insertedBlock);
if ((await editors.count()) > 0 && feedback) {
await editors.nth(0).fill(feedback);
}
}
async function fillMultipleChoiceBlock(page, insertedBlock, block) {
const answers = block.data.answers ?? [];
if (answers.length < 2) {
throw new Error("Meerkeuzeblok vereist minimaal 2 antwoorden.");
}
await fillQuestionAndFeedback(insertedBlock, block, "Geen vraagveld gevonden voor meerkeuzeblok.");
await ensureAnswerInputCount(page, insertedBlock, answers.length);
for (let i = 0; i < answers.length; i += 1) {
await (await answerFieldLocator(insertedBlock, i)).fill(answers[i].text);
}
const correctIndex = answers.findIndex((answer) => answer.correct);
if (correctIndex === -1) {
throw new Error("Meerkeuzeblok vereist exact 1 correct antwoord.");
}
await selectChoiceAnswer(insertedBlock, correctIndex, "radio");
}
async function fillMultipleResponseBlock(page, insertedBlock, block) {
const answers = block.data.answers ?? [];
const correctIndexes = answers
.map((answer, index) => (answer.correct ? index : -1))
.filter((index) => index !== -1);
if (answers.length < 2) {
throw new Error("Multi-antwoordblok vereist minimaal 2 antwoorden.");
}
if (answers.length > MAX_MULTI_RESPONSE_ANSWERS) {
throw new Error(
`Multi-antwoordblok mag maximaal ${MAX_MULTI_RESPONSE_ANSWERS} antwoorden hebben.`
);
}
if (correctIndexes.length === 0) {
throw new Error("Multi-antwoordblok vereist minimaal 1 correct antwoord.");
}
await fillQuestionAndFeedback(
insertedBlock,
block,
"Geen vraagveld gevonden voor multi-antwoordblok."
);
await ensureAnswerInputCount(page, insertedBlock, answers.length);
for (let i = 0; i < answers.length; i += 1) {
await (await answerFieldLocator(insertedBlock, i)).fill(answers[i].text);
}
await clearChoiceAnswers(insertedBlock, "checkbox");
for (const index of correctIndexes) {
await selectChoiceAnswer(insertedBlock, index, "checkbox");
}
}
async function fillOpenQuestionBlock(insertedBlock, block) {
await fillQuestionAndFeedback(insertedBlock, block, "Geen velden gevonden voor open vraag.");
}
async function fillQuestionBlock(insertedBlock, block) {
const textareas = questionTextareas(insertedBlock);
const count = await textareas.count();
if (count === 0) {
throw new Error(`Geen velden gevonden voor ${block.type}.`);
}
await textareas.nth(0).fill(block.data.question);
}
async function fillBlock(page, insertedBlock, block, contentBaseDir) {
switch (block.type) {
case "text":
await fillTextBlock(insertedBlock, block);
return;
case "heading":
await fillHeadingBlock(insertedBlock, block);
return;
case "quote":
await fillQuoteBlock(insertedBlock, block);
return;
case "image":
await fillImageBlock(page, insertedBlock, block, contentBaseDir);
return;
case "table":
await fillTableBlock(page, insertedBlock, block);
return;
case "multiple-choice":
await fillMultipleChoiceBlock(page, insertedBlock, block);
return;
case "multiple-response":
await fillMultipleResponseBlock(page, insertedBlock, block);
return;
case "open-question":
await fillOpenQuestionBlock(insertedBlock, block);
return;
default:
throw new Error(`Invullen voor bloktype ${block.type} is nog niet geïmplementeerd.`);
}
}
function blockTypeToTestId(type) {
const mapping = {
text: "drag-block-text",
heading: "drag-block-heading",
quote: "drag-block-quote",
image: "drag-block-image",
table: "drag-block-table",
"multiple-choice": "drag-block-multiple-choice",
"multiple-response": "drag-block-multiple-response",
"open-question": "drag-block-open-question",
};
const testId = mapping[type];
if (!testId) {
throw new Error(`Geen drag-block mapping voor ${type}`);
}
return testId;
}
async function executePlan(page, courseModel, outputDir, contentBaseDir) {
if (courseModel.chapters.length === 0) {
throw new Error("Geen hoofdstuk/pagina in markdownmodel.");
}
const executableChapters = courseModel.chapters.filter((chapter) => chapter.pages.length > 0);
const totalChapters = executableChapters.length;
const totalPages = executableChapters.reduce((total, chapter) => total + chapter.pages.length, 0);
let completedPages = 0;
let pageSequence = 0;
for (let chapterIndex = 0; chapterIndex < executableChapters.length; chapterIndex += 1) {
const chapter = executableChapters[chapterIndex];
if (chapter.pages.length === 0) {
continue;
}
logInfo(
`Hoofdstuk ${chapterIndex + 1} van ${totalChapters}: ${chapter.title} (${chapter.pages.length} pagina's)`
);
await createChapter(page, chapter.title);
await capture(page, outputDir, `chapter-${chapterIndex + 1}-structure`);
for (let i = 0; i < chapter.pages.length; i += 1) {
const modelPage = chapter.pages[i];
pageSequence += 1;
completedPages += 1;
logInfo(
`Pagina ${completedPages} van ${totalPages}: ${modelPage.title} (${modelPage.blocks.length} blokken)`
);
if (i === 0) {
await renameCurrentPage(page, modelPage.title);
} else {
await createPage(page, modelPage.title);
}
await capture(page, outputDir, `page-${pageSequence}-structure`);
for (let j = 0; j < modelPage.blocks.length; j += 1) {
const block = modelPage.blocks[j];
if (!SUPPORTED_UI_BLOCKS.has(block.type)) {
throw new Error(`Bloktype ${block.type} valt buiten MVP UI-scope.`);
}
logInfo(
`Blok ${j + 1} van ${modelPage.blocks.length}: ${block.type} - ${summarizeBlock(block)}`
);
const beforeCount = await blockCards(page).count();
const beforeSnapshot = await snapshotBlocks(page);
await dragBlockIntoCanvas(page, blockTypeToTestId(block.type));
await waitForNewBlockCount(page, beforeCount);
const insertedBlock = await findInsertedBlock(page, beforeSnapshot);
await fillBlock(page, insertedBlock, block, contentBaseDir);
await capture(page, outputDir, `page-${pageSequence}-block-${j + 1}-${block.type}`);
}
}
}
}
async function main() {
let options = parseArgs(process.argv);
logStep("Voorbereiden");
logInfo(`Trainingsmap: ${options.trainingDir ?? "niet opgegeven"}`);
logInfo(`Env-bestand: ${path.resolve(options.envFile)}`);
await loadEnvFile(options.envFile).catch((error) => {
if (options.envFile === ".env" && error?.code === "ENOENT") {
return null;
}
throw new Error(`Kon env-bestand niet laden: ${path.resolve(options.envFile)}`);
});
options = await applyTrainingConfig(options);
const email = process.env.OA_EMAIL;
const password = process.env.OA_PASSWORD;
if (!email || !password) {
throw new Error(
`Zet OA_EMAIL en OA_PASSWORD in ${path.resolve(options.envFile)} of in de omgeving.`
);
}
const markdown = await fs.readFile(options.markdownPath, "utf8");
const courseModel = parseMarkdownToCourseModel(markdown);
const validationIssues = validateCourseModel(courseModel, SUPPORTED_UI_BLOCKS);
const plan = buildPlan(courseModel);
const planSummary = summarizePlan(plan);
await ensureDir(options.outputDir);
await writeJson(path.join(options.outputDir, "plan.json"), plan);
await writeJson(path.join(options.outputDir, "validation.json"), validationIssues);
logInfo(`Doel-URL: ${options.targetUrl}`);
logInfo(
`Inhoud gelezen: ${planSummary.chapterCount} hoofdstuk(ken), ${planSummary.pageCount} pagina('s), ${planSummary.blockCount} blok(ken).`
);
logInfo(describeValidationIssues(validationIssues));
logInfo(`Artifacts worden opgeslagen in: ${options.outputDir}`);
logInfo(
options.execute
? options.save
? "Modus: execute + save. De runner bouwt de inhoud op, klikt op 'Opslaan als' en kiest daarna 'Concept'."
: "Modus: execute zonder save. De runner bouwt de inhoud op en laat het scherm daarna open voor controle."
: "Modus: review. De runner controleert login en editor, maar voert geen invoeracties uit."
);
const browser = await chromium.launch({
headless: !options.headed,
slowMo: options.slowMo,
});
try {
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
try {
logStep("Browser openen");
await page.goto(options.targetUrl, { waitUntil: "domcontentloaded" });
logStep("Inloggen");
await loginIfNeeded(page, email, password);
logStep("Editor controleren");
await verifyEditor(page);
await capture(page, options.outputDir, "editor-ready");
const currentTitle = await getCurrentTrainingTitle(page);
logInfo(
currentTitle
? `Editor gevonden. Huidige geopende paginatitel: ${currentTitle}`
: "Editor gevonden."
);
if (!options.execute) {
logStep("Resultaat");
logInfo("Review geslaagd.");
logInfo("Er zijn geen muterende acties uitgevoerd.");
logInfo(`Controleer eventueel de screenshots in: ${options.outputDir}`);
return;
}
if (validationIssues.length > 0) {
throw new Error("Uitvoering geblokkeerd: markdown bevat bloktypes buiten de MVP-scope.");
}
logStep("Inhoud opbouwen");
await executePlan(
page,
courseModel,
options.outputDir,
path.dirname(options.markdownPath)
);
if (options.save) {
logStep("Opslaan");
logInfo("De inhoud is opgebouwd. De runner klikt nu op 'Opslaan als' en kiest daarna 'Concept'.");
await clickSaveButton(page);
await capture(page, options.outputDir, "after-save");
logStep("Resultaat");
logInfo("Execute-run afgerond.");
logInfo("De runner heeft op 'Opslaan als' geklikt en daarna 'Concept' gekozen.");
logInfo("Controleer in de browser of OnlineAcademy de save-actie bevestigt.");
} else {
logStep("Resultaat");
logInfo("Execute-run afgerond tot het reviewpunt.");
logInfo("De inhoud is opgebouwd in de editor.");
logInfo("Er is nog niet opgeslagen.");
logInfo("Het scherm blijft open zodat je kunt controleren wat er is ingevoerd.");
await capture(page, options.outputDir, "review-stop");
await waitForUserReview();
}
} catch (error) {
await capture(page, options.outputDir, "failure-state").catch(() => {});
throw new Error(buildFriendlyErrorMessage(error, options));
}
} finally {
await browser.close();
}
}
await main().catch((error) => {
console.error(`\n[RESULTAAT] Mislukt`);
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});