1028 lines
33 KiB
JavaScript
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();
|