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