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 of --markdown ." ); } return options; } async function applyTrainingConfig(options) { if (!options.trainingConfigPath) { if (!options.targetUrl) { throw new Error( "Gebruik --url of werk met --training-dir 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; });