Files
markdown_to_skillstown/onlineacademy-playwright-runner.mjs
T
2026-05-09 08:34:58 +02:00

1028 lines
33 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 parseArgs(argv) {
const options = {
markdownPath: null,
targetUrl: null,
execute: 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 === "--markdown") {
options.markdownPath = argv[++i];
} else if (arg === "--url") {
options.targetUrl = argv[++i];
} else if (arg === "--execute") {
options.execute = 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.markdownPath) {
throw new Error("Gebruik --markdown <bestand.md>.");
}
if (!options.targetUrl) {
throw new Error("Gebruik --url <onlineacademy-edit-url>.");
}
return options;
}
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 loginIfNeeded(page, email, password) {
await page.waitForLoadState("networkidle").catch(() => {});
const passwordField = page.locator('input[type="password"]');
if ((await passwordField.count()) === 0) {
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"]');
await editorMain.waitFor({ state: "visible", timeout: 15000 });
const chapterButton = page.locator('[data-testid="page-button"]');
await chapterButton.waitFor({ state: "visible", timeout: 15000 });
}
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) {
if (!block.data.sourcePath) {
throw new Error("Afbeeldingsblok mist bronbestand.");
}
const absolutePath = path.resolve(block.data.sourcePath);
await fs.access(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) {
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);
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) {
if (courseModel.chapters.length === 0) {
throw new Error("Geen hoofdstuk/pagina in markdownmodel.");
}
let pageSequence = 0;
for (let chapterIndex = 0; chapterIndex < courseModel.chapters.length; chapterIndex += 1) {
const chapter = courseModel.chapters[chapterIndex];
if (chapter.pages.length === 0) {
continue;
}
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;
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.`);
}
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);
await capture(page, outputDir, `page-${pageSequence}-block-${j + 1}-${block.type}`);
}
}
}
}
async function main() {
const options = parseArgs(process.argv);
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 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);
await ensureDir(options.outputDir);
await writeJson(path.join(options.outputDir, "plan.json"), plan);
await writeJson(path.join(options.outputDir, "validation.json"), validationIssues);
console.log("Plan:");
console.log(JSON.stringify(plan, null, 2));
if (validationIssues.length > 0) {
console.log("Validatieproblemen:");
console.log(JSON.stringify(validationIssues, null, 2));
}
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 {
await page.goto(options.targetUrl, { waitUntil: "domcontentloaded" });
await loginIfNeeded(page, email, password);
await verifyEditor(page);
await capture(page, options.outputDir, "editor-ready");
const currentTitle = await getCurrentTrainingTitle(page);
console.log(`Huidige geopende paginatitel: ${currentTitle}`);
if (!options.execute) {
console.log("Reviewmodus klaar. Geen muterende acties uitgevoerd.");
return;
}
if (validationIssues.length > 0) {
throw new Error("Uitvoering geblokkeerd: markdown bevat bloktypes buiten de MVP-scope.");
}
await executePlan(page, courseModel, options.outputDir);
console.log("Uitvoering gestopt na reviewpunt. Er is niet opgeslagen.");
await capture(page, options.outputDir, "review-stop");
if (options.pauseAtEnd) {
await waitForUserReview();
} else {
await page.waitForTimeout(8000);
}
} catch (error) {
await capture(page, options.outputDir, "failure-state").catch(() => {});
throw error;
}
} finally {
await browser.close();
}
}
await main();