feat(oa): add training-dir workflow and save flow for OnlineAcademy runner

Introduce a simpler training-based workflow for the OnlineAcademy Playwright runner.

Changes included:
- support --training-dir and automatic loading of content.md and training.json
- support --env-file so OA credentials no longer need manual shell sourcing
- resolve asset paths relative to the training directory
- improve human-readable review and execute output with step-by-step progress
- keep the browser open after execute when no save flag is used
- add optional --save flow that clicks 'Opslaan als' and then chooses 'Concept'
- add a concrete user guide for preparing and running training imports
- update handover documentation to reflect the current repo structure and workflow
- align the repo around trainings/<name>/content.md, training.json and assets/
- remove reliance on older pilot/test material in the documented main flow
This commit is contained in:
2026-05-09 11:29:35 +02:00
parent 8435e62e9a
commit 410f37a008
226 changed files with 1011 additions and 2226 deletions
+392 -36
View File
@@ -24,11 +24,181 @@ 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,
@@ -37,12 +207,18 @@ function parseArgs(argv) {
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--markdown") {
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") {
@@ -56,16 +232,53 @@ function parseArgs(argv) {
}
}
if (!options.markdownPath) {
throw new Error("Gebruik --markdown <bestand.md>.");
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.targetUrl) {
throw new Error("Gebruik --url <onlineacademy-edit-url>.");
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,
@@ -120,10 +333,63 @@ async function waitForUserReview() {
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;
}
@@ -151,10 +417,31 @@ async function loginIfNeeded(page, email, password) {
async function verifyEditor(page) {
await page.waitForLoadState("networkidle").catch(() => {});
const editorMain = page.locator('[data-testid="editor-main"]');
const editorMain = page.locator('[data-testid="editor-main"], .page__content, .page-blocks').first();
await editorMain.waitFor({ state: "visible", timeout: 15000 });
const chapterButton = page.locator('[data-testid="page-button"]');
await chapterButton.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) {
@@ -430,13 +717,17 @@ async function fillQuoteBlock(insertedBlock, block) {
await fields.nth(1).fill(block.data.author ?? "");
}
async function fillImageBlock(page, insertedBlock, block) {
async function fillImageBlock(page, insertedBlock, block, contentBaseDir) {
if (!block.data.sourcePath) {
throw new Error("Afbeeldingsblok mist bronbestand.");
}
const absolutePath = path.resolve(block.data.sourcePath);
await fs.access(absolutePath);
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) {
@@ -861,7 +1152,7 @@ async function fillQuestionBlock(insertedBlock, block) {
await textareas.nth(0).fill(block.data.question);
}
async function fillBlock(page, insertedBlock, block) {
async function fillBlock(page, insertedBlock, block, contentBaseDir) {
switch (block.type) {
case "text":
await fillTextBlock(insertedBlock, block);
@@ -873,7 +1164,7 @@ async function fillBlock(page, insertedBlock, block) {
await fillQuoteBlock(insertedBlock, block);
return;
case "image":
await fillImageBlock(page, insertedBlock, block);
await fillImageBlock(page, insertedBlock, block, contentBaseDir);
return;
case "table":
await fillTableBlock(page, insertedBlock, block);
@@ -910,25 +1201,36 @@ function blockTypeToTestId(type) {
return testId;
}
async function executePlan(page, courseModel, outputDir) {
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 < courseModel.chapters.length; chapterIndex += 1) {
const chapter = courseModel.chapters[chapterIndex];
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 {
@@ -942,12 +1244,15 @@ async function executePlan(page, courseModel, outputDir) {
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);
await fillBlock(page, insertedBlock, block, contentBaseDir);
await capture(page, outputDir, `page-${pageSequence}-block-${j + 1}-${block.type}`);
}
}
@@ -955,29 +1260,48 @@ async function executePlan(page, courseModel, outputDir) {
}
async function main() {
const options = parseArgs(process.argv);
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 de omgeving.");
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);
console.log("Plan:");
console.log(JSON.stringify(plan, null, 2));
if (validationIssues.length > 0) {
console.log("Validatieproblemen:");
console.log(JSON.stringify(validationIssues, null, 2));
}
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,
@@ -990,16 +1314,26 @@ async function main() {
});
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);
console.log(`Huidige geopende paginatitel: ${currentTitle}`);
logInfo(
currentTitle
? `Editor gevonden. Huidige geopende paginatitel: ${currentTitle}`
: "Editor gevonden."
);
if (!options.execute) {
console.log("Reviewmodus klaar. Geen muterende acties uitgevoerd.");
logStep("Resultaat");
logInfo("Review geslaagd.");
logInfo("Er zijn geen muterende acties uitgevoerd.");
logInfo(`Controleer eventueel de screenshots in: ${options.outputDir}`);
return;
}
@@ -1007,21 +1341,43 @@ async function main() {
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();
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 {
await page.waitForTimeout(8000);
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 error;
throw new Error(buildFriendlyErrorMessage(error, options));
}
} finally {
await browser.close();
}
}
await main();
await main().catch((error) => {
console.error(`\n[RESULTAAT] Mislukt`);
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});