Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:37:37 +02:00
commit ad8ad252a9
825 files changed
+124216

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { request } from "@playwright/test";
import {
OWNER_DISPLAY_NAME,
OWNER_EMAIL,
OWNER_PASSWORD,
WORKSPACE_NAME
} from "./specs/support/credentials";
const baseURL = process.env.KNOWLEDGE_HUB_E2E_BASE_URL ?? "http://localhost:1240";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** Where the pre-authenticated owner session's storage state is written, for specs
* that need to start already signed in (see `authenticatedStatePath`). Not committed —
* regenerated by every `globalSetup` run. */
export const authenticatedStatePath = path.join(__dirname, "../../artifacts/e2e-auth-state.json");
function extractCookie(response: Response): string | null {
// Node's fetch exposes combined Set-Cookie via getSetCookie() (undici); fall back to
// the raw header for older runtimes.
const cookies =
typeof response.headers.getSetCookie === "function"
? response.headers.getSetCookie()
: [response.headers.get("set-cookie")].filter((value): value is string => value !== null);
return cookies.map((cookie) => cookie.split(";")[0]).join("; ") || null;
}
async function completeFirstRunSetupIfNeeded(): Promise<void> {
const statusResponse = await fetch(`${baseURL}/api/v1/setup/status`);
const status = (await statusResponse.json()) as { setupRequired: boolean };
if (!status.setupRequired) {
// A previous run (or a manual setup) already completed this exact step —
// idempotent no-op rather than a hard failure, so repeated local `pnpm test:e2e`
// runs against the same stack keep working.
return;
}
const csrfResponse = await fetch(`${baseURL}/api/v1/auth/csrf`);
const csrfCookie = extractCookie(csrfResponse);
const csrf = (await csrfResponse.json()) as { token: string; headerName: string };
const completeResponse = await fetch(`${baseURL}/api/v1/setup/complete`, {
method: "POST",
headers: {
"Content-Type": "application/json",
[csrf.headerName]: csrf.token,
...(csrfCookie ? { Cookie: csrfCookie } : {})
},
body: JSON.stringify({
workspaceName: WORKSPACE_NAME,
displayName: OWNER_DISPLAY_NAME,
email: OWNER_EMAIL,
password: OWNER_PASSWORD
})
});
if (!completeResponse.ok) {
const body = await completeResponse.text();
throw new Error(
`E2E global setup failed to complete first-run setup (${completeResponse.status}): ${body}`
);
}
}
/**
* Logs in exactly once for the whole suite and persists the resulting session as
* Playwright storage state, so specs that only need to *already be signed in*
* (everything except the login/logout journey itself) can start from that state
* instead of each performing its own `POST /api/v1/auth/login`. The login endpoint is
* deliberately rate-limited (docs/12-auth-security.md — 10/minute); with roughly a
* dozen specs each logging in once, the previous "every spec logs in itself" design
* reliably tripped that limiter on a full local run. One login per full suite run
* keeps well under it while still exercising the real login endpoint (via
* auth-journey.spec.ts, which intentionally does not use this shared state).
*/
async function saveAuthenticatedState(): Promise<void> {
const api = await request.newContext({ baseURL });
const csrfResponse = await api.get("/api/v1/auth/csrf");
const csrf = (await csrfResponse.json()) as { token: string; headerName: string };
const loginResponse = await api.post("/api/v1/auth/login", {
headers: { [csrf.headerName]: csrf.token },
data: { email: OWNER_EMAIL, password: OWNER_PASSWORD }
});
if (!loginResponse.ok()) {
const body = await loginResponse.text();
throw new Error(`E2E global setup failed to log in the shared owner session (${loginResponse.status()}): ${body}`);
}
await api.storageState({ path: authenticatedStatePath });
await api.dispose();
}
export default async function globalSetup(): Promise<void> {
await completeFirstRunSetupIfNeeded();
await saveAuthenticatedState();
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@itworx/knowledge-hub-e2e",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"install:browsers": "playwright install --with-deps chromium"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.2",
"@playwright/test": "^1.62.1",
"@types/node": "^24.3.0"
}
}
+36
View File
@@ -0,0 +1,36 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.KNOWLEDGE_HUB_E2E_BASE_URL ?? "http://localhost:1240";
export default defineConfig({
testDir: "./specs",
// The suite intentionally shares one setup-created workspace and mutates its
// contents. Parallel workers would therefore race through the same UI state.
workers: 1,
globalSetup: "./global-setup.ts",
outputDir: "../../artifacts/playwright-results",
reporter: [
["list"],
["html", { outputFolder: "../../artifacts/playwright-report", open: "never" }]
],
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure"
},
projects: [
{
name: "chromium-desktop",
use: { ...devices["Desktop Chrome"] }
},
{
name: "chromium-mobile",
use: { ...devices["Pixel 7"] }
}
],
expect: {
timeout: 10_000
},
timeout: 30_000
});
+71
View File
@@ -0,0 +1,71 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { authenticatedStatePath } from "../global-setup";
import { expectAuthenticatedShell, fillMarkdown, openNewKnowledgePage } from "./support/v2";
test.use({ storageState: authenticatedStatePath });
// A minimal valid PNG signature — enough for the server's byte-signature detector to
// recognize it as image/png (docs/13-attachments.md); it does not need to be a
// structurally complete PNG for this purpose.
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01, 0x02, 0x03]);
test.describe("attachments", () => {
test("an editor can upload a file to a page, see it listed, and download it back", async ({ page }) => {
const uniqueTitle = `E2E-bijlagepagina ${Date.now()}`;
await page.goto("/");
await expectAuthenticatedShell(page);
await openNewKnowledgePage(page);
await page.getByLabel("Paginatitel").fill(uniqueTitle);
await fillMarkdown(page, "Pagina met een bijlage.");
await page.getByRole("button", { name: "Publiceren", exact: true }).click();
await expect(page.getByRole("heading", { name: uniqueTitle })).toBeVisible();
await page.getByText("Bijlagen").click();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
await page.getByLabel("Bijlage toevoegen").setInputFiles({
name: "foto.png",
mimeType: "image/png",
buffer: PNG_BYTES
});
const attachmentLink = page.getByRole("link", { name: "foto.png" });
await expect(attachmentLink).toBeVisible();
const href = await attachmentLink.getAttribute("href");
expect(href).toContain("/api/v1/attachments/");
const downloadResponse = await page.request.get(href!);
expect(downloadResponse.ok()).toBeTruthy();
expect(downloadResponse.headers()["content-type"]).toBe("image/png");
const body = await downloadResponse.body();
expect(Buffer.compare(body, PNG_BYTES)).toBe(0);
});
test("an executable disguised with a document extension is rejected", async ({ page }) => {
const uniqueTitle = `E2E-bijlageweigering ${Date.now()}`;
await page.goto("/");
await expectAuthenticatedShell(page);
await openNewKnowledgePage(page);
await page.getByLabel("Paginatitel").fill(uniqueTitle);
await fillMarkdown(page, "Pagina zonder toegestane bijlage.");
await page.getByRole("button", { name: "Publiceren", exact: true }).click();
await expect(page.getByRole("heading", { name: uniqueTitle })).toBeVisible();
await page.getByText("Bijlagen").click();
await page.getByLabel("Bijlage toevoegen").setInputFiles({
name: "factuur.pdf.exe",
mimeType: "application/pdf",
buffer: Buffer.from([0x4d, 0x5a, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00])
});
await expect(page.getByRole("alert")).toBeVisible();
await expect(page.getByRole("link", { name: "factuur.pdf.exe" })).not.toBeVisible();
});
});
+56
View File
@@ -0,0 +1,56 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { OWNER_DISPLAY_NAME, OWNER_EMAIL, OWNER_PASSWORD } from "./support/credentials";
test.describe("login / shell / logout journey", () => {
test("a user can log in with the keyboard alone, reach the shell, and log out", async ({
page
}) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Aanmelden" })).toBeVisible();
// Keyboard-only: no page.click() anywhere in this test.
await page.getByLabel("E-mailadres").click();
await page.keyboard.type(OWNER_EMAIL);
await page.keyboard.press("Tab");
await page.keyboard.type(OWNER_PASSWORD);
await page.keyboard.press("Enter");
await expect(page.getByRole("button", { name: "Accountmenu" })).toContainText(OWNER_DISPLAY_NAME);
await expect(page.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible();
const shellAccessibility = await new AxeBuilder({ page }).analyze();
expect(shellAccessibility.violations).toEqual([]);
// Account menu and logout remain keyboard-operable in the V2 shell.
await page.getByRole("button", { name: "Accountmenu" }).focus();
await page.keyboard.press("Enter");
await page.getByRole("menuitem", { name: "Afmelden" }).focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("heading", { name: "Aanmelden" })).toBeVisible();
});
test("an incorrect password shows a clear, accessible error and does not sign the user in", async ({
page
}) => {
await page.goto("/");
// Deliberately a non-existent account rather than the shared owner account: the
// owner is reused (and logged in) by every project in this suite, and repeatedly
// failing its password here would risk tripping real account lockout across
// parallel workers/projects. The generic error text and non-authenticated outcome
// are the same either way (see LoginUseCase — unknown account and wrong password
// are indistinguishable by design).
await page.getByLabel("E-mailadres").fill("onbekend@itworx.e2e");
await page.getByLabel("Wachtwoord", { exact: true }).fill("whatever-password-value");
await page.getByRole("button", { name: "Aanmelden" }).click();
const alert = page.getByRole("alert");
await expect(alert).toBeVisible();
await expect(alert).toContainText("onjuist");
// Still on the login screen, not silently signed in.
await expect(page.getByRole("heading", { name: "Aanmelden" })).toBeVisible();
});
});
+21
View File
@@ -0,0 +1,21 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
test("health endpoints and the login screen are reachable and accessible", async ({
page,
request
}) => {
const health = await request.get("/health/live");
expect(health.ok()).toBeTruthy();
const ready = await request.get("/health/ready");
expect(ready.ok()).toBeTruthy();
// global-setup.ts has already completed first-run setup for the shared stack, so an
// unauthenticated visitor now reaches the login screen, not the setup form.
await page.goto("/");
await expect(page.getByRole("heading", { name: "Aanmelden" })).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
});
+103
View File
@@ -0,0 +1,103 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { authenticatedStatePath } from "../global-setup";
import { expectAuthenticatedShell, fillMarkdown, openNewKnowledgePage } from "./support/v2";
// Reuses the single shared login performed once in global-setup.ts (the login
// endpoint is rate-limited — see that file's comment) rather than each test signing
// in for itself; only auth-journey.spec.ts exercises the login endpoint directly.
test.use({ storageState: authenticatedStatePath });
test.describe("discovery: search, revisions, backlinks and tags", () => {
test("a page can be found by search, filtered by tag, and shows backlinks", async ({ page }) => {
const uniqueTitle = `E2E-zoekpagina ${Date.now()}`;
const tag = `e2etag${Date.now()}`;
await page.goto("/");
await expectAuthenticatedShell(page);
await openNewKnowledgePage(page);
await page.getByLabel("Paginatitel").fill(uniqueTitle);
await fillMarkdown(page, "Inhoud met een uniek doorzoekbaar woord zoekbaarheidsproef.");
await page.getByLabel(/Labels/).fill(tag);
await page.getByRole("button", { name: "Publiceren", exact: true }).click();
await expect(page.getByRole("heading", { name: uniqueTitle })).toBeVisible();
// The tag appears as a chip on the page.
await expect(page.getByText(tag)).toBeVisible();
// A second page links to the first — proving backlinks.
await openNewKnowledgePage(page);
const linkingTitle = `E2E-linkpagina ${Date.now()}`;
await page.getByLabel("Paginatitel").fill(linkingTitle);
await fillMarkdown(page, `Zie [[${uniqueTitle}]] voor meer.`);
await page.getByRole("button", { name: "Publiceren", exact: true }).click();
await expect(page.getByRole("heading", { name: linkingTitle })).toBeVisible();
// Navigate to the target page via search, then check its backlinks panel.
await page.goto("/search");
await expect(page.getByRole("heading", { name: "Zoeken" })).toBeVisible();
const searchAccessibility = await new AxeBuilder({ page }).analyze();
expect(searchAccessibility.violations).toEqual([]);
// Scoped to the search results list specifically: on a desktop viewport the
// permanent sidebar tree also shows a same-named entry once the page exists, so an
// unscoped locator would be ambiguous.
const searchResults = page.getByRole("list", { name: "Zoekresultaten" });
await page.getByLabel("Zoekterm").fill("zoekbaarheidsproef");
await page.getByRole("button", { name: "Zoekopdracht uitvoeren" }).click();
await expect(searchResults.getByRole("button", { name: new RegExp(uniqueTitle) })).toBeVisible();
// Tag filter narrows results to just the tagged page.
await page.getByLabel("Label").fill(tag);
await page.getByRole("button", { name: "Zoekopdracht uitvoeren" }).click();
await expect(searchResults.getByRole("button", { name: new RegExp(uniqueTitle) })).toBeVisible();
await searchResults.getByRole("button", { name: new RegExp(uniqueTitle) }).click();
await expect(page.getByRole("heading", { name: uniqueTitle })).toBeVisible();
// Scoped to the backlinks panel: on a desktop viewport the permanent sidebar tree
// also shows a same-named entry for the linking page.
await page.getByText("Pagina's die hierheen linken").click();
const backlinksPanel = page.locator(".backlinks");
await expect(backlinksPanel.getByRole("button", { name: linkingTitle })).toBeVisible();
});
test("revision history lets an editor preview and restore a past revision without losing history", async ({
page
}) => {
const originalTitle = `E2E-revisiepagina ${Date.now()}`;
await page.goto("/");
await expectAuthenticatedShell(page);
await openNewKnowledgePage(page);
await page.getByLabel("Paginatitel").fill(originalTitle);
await fillMarkdown(page, "Oorspronkelijke inhoud.");
await page.getByRole("button", { name: "Publiceren", exact: true }).click();
await expect(page.getByRole("heading", { name: originalTitle })).toBeVisible();
const updatedTitle = `${originalTitle} (bijgewerkt)`;
await page.getByRole("button", { name: "Bewerken" }).click();
await page.getByLabel("Paginatitel").fill(updatedTitle);
await page.getByRole("button", { name: "Wijzigingen publiceren" }).click();
await expect(page.getByRole("heading", { name: updatedTitle })).toBeVisible();
await page.getByText("Revisiegeschiedenis").click();
await expect(page.getByRole("button", { name: `Revisie 1: ${originalTitle}` })).toBeVisible();
const revisionAccessibility = await new AxeBuilder({ page }).analyze();
expect(revisionAccessibility.violations).toEqual([]);
await page.getByRole("button", { name: `Revisie 1: ${originalTitle}` }).click();
await expect(page.getByRole("heading", { name: `Revisie 1: ${originalTitle}` })).toBeVisible();
await page.getByRole("button", { name: "Deze revisie herstellen" }).click();
await expect(page.getByRole("heading", { name: originalTitle, exact: true })).toBeVisible();
// History is never rewritten: the "(bijgewerkt)" state remains revision 2. The
// history panel was already open before the restore and refreshes in place —
// it must not be re-toggled here (that would close it instead).
await expect(page.getByRole("button", { name: `Revisie 2: ${updatedTitle}` })).toBeVisible();
});
});
+67
View File
@@ -0,0 +1,67 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
import { authenticatedStatePath } from "../global-setup";
test.use({ storageState: authenticatedStatePath });
async function createProposal(page: Page): Promise<string> {
return page.evaluate(async () => {
const csrfResponse = await fetch("/api/v1/auth/csrf");
const csrf = await csrfResponse.json() as { token: string; headerName: string };
const response = await fetch("/api/v1/ingestion-proposals", {
method: "POST",
headers: { "content-type": "application/json", [csrf.headerName]: csrf.token, "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({ manifest: "# E2E mobiele beoordeling", correlationId: `e2e-review-${crypto.randomUUID()}` }),
});
if (!response.ok) throw new Error(`Voorstel maken mislukte (${response.status}): ${await response.text()}`);
return (await response.json() as { id: string }).id;
});
}
test.describe("intakebeoordeling: responsieve, toegankelijke werkruimte", () => {
test("houdt reviewpanelen en voortgang toegankelijk op desktop, tablet en mobiel", async ({ page, browserName }, testInfo) => {
test.skip(browserName !== "chromium", "Deze bounded responsive matrix gebruikt Chromium.");
const isMobileProject = testInfo.project.name === "chromium-mobile";
await page.setViewportSize(isMobileProject ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
await page.goto("/");
const proposalId = await createProposal(page);
await page.goto(`/knowledge/intake/${proposalId}`);
await expect(page.getByRole("heading", { name: "Voorstel beoordelen" })).toBeVisible();
await expect(page.locator('[data-testid="ingestion-progress"]')).toBeVisible();
await expect(page.locator(".proposal-sources-panel")).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
if (!isMobileProject) {
await expect(page.getByRole("region", { name: "Analyse en onderbouwing" })).toBeVisible();
await expect(page.locator('[data-mobile-step="decision"]')).toBeVisible();
await page.setViewportSize({ width: 1024, height: 900 });
await expect(page.locator(".mobile-review-navigator")).toBeVisible();
await expect(page.locator(".proposal-sources-panel")).toBeVisible();
await expect(page.getByRole("region", { name: "Analyse en onderbouwing" })).toBeVisible();
await expect(page.locator('[data-mobile-step="decision"]')).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.setViewportSize({ width: 390, height: 844 });
}
await expect(page.locator(".mobile-review-navigator")).toBeVisible();
await expect(page.locator('[data-mobile-step="evidence"]')).toBeHidden();
await expect(page.locator('[data-mobile-step="decision"]')).toBeHidden();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.getByRole("button", { name: "Volgende stap" }).focus();
await page.keyboard.press("Enter");
await expect(page.locator('[data-testid="ingestion-progress"]')).toHaveText("Stap 2 van 3: Onderbouwing");
await expect(page.locator('[data-mobile-step="sources"]')).toBeHidden();
await expect(page.getByRole("region", { name: "Analyse en onderbouwing" })).toBeVisible();
await expect(page.getByText("Waarschuwingen en conflicten")).toBeVisible();
await page.getByRole("button", { name: "Volgende stap" }).click();
await expect(page.locator('[data-mobile-step="decision"]')).toBeVisible();
await expect(page.getByRole("button", { name: "Goedkeuren" })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
const axe = new AxeBuilder({ page });
await expect(page.locator('[data-testid="ingestion-progress"]')).toBeVisible();
expect((await axe.analyze()).violations).toEqual([]);
});
});
+141
View File
@@ -0,0 +1,141 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { authenticatedStatePath } from "../global-setup";
import { expectAuthenticatedShell, fillMarkdown, openNewKnowledgePage, readMarkdown } from "./support/v2";
// Reuses the single shared login performed once in global-setup.ts (the login
// endpoint is rate-limited — see that file's comment) rather than each test signing
// in for itself; only auth-journey.spec.ts exercises the login endpoint directly.
test.use({ storageState: authenticatedStatePath });
test.describe("knowledge page editing journey", () => {
test("managed import markers stay stored and editable but never appear in the reader view", async ({ page }) => {
const uniqueTitle = `E2E-beheerde-bron ${Date.now()}`;
const notes = "Mijn byte-beschermde notitie.";
const markdown = [
"<!-- managed-source:start -->",
"# Veilige broninhoud",
"<!-- unknown-control -->",
"<script>alert('nooit uitvoeren')</script>",
"<!-- managed-source:end -->",
"",
"<!-- user-notes:start -->",
notes,
"<!-- user-notes:end -->"
].join("\n");
await page.goto("/");
await expectAuthenticatedShell(page);
await openNewKnowledgePage(page);
await page.getByLabel("Paginatitel").fill(uniqueTitle);
await fillMarkdown(page, markdown);
await page.getByLabel(/Labels/).fill("source:canvas, course:zc0026, managed-source");
await page.getByRole("button", { name: "Publiceren", exact: true }).click();
const article = page.locator("article.page-content");
await expect(article).toContainText("Veilige broninhoud");
await expect(article).toContainText(notes);
await expect(article).toContainText("unknown-control");
await expect(article).not.toContainText("managed-source");
await expect(article).not.toContainText("user-notes");
await expect(article.locator("script")).toHaveCount(0);
await expect(page.getByText("Broninformatie en beheerstatus")).toBeVisible();
await expect(page.locator(".tag-chip", { hasText: "source:canvas" })).toHaveCount(0);
await page.getByRole("button", { name: "Bewerken" }).click();
await expect(page.getByRole("note")).toContainText("Laat de vier importmarkeringen");
expect(await readMarkdown(page)).toBe(markdown);
const republished = markdown.replace("# Veilige broninhoud", "# Veilige broninhoud bijgewerkt");
await fillMarkdown(page, republished);
await page.getByRole("button", { name: "Wijzigingen publiceren" }).click();
await expect(article).toContainText("Veilige broninhoud bijgewerkt");
await expect(article).toContainText(notes);
await expect(article).not.toContainText("managed-source");
});
test("a page can be created, edited, moved to the trash and restored", async ({ page }) => {
const uniqueTitle = `E2E-testpagina ${Date.now()}`;
await page.goto("/");
await expectAuthenticatedShell(page);
// --- Create -----------------------------------------------------------------
await openNewKnowledgePage(page);
const createAccessibility = await new AxeBuilder({ page }).analyze();
expect(createAccessibility.violations).toEqual([]);
await page.getByLabel("Paginatitel").fill(uniqueTitle);
await fillMarkdown(page, "# Inleiding\n\nDit is een testpagina voor de E2E-suite.");
await page.getByRole("button", { name: "Publiceren", exact: true }).click();
await expect(page.getByRole("heading", { name: uniqueTitle })).toBeVisible();
await expect(page.getByText("Inleiding")).toBeVisible();
const viewAccessibility = await new AxeBuilder({ page }).analyze();
expect(viewAccessibility.violations).toEqual([]);
// --- Edit ---------------------------------------------------------------------
await page.getByRole("button", { name: "Bewerken" }).click();
await expect(page.getByRole("heading", { name: `Bewerken: ${uniqueTitle}` })).toBeVisible();
const updatedTitle = `${uniqueTitle} (bijgewerkt)`;
await page.getByLabel("Paginatitel").fill(updatedTitle);
await page.getByRole("button", { name: "Wijzigingen publiceren" }).click();
await expect(page.getByRole("heading", { name: updatedTitle })).toBeVisible();
// --- Trash and restore ---------------------------------------------------------
await page.getByRole("button", { name: "Meer pagina-acties" }).click();
await page.getByRole("menuitem", { name: "Naar prullenbak" }).click();
await expect(page.getByText("Deze pagina staat in de prullenbak.")).toBeVisible();
await page.getByRole("button", { name: "Herstellen" }).click();
await expect(page.getByText("Deze pagina staat in de prullenbak.")).not.toBeVisible();
});
test("publishing against a stale version shows an explicit conflict resolution flow", async ({
page,
context
}) => {
const uniqueTitle = `E2E-conflictpagina ${Date.now()}`;
await page.goto("/");
await expectAuthenticatedShell(page);
await openNewKnowledgePage(page);
await page.getByLabel("Paginatitel").fill(uniqueTitle);
await fillMarkdown(page, "Oorspronkelijke inhoud.");
await page.getByRole("button", { name: "Publiceren", exact: true }).click();
await expect(page.getByRole("heading", { name: uniqueTitle })).toBeVisible();
// A second tab opens the same shareable V2 deep link, simulating two concurrent
// editors without relying on transient component state.
const pageUrl = page.url();
const secondPage = await context.newPage();
await secondPage.goto(pageUrl);
await expectAuthenticatedShell(secondPage);
await expect(secondPage.getByRole("heading", { name: uniqueTitle })).toBeVisible();
await secondPage.getByRole("button", { name: "Bewerken" }).click();
await secondPage.getByLabel("Paginatitel").fill(`${uniqueTitle} (tweede editor)`);
// The first tab publishes first, advancing the page's revision/ETag.
await page.getByRole("button", { name: "Bewerken" }).click();
await page.getByLabel("Paginatitel").fill(`${uniqueTitle} (eerste editor)`);
await page.getByRole("button", { name: "Wijzigingen publiceren" }).click();
await expect(page.getByRole("heading", { name: `${uniqueTitle} (eerste editor)` })).toBeVisible();
// The second tab's publish attempt now targets a stale ETag and must surface an
// explicit conflict rather than silently overwriting the first editor's change.
await secondPage.getByRole("button", { name: "Wijzigingen publiceren" }).click();
await expect(secondPage.getByText("Versieconflict")).toBeVisible();
const conflictAccessibility = await new AxeBuilder({ page: secondPage }).analyze();
expect(conflictAccessibility.violations).toEqual([]);
await secondPage.getByRole("button", { name: "Serverversie gebruiken" }).click();
await expect(secondPage.getByLabel("Paginatitel")).toHaveValue(`${uniqueTitle} (eerste editor)`);
await secondPage.close();
});
});
+168
View File
@@ -0,0 +1,168 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { authenticatedStatePath } from "../global-setup";
import { expectAuthenticatedShell } from "./support/v2";
test.use({ storageState: authenticatedStatePath });
test.describe("V2 workspaces: projects, architecture decisions and runbooks", () => {
test("a project can be created, its status changed, and an update added to its timeline", async ({ page }) => {
const uniqueName = `E2E-project ${Date.now()}`;
await page.goto("/");
await expectAuthenticatedShell(page);
await page.goto("/projects");
await expect(page.getByRole("heading", { name: "Projecten", exact: true })).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
await page.getByRole("button", { name: "Nieuw project" }).click();
await page.getByLabel("Naam").fill(uniqueName);
await page.getByLabel("Samenvatting").fill("E2E-testproject.");
await page.getByRole("button", { name: "Project aanmaken" }).click();
await expect(page.getByRole("heading", { name: uniqueName })).toBeVisible();
await expect(page.getByText("Concept", { exact: true }).first()).toBeVisible();
const detailAccessibility = await new AxeBuilder({ page }).analyze();
expect(detailAccessibility.violations).toEqual([]);
// Only documented lifecycle transitions are selectable from the V2 workspace.
const status = page.getByLabel("Projectstatus wijzigen");
await expect(status.getByRole("option", { name: "Actief" })).toHaveCount(0);
await status.selectOption("Planned");
await expect(page.getByText("Gepland", { exact: true }).first()).toBeVisible();
await page.getByRole("button", { name: "Update", exact: true }).click();
await page.getByLabel("Titel").fill("Eerste voortgangsupdate");
await page.getByRole("button", { name: "Update toevoegen" }).click();
await expect(page.getByText("Eerste voortgangsupdate")).toBeVisible();
});
test("an ADR can be proposed and then accepted", async ({ page }) => {
const title = `E2E-ADR ${Date.now()}`;
const projectName = `E2E-ADR-project ${Date.now()}`;
await page.goto("/projects");
await expectAuthenticatedShell(page);
await page.getByRole("button", { name: "Nieuw project" }).click();
await page.getByLabel("Naam").fill(projectName);
await page.getByRole("button", { name: "Project aanmaken" }).click();
await page.getByRole("button", { name: "Beslissing", exact: true }).click();
await page.getByLabel("Titel").fill(title);
await page.getByLabel("Samenvatting").fill("Testbeslissing.");
await page.getByRole("button", { name: "Beslissing aanmaken" }).click();
await expect(page.getByRole("heading", { name: title })).toBeVisible();
await expect(page.getByText("Voorgesteld")).toBeVisible();
const decisionAccessibility = await new AxeBuilder({ page }).analyze();
expect(decisionAccessibility.violations).toEqual([]);
await page.getByRole("button", { name: "Accepteren" }).click();
await expect(page.getByText("Geaccepteerd")).toBeVisible();
});
// V2-M5: the "Nieuwe beslissing" form now exposes a "Vervangt beslissing" field
// (previously API-only, see CURRENT_STATE.md and ADR-014 decision 12). This proves
// the full supersession lineage end to end via the UI alone: propose+accept an
// original decision, propose+accept a second decision that supersedes it, and
// confirm both directions of the bidirectional lineage link render correctly —
// matching DecisionTests.Accepting_a_decision_that_supersedes_an_accepted_one_flips_
// the_original_to_superseded_with_bidirectional_links at the integration level.
test("a decision can supersede an already-accepted one, and the bidirectional lineage is reachable from the UI", async ({ page }) => {
const projectName = `E2E-supersede-project ${Date.now()}`;
const originalTitle = `E2E-ADR-original ${Date.now()}`;
const replacementTitle = `E2E-ADR-replacement ${Date.now()}`;
await page.goto("/projects");
await expectAuthenticatedShell(page);
await page.getByRole("button", { name: "Nieuw project" }).click();
await page.getByLabel("Naam").fill(projectName);
await page.getByRole("button", { name: "Project aanmaken" }).click();
await expect(page.getByRole("heading", { name: projectName })).toBeVisible();
// Original decision: propose then accept.
await page.getByRole("button", { name: "Beslissing", exact: true }).click();
await page.getByLabel("Titel").fill(originalTitle);
await page.getByLabel("Samenvatting").fill("Oorspronkelijke keuze.");
// A freshly created project has no Accepted decisions yet, so the only option is
// "Geen".
await expect(page.getByLabel("Vervangt beslissing (optioneel)")).toHaveValue("");
await page.getByRole("button", { name: "Beslissing aanmaken" }).click();
await expect(page.getByRole("heading", { name: originalTitle })).toBeVisible();
await page.getByRole("button", { name: "Accepteren" }).click();
await expect(page.getByText("Geaccepteerd")).toBeVisible();
await page.getByRole("button", { name: "← Terug naar beslissingen" }).click();
await expect(page.getByRole("heading", { name: projectName })).toBeVisible();
// Replacement decision: now the original, Accepted decision is a selectable
// supersession target.
await page.getByRole("button", { name: "Beslissing", exact: true }).click();
await page.getByLabel("Titel").fill(replacementTitle);
await page.getByLabel("Samenvatting").fill("Herziene keuze na evaluatie.");
const supersedesField = page.getByLabel("Vervangt beslissing (optioneel)");
// <option> elements report as Playwright-"hidden" while their <select> is closed
// (same reason the pre-existing "Actief"-option-count assertion above uses
// toHaveCount rather than toBeVisible) — assert presence by count, not visibility.
await expect(supersedesField.getByRole("option", { name: /Oorspronkelijke keuze\./ })).toHaveCount(1);
// A brand-new project's decisions are numbered from 1 (ADR-014 decision 3: numbering
// is scoped per project), so the just-accepted original decision is always ADR-1.
await supersedesField.selectOption({ label: "ADR-1: Oorspronkelijke keuze." });
await page.getByRole("button", { name: "Beslissing aanmaken" }).click();
await expect(page.getByRole("heading", { name: replacementTitle })).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
await page.getByRole("button", { name: "Accepteren" }).click();
await expect(page.getByText("Geaccepteerd")).toBeVisible();
// The replacement decision itself shows the "vervangt" direction of the lineage.
const replacementLineageLink = page.getByRole("button", { name: /^Vervangt ADR-\d+: Oorspronkelijke keuze\./ });
await expect(replacementLineageLink).toBeVisible();
const lineageAccessibility = await new AxeBuilder({ page }).analyze();
expect(lineageAccessibility.violations).toEqual([]);
// Following the lineage link shows the original decision now flipped to
// Superseded, with the "vervangen door" direction of the same bidirectional link.
await replacementLineageLink.click();
await expect(page.getByRole("heading", { name: originalTitle })).toBeVisible();
await expect(page.getByText("Vervangen", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: /^Vervangen door ADR-\d+: Herziene keuze na evaluatie\./ })).toBeVisible();
// A Superseded decision offers no further lifecycle actions.
await expect(page.getByRole("button", { name: "Accepteren" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Intrekken" })).toHaveCount(0);
});
test("a runbook can be created, shows its stale-validation state honestly, and can be validated", async ({ page }) => {
const uniqueTitle = `E2E-runbook ${Date.now()}`;
await page.goto("/runbooks");
await expectAuthenticatedShell(page);
await expect(page.getByRole("heading", { name: "Runbooks", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Nieuw runbook", exact: true }).click();
await page.getByLabel("Titel").fill(uniqueTitle);
await page.getByRole("button", { name: "Runbook aanmaken" }).click();
await expect(page.getByRole("heading", { name: uniqueTitle })).toBeVisible();
await expect(page.getByText(/moet opnieuw worden gevalideerd/)).toBeVisible();
const runbookAccessibility = await new AxeBuilder({ page }).analyze();
expect(runbookAccessibility.violations).toEqual([]);
await page.getByRole("button", { name: "Valideren tegen huidige revisie" }).click();
await expect(page.getByText(/Laatst gevalideerd op/)).toBeVisible();
await expect(page.getByText(/moet opnieuw worden gevalideerd/)).not.toBeVisible();
// The platform never offers to execute the procedure — only to view/validate it
// (docs/16-runbooks-and-adrs.md: "The platform never runs its commands.").
await expect(page.getByRole("button", { name: /uitvoeren/i })).not.toBeVisible();
});
});
+6
View File
@@ -0,0 +1,6 @@
/** Fixed owner account created once by global-setup.ts and reused read-only by every
* spec/project (desktop and mobile alike) against the shared running stack. */
export const OWNER_EMAIL = "eigenaar@itworx.e2e";
export const OWNER_PASSWORD = ["correct", "horse", "battery", "staple", "e2e"].join("-");
export const WORKSPACE_NAME = "ItWorx E2E Kennisbank";
export const OWNER_DISPLAY_NAME = "E2E Eigenaar";
+60
View File
@@ -0,0 +1,60 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
export async function expectAuthenticatedShell(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Accountmenu" })).toBeVisible();
}
export async function openPrimaryNavigation(page: Page): Promise<void> {
const toggle = page.getByRole("button", { name: "Navigatie openen" });
// toggle.isVisible() alone does not auto-wait — calling it immediately after
// page.goto() can race the shell's first paint under load, silently no-op-ing on a
// mobile viewport where the toggle genuinely exists but has not rendered yet.
// Found via a real, reproducible flake in a full-suite run (V2-M2 shell audit),
// not by inspection. waitFor's own timeout distinguishes "not visible yet" from
// "never visible" (desktop, where the toggle stays CSS-hidden and the primary
// navigation is already on screen without it).
try {
await toggle.waitFor({ state: "visible", timeout: 2000 });
} catch {
return;
}
await toggle.click();
}
export async function followPrimaryNavigation(page: Page, name: string): Promise<void> {
await openPrimaryNavigation(page);
await page.getByRole("navigation", { name: "Hoofdnavigatie" }).getByRole("link", { name, exact: true }).click();
}
export async function openNewKnowledgePage(page: Page): Promise<void> {
await page.goto("/knowledge/new");
await expect(page.getByRole("heading", { name: "Nieuwe pagina" })).toBeVisible();
}
export async function fillMarkdown(page: Page, markdown: string): Promise<void> {
const editor = page.getByRole("textbox", { name: "Inhoud (Markdown)" });
// Explicit select-all + delete before Locator.fill(), rather than relying on
// fill()'s own clear heuristic: CodeMirror's contenteditable surface was not
// always fully cleared by fill() alone when it already had content, under mobile/
// touch emulation specifically — found via a real V2-M3 test where a second
// fillMarkdown() on an existing page's editor produced garbled
// old-content-plus-new-content on the chromium-mobile project only (not desktop,
// same code), not by inspection. Still uses fill() (not page.keyboard.type()) for
// the actual insertion: several existing specs fill markdown containing literal
// "[[...]]" wiki-link syntax, which would interact with CodeMirror's
// closeBrackets extension if typed via real keystrokes — fill() bypasses that
// keystroke-level extension entirely, matching this helper's prior behavior.
await editor.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.press("Delete");
await editor.fill(markdown);
}
export function markdownEditor(page: Page) {
return page.getByRole("textbox", { name: "Inhoud (Markdown)" });
}
export async function readMarkdown(page: Page): Promise<string> {
return (await markdownEditor(page).locator(".cm-line").allTextContents()).join("\n");
}
@@ -0,0 +1,202 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { authenticatedStatePath } from "../global-setup";
import { expectAuthenticatedShell, fillMarkdown, markdownEditor, openNewKnowledgePage, readMarkdown } from "./support/v2";
/**
* V2-M3 LeafWiki-floor proof: every row of V2_PRODUCT_REQUIREMENTS.md §6 that is not
* already covered by knowledge-editing.spec.ts / discovery.spec.ts / attachments.spec.ts.
* Specifically: internal-link autocomplete, in-editor attachment insertion at the
* cursor (distinct from the page-view "Bijlagen" panel upload attachments.spec.ts
* already covers), split/write/preview view-mode switching, revision diff-view
* content, and the command palette's real content search (V2-M3 fix — see
* CommandPalette.tsx / V2_EVIDENCE.md).
*/
test.use({ storageState: authenticatedStatePath });
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01, 0x02, 0x03]);
test.describe("V2-M3 knowledge editor: LeafWiki-floor proofs", () => {
test("internal-link autocomplete suggests and inserts a real page reference", async ({ page }) => {
const targetTitle = `E2E-autocomplete-doel ${Date.now()}`;
await page.goto("/");
await expectAuthenticatedShell(page);
await openNewKnowledgePage(page);
await page.getByLabel("Paginatitel").fill(targetTitle);
await fillMarkdown(page, "Doelpagina voor de autocomplete-proef.");
await page.getByRole("button", { name: "Publiceren", exact: true }).click();
await expect(page.getByRole("heading", { name: targetTitle })).toBeVisible();
await openNewKnowledgePage(page);
await page.getByLabel("Paginatitel").fill(`E2E-linkbron ${Date.now()}`);
const editor = markdownEditor(page);
await editor.click();
// Type the full unique title (including its timestamp), not a fixed-length
// prefix: a truncated prefix is not unique against other pages this same spec
// (or a previous run against the shared E2E database) may have created with the
// same leading words — found via a real strict-mode-violation failure (two
// matching options), not by inspection.
await page.keyboard.type(`Zie [[${targetTitle}`);
const completions = page.getByRole("listbox", { name: "Completions" });
await expect(completions).toBeVisible();
const option = completions.getByRole("option", { name: new RegExp(targetTitle) });
await expect(option).toBeVisible();
// Click the option directly rather than pressing Enter: CodeMirror's own
// auto-close-brackets extension (basicSetup closeBrackets: true) already inserts
// a matching "]]" as soon as "[[" is typed, so a keyboard Enter's timing against
// the async, filtering completion popup was flaky in practice (found via a real
// failing run producing truncated/mangled inserted text) — a direct click is
// unambiguous and is also how a mouse user would accept a suggestion.
await option.click();
expect(await readMarkdown(page)).toContain(`[[${targetTitle}]]`);
});
test("an attachment inserted from the editor toolbar lands at the cursor as a real reference, distinct from the page-view panel upload", async ({ page }) => {
const uniqueTitle = `E2E-editor-bijlage ${Date.now()}`;
await page.goto("/");
await expectAuthenticatedShell(page);
await openNewKnowledgePage(page);
await page.getByLabel("Paginatitel").fill(uniqueTitle);
const editor = markdownEditor(page);
await editor.click();
await page.keyboard.type("Voor de afbeelding: ");
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
// The toolbar's "Bijlage invoegen" button forwards the click to this hidden file
// input (PageEditorPage.tsx) — set the file directly, matching how
// attachments.spec.ts already drives the equivalent page-view control.
await page.getByLabel("Bijlage selecteren").setInputFiles({
name: "toolbar-inline.png",
mimeType: "image/png",
buffer: PNG_BYTES
});
// V2-R1 (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md, audit finding H1):
// before the first publish, an attachment uploaded from the editor is now staged
// server-side and already gets its real, final, servable URL immediately — no
// placeholder "pending-attachment:" token exists anymore, so the same reference
// inserted at the cursor is already correct even before publish.
await expect.poll(() => readMarkdown(page)).toContain("toolbar-inline.png");
expect(await readMarkdown(page)).toMatch(/Voor de afbeelding: !?\[toolbar-inline\.png\]\(\/api\/v1\/attachments\//);
await expect(page.getByText("1 bijlage klaar — nog niet gepubliceerd")).toBeVisible();
await page.getByRole("button", { name: "Publiceren", exact: true }).click();
await expect(page.getByRole("heading", { name: uniqueTitle })).toBeVisible();
// The atomic commit re-points the already-real attachment to the new page in the
// same transaction as the page's own creation — the reader view must render a
// real image, not dead markdown.
const image = page.locator("article.page-content img[alt='toolbar-inline.png']");
await expect(image).toBeVisible();
const src = await image.getAttribute("src");
expect(src).toContain("/api/v1/attachments/");
});
test("write, split and preview view modes are all keyboard-operable and render the same content", async ({ page }) => {
const uniqueTitle = `E2E-weergavemodus ${Date.now()}`;
await page.goto("/");
await expectAuthenticatedShell(page);
await openNewKnowledgePage(page);
await page.getByLabel("Paginatitel").fill(uniqueTitle);
await fillMarkdown(page, "# Kop\n\nParagraaf voor weergavemodusproef.");
const modeGroup = page.getByRole("group", { name: "Editorweergave" });
const preview = page.getByRole("region", { name: "Live voorbeeld" }).locator("article.page-content");
// Dedicated preview mode (full pane) must render on every viewport.
await modeGroup.getByRole("button", { name: "Voorbeeld", exact: false }).click();
await expect(preview).toContainText("Paragraaf voor weergavemodusproef");
// Split mode is desktop-only by design (docs/design/v2/responsive-behavior.md:
// "At 390px, editor modes are tabs and never a compressed split" —
// design-system/responsive.css hides .editor-preview-pane specifically inside
// .mode-split below 760px). Only assert the split preview pane's visibility on a
// wide-enough viewport; on narrow viewports just confirm selecting it doesn't
// break the editor.
const viewport = page.viewportSize();
await modeGroup.getByRole("button", { name: "Split", exact: false }).click();
if (viewport && viewport.width >= 760) {
await expect(preview).toContainText("Paragraaf voor weergavemodusproef");
}
await expect(markdownEditor(page)).toBeVisible();
await modeGroup.getByRole("button", { name: "Schrijven", exact: false }).click();
await expect(markdownEditor(page)).toBeVisible();
});
test("the revision diff view shows real line-level differences, not just a restore button", async ({ page }) => {
const originalTitle = `E2E-diffweergave ${Date.now()}`;
await page.goto("/");
await expectAuthenticatedShell(page);
await openNewKnowledgePage(page);
await page.getByLabel("Paginatitel").fill(originalTitle);
await fillMarkdown(page, "Regel een.\nRegel twee.\nRegel drie.");
await page.getByRole("button", { name: "Publiceren", exact: true }).click();
await expect(page.getByRole("heading", { name: originalTitle })).toBeVisible();
// Only the middle line changes (same line count, first/last line untouched):
// revisionDiff.ts's buildLineDiff is a common-prefix/common-suffix diff, not a
// full LCS algorithm, so a scenario that also inserts/removes a line shifts every
// line after the change into "removed"+"added" even where content is identical —
// a real, working-as-designed simplification found while writing this test, not a
// defect V2-M3 needs to fix (out of scope: rewriting the diff algorithm). This
// scenario stays within the algorithm's intended single-line-edit case.
await page.getByRole("button", { name: "Bewerken" }).click();
await fillMarkdown(page, "Regel een.\nRegel twee gewijzigd.\nRegel drie.");
await page.getByRole("button", { name: "Wijzigingen publiceren" }).click();
await expect(page.getByRole("heading", { name: originalTitle })).toBeVisible();
await page.getByText("Revisiegeschiedenis").click();
await page.getByRole("button", { name: `Revisie 1: ${originalTitle}` }).click();
await expect(page.getByRole("group", { name: "Revisieweergave" })).toBeVisible();
const diff = page.locator(".revision-diff");
await expect(diff).toBeVisible();
await expect(diff.locator(".diff-line.removed")).toHaveCount(1);
await expect(diff.locator(".diff-line.added")).toHaveCount(1);
await expect(diff.locator(".diff-line.removed")).toContainText("Regel twee.");
await expect(diff.locator(".diff-line.added")).toContainText("Regel twee gewijzigd.");
await expect(diff.locator(".diff-line.equal")).toHaveCount(2);
const diffAccessibility = await new AxeBuilder({ page }).analyze();
expect(diffAccessibility.violations).toEqual([]);
});
test("the command palette searches real knowledge content, not just fixed navigation destinations", async ({ page }) => {
const uniqueTitle = `E2E-paletzoekpagina ${Date.now()}`;
await page.goto("/");
await expectAuthenticatedShell(page);
await openNewKnowledgePage(page);
await page.getByLabel("Paginatitel").fill(uniqueTitle);
await fillMarkdown(page, "Inhoud voor de opdrachtenpaletproef.");
await page.getByRole("button", { name: "Publiceren", exact: true }).click();
await expect(page.getByRole("heading", { name: uniqueTitle })).toBeVisible();
await page.goto("/");
await page.getByRole("button", { name: "Opdrachten en zoeken openen" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.getByLabel("Zoeken en opdrachten").fill(uniqueTitle.slice(0, 22));
const knowledgeGroup = page.getByRole("group", { name: "Kennisbank" });
await expect(knowledgeGroup.getByRole("option", { name: new RegExp(uniqueTitle) })).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
await knowledgeGroup.getByRole("option", { name: new RegExp(uniqueTitle) }).click();
await expect(page.getByRole("heading", { name: uniqueTitle })).toBeVisible();
await expect(page.getByRole("dialog")).toBeHidden();
});
});
@@ -0,0 +1,195 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
import { authenticatedStatePath } from "../global-setup";
import { expectAuthenticatedShell } from "./support/v2";
/**
* V2-M4: structured infrastructure facts, editorial profiles and manual overrides are
* visibly distinct and safely editable in the UI (ADR-023), and a proposed service has
* a real accept/reject review flow reachable from the UI — proven end to end against
* the real API, not just the repository-level InfrastructureSynchronizationTests.
* Synchronization itself stays API-first by design (ADR-023: an external inventory
* tool posts snapshots; there is deliberately no "trigger a sync" UI button, matching
* the existing V1 "API first" precedent for LeafWiki import etc.) — this spec seeds a
* snapshot directly via the API, matching how a real inventory agent would, and then
* exercises only the UI review/override flows.
*/
test.use({ storageState: authenticatedStatePath });
async function postWithCsrf(page: Page, path: string, data: unknown) {
const csrfResponse = await page.request.get("/api/v1/auth/csrf");
const csrf = (await csrfResponse.json()) as { token: string; headerName: string };
return page.request.post(path, { headers: { [csrf.headerName]: csrf.token }, data });
}
async function seedProposedService(page: Page, sourceKey: string, detectedName: string) {
const response = await postWithCsrf(page, "/api/v1/infrastructure/inventory/snapshots", {
sourceKey: "inventory-e2e",
sourceHash: `hash-${sourceKey}`,
capturedAt: new Date().toISOString(),
hosts: [
{
sourceKey: "host:e2e",
hostname: "E2E-Host",
operatingSystem: null,
kernelVersion: null,
architecture: null,
logicalCpuCount: 4,
totalMemoryBytes: 1_000_000
}
],
services: [
{
sourceKey,
hostSourceKey: "host:e2e",
detectedName,
runtimeKind: "container",
runtimeStatus: "running",
health: "Healthy",
imageReference: "example/e2e-service:1",
imageDigest: null,
runtimeUser: null,
readOnlyRootFilesystem: true,
noNewPrivileges: true,
privileged: false,
confidence: "High",
components: null,
endpoints: null,
networks: null,
volumes: null,
databases: null,
dependencies: null,
healthObservations: null,
deployments: null,
backups: null
}
]
});
expect(response.ok(), await response.text()).toBeTruthy();
}
test.describe("V2-M4 infrastructure review, editorial ownership and overrides", () => {
test("a proposed service is reviewable (accept) from the admin UI, then its facts and overrides are visibly distinct on its workspace", async ({ page }) => {
const sourceKey = `docker:e2e-accept-${Date.now()}`;
const detectedName = `e2e-accept-service-${Date.now()}`;
await seedProposedService(page, sourceKey, detectedName);
await page.goto("/admin");
await expectAuthenticatedShell(page);
await expect(page.getByRole("heading", { name: "Wijzigingen sinds snapshots" })).toBeVisible();
const proposalCard = page.locator(".inventory-proposals article", { hasText: detectedName });
await expect(proposalCard).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
await proposalCard.getByRole("button", { name: "Bevestigen" }).click();
await expect(proposalCard).toBeHidden();
// Confirmed via the review action — the summary list now shows it as Present, not
// Proposed, matching InfrastructureSynchronizationTests'
// An_administrator_can_accept_a_proposed_service_and_the_decision_survives_sync.
await page.goto("/infrastructure/services");
await expect(page.getByRole("heading", { name: "Services" })).toBeVisible();
const serviceLink = page.getByRole("link", { name: new RegExp(detectedName) });
await expect(serviceLink).toBeVisible();
await serviceLink.click();
await expect(page.getByRole("heading", { name: detectedName })).toBeVisible();
await page.getByRole("tab", { name: "Live facts" }).click();
const factsTable = page.locator(".data-surface table.data-table");
await expect(factsTable).toBeVisible();
// Every fact row still shows "Inventaris" (source-managed) as its ownership —
// ADR-023's "Information ownership" requirement made visible per value, not just
// as a generic legend.
await expect(factsTable.locator("tbody tr", { hasText: "Inventaris" }).first()).toBeVisible();
await expect(factsTable.getByText("Override", { exact: true })).toHaveCount(0);
const factsAccessibility = await new AxeBuilder({ page }).analyze();
expect(factsAccessibility.violations).toEqual([]);
// Add a manual override and confirm the same fact's row flips to "Override"
// ownership — the whole point of ADR-023's three-layer model made concrete in the
// UI, not just provable at the repository level.
await page.getByRole("button", { name: "Override toevoegen" }).click();
await page.getByLabel("Fact").selectOption("runtimeKind");
await page.getByLabel("Gecorrigeerde waarde").fill("virtual-machine");
await page.getByLabel("Reden").fill("E2E: automatische detectie was onjuist.");
await page.getByRole("button", { name: "Override opslaan" }).click();
// "virtual-machine" legitimately appears twice once saved (the FactsMatrix row
// and the ServiceOverrideManager's own override-list entry below it) — assert on
// the specific, scoped row rather than an ambiguous page-wide text match.
const overrideRow = factsTable.locator("tbody tr", { hasText: "Runtimecategorie" });
await expect(overrideRow.getByText("Override", { exact: true })).toBeVisible();
await expect(overrideRow).toContainText("virtual-machine");
const afterOverrideAccessibility = await new AxeBuilder({ page }).analyze();
expect(afterOverrideAccessibility.violations).toEqual([]);
// Removing the override reverts the row to its source-managed value and ownership.
await page.getByRole("button", { name: /Override voor runtimeKind verwijderen/ }).click();
await expect(page.getByText("Geen actieve overrides.")).toBeVisible();
await expect(overrideRow.getByText("Inventaris", { exact: true })).toBeVisible();
});
test("a proposed service can be rejected with a reason from the admin UI and is never confirmed", async ({ page }) => {
const sourceKey = `docker:e2e-reject-${Date.now()}`;
const detectedName = `e2e-reject-service-${Date.now()}`;
await seedProposedService(page, sourceKey, detectedName);
await page.goto("/admin");
await expectAuthenticatedShell(page);
const proposalCard = page.locator(".inventory-proposals article", { hasText: detectedName });
await expect(proposalCard).toBeVisible();
await proposalCard.getByRole("button", { name: "Afwijzen" }).click();
await proposalCard.getByLabel("Reden voor afwijzing").fill("E2E: geen bekende productiedienst.");
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
await proposalCard.getByRole("button", { name: "Afwijzing bevestigen" }).click();
await expect(proposalCard).toBeHidden();
// A rejected proposal must not appear as a confirmed, visible service.
await page.goto("/infrastructure/services");
await expect(page.getByRole("heading", { name: "Services" })).toBeVisible();
await expect(page.getByRole("link", { name: new RegExp(detectedName) })).toHaveCount(0);
});
test("a Reader sees infrastructure facts read-only: no review, override or editorial controls", async ({ page, baseURL }) => {
const readerEmail = `e2e-infra-reader-${Date.now()}@itworx.e2e`;
const readerPassword = "correct-horse-battery-staple-reader";
await page.goto("/");
const csrfResponse = await page.request.get("/api/v1/auth/csrf");
const csrf = (await csrfResponse.json()) as { token: string; headerName: string };
const createResponse = await page.request.post("/api/v1/admin/users", {
headers: { [csrf.headerName]: csrf.token },
data: { displayName: "E2E Infra Reader", email: readerEmail, password: readerPassword, role: "Reader" }
});
expect(createResponse.ok(), await createResponse.text()).toBeTruthy();
const readerContext = await page.context().browser()!.newContext({
baseURL,
storageState: { cookies: [], origins: [] }
});
const readerPage = await readerContext.newPage();
await readerPage.goto("/");
await readerPage.getByLabel("E-mailadres").fill(readerEmail);
await readerPage.getByLabel("Wachtwoord", { exact: true }).fill(readerPassword);
await readerPage.getByRole("button", { name: "Aanmelden" }).click();
await expect(readerPage.getByRole("button", { name: "Accountmenu" })).toBeVisible();
// The admin-only review panel must not be reachable at all (server-enforced,
// matching v2-routing-and-fidelity.spec.ts's existing /admin role-guard proof).
await readerPage.goto("/admin");
await expect(readerPage).toHaveURL(/\/$/);
await readerContext.close();
});
});
@@ -0,0 +1,134 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
import { authenticatedStatePath } from "../global-setup";
import { expectAuthenticatedShell } from "./support/v2";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* V2-M5: services and project workspaces.
*
* V2-M2's fidelity pass (docs/v2/V2_EVIDENCE.md "V2-M2") deliberately covered the
* *list/overview* screens only (dashboard, infrastructure overview, projects overview,
* knowledge tree) and narrowed the full per-route implementation-fidelity-checklist
* sweep to V2-M8. This spec adds the missing spot-check for the *detail/workspace*
* screens this milestone is actually about — project workspace (screen 10), service
* detail (screen 7) and runbook detail (screen 13) — captured with real, if modest,
* seeded content rather than an empty state, since Stitch's own references show
* populated data. Concrete match/divergence notes are recorded in V2_EVIDENCE.md
* "V2-M5", not just "screenshot exists".
*/
test.use({ storageState: authenticatedStatePath });
const screenshotDir = path.resolve(__dirname, "../../../artifacts/v2-m5/screenshots");
fs.mkdirSync(screenshotDir, { recursive: true });
async function postWithCsrf(page: Page, url: string, data: unknown) {
const csrfResponse = await page.request.get("/api/v1/auth/csrf");
const csrf = (await csrfResponse.json()) as { token: string; headerName: string };
return page.request.post(url, { headers: { [csrf.headerName]: csrf.token }, data });
}
test.describe("V2-M5 workspace fidelity spot-check", () => {
test("project, service and runbook detail workspaces are captured with real content for a fidelity comparison", async ({ page }) => {
const stamp = Date.now();
// --- Project workspace, with a milestone/decision/update so the panels are not
// all in their empty state, matching what screen 10 (project detail) shows.
const projectName = `Facturatieplatform ${stamp}`;
await page.goto("/projects");
await expectAuthenticatedShell(page);
await page.getByRole("button", { name: "Nieuw project" }).click();
await page.getByLabel("Naam").fill(projectName);
await page.getByLabel("Samenvatting").fill("Vernieuwing van het interne facturatieplatform.");
await page.getByRole("button", { name: "Project aanmaken" }).click();
await expect(page.getByRole("heading", { name: projectName })).toBeVisible();
await page.getByRole("button", { name: "Mijlpaal" }).click();
await page.getByLabel("Naam").fill("Bèta-release");
await page.getByRole("button", { name: "Mijlpaal toevoegen" }).click();
await expect(page.getByText("Bèta-release")).toBeVisible();
await page.getByRole("button", { name: "Update", exact: true }).click();
await page.getByLabel("Titel").fill("Databasemigratie afgerond");
await page.getByRole("button", { name: "Update toevoegen" }).click();
await expect(page.getByText("Databasemigratie afgerond")).toBeVisible();
await page.getByRole("button", { name: "Beslissing", exact: true }).click();
await page.getByLabel("Titel").fill("Kies event-driven facturatie");
await page.getByLabel("Samenvatting").fill("Ontkoppelt facturatie van het bestelproces.");
await page.getByRole("button", { name: "Beslissing aanmaken" }).click();
await expect(page.getByRole("heading", { name: "Kies event-driven facturatie" })).toBeVisible();
await page.goBack();
await expect(page.getByRole("heading", { name: projectName })).toBeVisible();
// A history navigation can still be resolving the route's lazy chunk while the
// heading assertion already passes against the outgoing render. Scanning at that
// moment reports the transient empty document -- including "page must have a
// level-one heading" -- rather than the workspace under test. Seen once as a
// 35-violation failure between two clean runs.
await page.waitForLoadState("networkidle");
const projectAccessibility = await new AxeBuilder({ page }).analyze();
expect(projectAccessibility.violations).toEqual([]);
await page.screenshot({ path: path.join(screenshotDir, "10-project-workspace-populated.png"), fullPage: true });
// --- Runbook detail, validated (so it is not stuck in its initial stale-only
// state), matching screen 13 (runbook detail).
await page.goto("/runbooks");
await page.getByRole("button", { name: "Nieuw runbook", exact: true }).click();
await page.getByLabel("Titel").fill(`PostgreSQL failover ${stamp}`);
await page.getByRole("button", { name: "Runbook aanmaken" }).click();
await expect(page.getByRole("heading", { name: `PostgreSQL failover ${stamp}` })).toBeVisible();
await page.getByRole("button", { name: "Valideren tegen huidige revisie" }).click();
await expect(page.getByText(/Laatst gevalideerd op/)).toBeVisible();
const runbookAccessibility = await new AxeBuilder({ page }).analyze();
expect(runbookAccessibility.violations).toEqual([]);
await page.screenshot({ path: path.join(screenshotDir, "13-runbook-detail-validated.png"), fullPage: true });
// --- Service detail, accepted from a seeded proposal (same mechanism as
// v2-infrastructure-review.spec.ts), matching screen 7 (service detail).
const sourceKey = `docker:e2e-fidelity-${stamp}`;
const detectedName = `facturatie-api-${stamp}`;
const snapshotResponse = await postWithCsrf(page, "/api/v1/infrastructure/inventory/snapshots", {
sourceKey: "inventory-e2e",
sourceHash: `hash-fidelity-${stamp}`,
capturedAt: new Date().toISOString(),
hosts: [
{
sourceKey: "host:e2e-fidelity", hostname: "E2E-Fidelity-Host", operatingSystem: "Linux",
kernelVersion: null, architecture: "x86_64", logicalCpuCount: 8, totalMemoryBytes: 16_000_000_000
}
],
services: [
{
sourceKey, hostSourceKey: "host:e2e-fidelity", detectedName, runtimeKind: "container",
runtimeStatus: "running", health: "Healthy", imageReference: "itworx/facturatie-api:2.3.0",
imageDigest: null, runtimeUser: "app", readOnlyRootFilesystem: true, noNewPrivileges: true,
privileged: false, confidence: "High", components: null, endpoints: null, networks: null,
volumes: null, databases: null, dependencies: null, healthObservations: null, deployments: null,
backups: null
}
]
});
expect(snapshotResponse.ok(), await snapshotResponse.text()).toBeTruthy();
await page.goto("/admin");
const proposalCard = page.locator(".inventory-proposals article", { hasText: detectedName });
await expect(proposalCard).toBeVisible();
await proposalCard.getByRole("button", { name: "Bevestigen" }).click();
await expect(proposalCard).toBeHidden();
await page.goto("/infrastructure/services");
await page.getByRole("link", { name: new RegExp(detectedName) }).click();
await expect(page.getByRole("heading", { name: detectedName })).toBeVisible();
const serviceAccessibility = await new AxeBuilder({ page }).analyze();
expect(serviceAccessibility.violations).toEqual([]);
await page.screenshot({ path: path.join(screenshotDir, "07-service-detail-populated.png"), fullPage: true });
});
});
@@ -0,0 +1,126 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { authenticatedStatePath } from "../global-setup";
import { expectAuthenticatedShell } from "./support/v2";
/**
* V2-M6: visualizations, motion, matrices and dashboard.
*
* `ServerMap`/`ServiceTopology`/`ProjectSignature` and `DataTable` were already
* implemented from the prior Codex session (see docs/v2/V2_ROADMAP.md's V2-M6
* entry). This spec adds the missing real proofs for this milestone's specific
* acceptance criteria: reduced-motion renders a *genuine final state* (not just
* "axe is clean", which src/visualizations/*.test.tsx and the pre-existing
* v2-routing-and-fidelity.spec.ts reduced-motion test already cover at the
* component/axe level), a real matrix route has no page-level horizontal overflow,
* and dashboard widget order/visibility actually round-trips through Settings → a
* real reload of the dashboard route (not just localStorage keys, already proven at
* the unit level in PreferencesContext.test.tsx).
*/
test.use({ storageState: authenticatedStatePath });
test.describe("V2-M6 motion, matrices and dashboard personalization", () => {
test("the server map renders its final, fully-opaque state under prefers-reduced-motion, not a mid-animation frame", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/");
await expect(page.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible();
const serverMap = page.locator(".server-map");
await expect(serverMap).toBeVisible();
const opacities = await serverMap.locator("svg > g, svg > path").evaluateAll((elements) =>
elements.map((element) => window.getComputedStyle(element).opacity)
);
expect(opacities.length).toBeGreaterThan(0);
for (const opacity of opacities) {
expect(Number(opacity)).toBeCloseTo(1, 2);
}
});
test("a project's signature illustration renders its final, fully-opaque state under prefers-reduced-motion", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
const projectName = `E2E-motion-project ${Date.now()}`;
await page.goto("/projects");
await expectAuthenticatedShell(page);
await page.getByRole("button", { name: "Nieuw project" }).click();
await page.getByLabel("Naam").fill(projectName);
await page.getByRole("button", { name: "Project aanmaken" }).click();
await expect(page.getByRole("heading", { name: projectName })).toBeVisible();
const signature = page.locator(".project-signature");
// Below 900px the decorative signature is deliberately not rendered at all (OUX-6:
// operational data comes before decoration on a narrow screen), so there is no
// final animation state to assert there. Asserting it visible on every viewport
// would be asserting the opposite of the product decision.
const viewport = page.viewportSize();
test.skip(
(viewport?.width ?? 0) < 900,
"The project signature is intentionally hidden below 900px; nothing animates there.",
);
await expect(signature).toBeVisible();
// Only the ".project-core" diamond marker animates opacity (0 → 1 via `animate`);
// the decorative rings around it use a permanent, layered per-ring `opacity`
// *attribute* (e.g. 0.72, 0.64, ...) as a static design choice, not an
// animate-in artifact — asserting rings end at opacity 1 would be a real, wrong
// assumption about this component (confirmed by reading ProjectSignature.tsx,
// not guessed after a confusing first failure).
const coreOpacity = await signature.locator("svg .project-core").evaluate(
(element) => window.getComputedStyle(element).opacity
);
expect(Number(coreOpacity)).toBeCloseTo(1, 2);
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
});
test("the storage matrix causes no page-level horizontal overflow at a realistic viewport", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/infrastructure/storage");
await expect(page.getByRole("heading", { name: "Opslag en capaciteit" })).toBeVisible();
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
);
expect(overflow).toBeLessThanOrEqual(1);
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
});
test("hiding and reordering a dashboard widget in Settings actually changes the real dashboard, and survives a reload", async ({ page }) => {
await page.goto("/settings");
await expectAuthenticatedShell(page);
await expect(page.getByRole("heading", { name: "Dashboardwidgets" })).toBeVisible();
// Hide the "Projecten" widget entirely.
await page.getByRole("checkbox", { name: "Projecten tonen" }).uncheck();
// "Operationele signalen" is the default first widget since OUX-4 (critical
// signals before the topology illustration), so its "up" control is correctly
// disabled. Reorder in the direction that is actually available: move
// "Unraid-overzicht" above it.
await expect(page.getByRole("button", { name: "Operationele signalen omhoog" })).toBeDisabled();
await page.getByRole("button", { name: "Unraid-overzicht omhoog" }).click();
await page.goto("/");
await expect(page.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Projecten", exact: true })).toHaveCount(0);
const headingOrder = await page.getByRole("main").getByRole("heading", { level: 2 }).allTextContents();
expect(headingOrder.indexOf("Unraid-overzicht")).toBeLessThan(headingOrder.indexOf("Operationele signalen"));
// Reload the dashboard directly (not a fresh in-app navigation) to prove this is
// real persisted state, not just in-memory React state surviving a client route
// change.
await page.reload();
await expect(page.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Projecten", exact: true })).toHaveCount(0);
const headingOrderAfterReload = await page.getByRole("main").getByRole("heading", { level: 2 }).allTextContents();
expect(headingOrderAfterReload.indexOf("Unraid-overzicht")).toBeLessThan(
headingOrderAfterReload.indexOf("Operationele signalen")
);
// Restore the default so this test does not leak state into any other test that
// shares the same authenticated storage state / owner account.
await page.goto("/settings");
await page.getByRole("checkbox", { name: "Projecten tonen" }).check();
await page.getByRole("button", { name: "Unraid-overzicht omlaag" }).click();
});
});
+194
View File
@@ -0,0 +1,194 @@
import { execFileSync } from "node:child_process";
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
import { authenticatedStatePath } from "../global-setup";
import { expectAuthenticatedShell } from "./support/v2";
/**
* V2-M7: administration, personalization and missing CRUD.
*
* Closes three named V1 "API first, UI deferred" gaps (CURRENT_STATE.md "What is not
* yet complete") with a real admin UI: recovery-token issuance, trash-purge, and
* LeafWiki import. All three endpoints were already correctly Administrator-only and
* integration-tested at the API level (RecoveryTests, TrashPurgeTests,
* LeafWikiImportTests) — this spec proves each is now genuinely reachable and usable
* from the UI, not API-only.
*/
test.use({ storageState: authenticatedStatePath });
// Keep the ordinary local-stack name as the zero-configuration default, while
// allowing clean-room/release verification to target its isolated app container.
// Without this override the fixture is written into a different healthy stack and
// the preflight correctly rejects the path as absent from its own imports mount.
const CONTAINER_NAME =
process.env.KNOWLEDGE_HUB_E2E_APP_CONTAINER ?? "itworx-knowledge-hub-app-1";
const IMPORTS_ROOT = "/var/lib/knowledge-hub/imports";
function seedLeafWikiFixture(relativeDir: string, title: string) {
// Writes a minimal, synthetic Markdown fixture directly into the running app
// container's imports volume (never a real LeafWiki source — CLAUDE.md's
// read-only-source rule only ever applies to a real snapshot, which this is not).
// Uses `docker exec` from the test runner rather than a Playwright API call, since
// the import endpoint deliberately only ever reads a server-side mounted path, by
// design (docs/17-leafwiki-migration.md) — there is no upload endpoint to seed it
// through.
execFileSync("docker", [
"exec",
CONTAINER_NAME,
"sh",
"-c",
`mkdir -p '${IMPORTS_ROOT}/${relativeDir}' && printf '# ${title}\\n\\nDit is een synthetische testpagina voor de V2-M7 importproef.\\n' > '${IMPORTS_ROOT}/${relativeDir}/welkom.md'`
]);
}
test.describe("V2-M7 admin actions: recovery tokens, trash purge, LeafWiki import", () => {
test("an administrator can issue a one-time recovery token for another user from the admin UI", async ({ page }) => {
const targetEmail = `e2e-recovery-target-${Date.now()}@itworx.e2e`;
await page.goto("/admin");
await expectAuthenticatedShell(page);
await expect(page.getByRole("heading", { name: "Gebruikers", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Gebruiker toevoegen" }).click();
await page.getByLabel("Naam").fill("E2E Recovery Doel");
await page.getByLabel("E-mailadres").fill(targetEmail);
await page.getByLabel("Tijdelijk wachtwoord").fill("correct-horse-battery-staple");
await page.getByRole("button", { name: "Gebruiker aanmaken" }).click();
await expect(page.getByText(targetEmail)).toBeVisible();
const userRow = page.locator(".admin-row", { hasText: targetEmail });
await userRow.getByRole("button", { name: "Herstelcode aanmaken" }).click();
const tokenPanel = page.getByRole("heading", { name: `Eenmalige herstelcode voor ${targetEmail}` });
await expect(tokenPanel).toBeVisible();
await expect(page.getByTestId("recovery-token")).not.toBeEmpty();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
const tokenSection = page.locator("section", { has: tokenPanel });
await tokenSection.getByRole("button", { name: "Sluiten" }).click();
await expect(tokenPanel).toBeHidden();
});
test("an administrator can trigger a trash purge from the admin UI, and it honestly reports nothing eligible yet", async ({ page }) => {
await page.goto("/admin");
await expectAuthenticatedShell(page);
await expect(page.getByRole("heading", { name: "Prullenbak" })).toBeVisible();
await page.getByRole("button", { name: "Prullenbak legen" }).click();
// PurgeTrashUseCase only removes pages whose retention window has already
// elapsed — a fresh test run has nothing eligible, and the UI must say so
// honestly rather than implying something was deleted.
await expect(
page.getByText("Geen pagina's kwamen in aanmerking voor definitieve verwijdering")
).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
});
test("an administrator can run a full LeafWiki import (preflight, execute, rollback) from the admin UI", async ({ page }) => {
const relativeDir = `e2e-leafwiki-${Date.now()}`;
// The imported page's *title* has to be unique per run, not just the fixture
// directory. The local verification database is deliberately never reset, so any
// earlier run that failed before reaching its own rollback step leaves its
// imported page behind -- and a fixed title then makes both the "it exists" and
// the "it is gone after rollback" assertions ambiguous against those leftovers
// rather than against this run's own import.
const pageTitle = `Welkom bij LeafWiki ${relativeDir}`;
seedLeafWikiFixture(relativeDir, pageTitle);
await page.goto("/admin");
await expectAuthenticatedShell(page);
await expect(page.getByRole("heading", { name: "LeafWiki-import" })).toBeVisible();
await page.getByLabel("Relatief pad onder de importmap").fill(relativeDir);
await page.getByRole("button", { name: "Preflight uitvoeren" }).click();
await expect(page.getByText(/item\(s\) gevonden/)).toBeVisible();
const planTable = page.getByRole("table", { name: "Importplan" });
await expect(planTable).toBeVisible();
await expect(planTable.getByRole("cell", { name: pageTitle })).toBeVisible();
// No blockers for a clean synthetic fixture, so the import button is offered.
// exact: true since the curated-inventory panel elsewhere on this page has its
// own, differently-worded submit button that also happens to contain the word
// "importeren".
const importButton = page.getByRole("button", { name: "Importeren", exact: true });
await expect(importButton).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
// force: true, deliberately, after real investigation (not a reflex fix for a
// flaky click): on chromium-mobile only, immediately after the AxeBuilder scan
// above, Playwright's own actionability pre-check reported this exact button as
// intercepted by an unrelated earlier element (.form-error-summary) and retried
// for the full 30s timeout. Directly confirmed this was Playwright's own hit-test
// heuristic misfiring, not a real layout bug: getBoundingClientRect()/
// elementFromPoint() at the button's true center resolved correctly and stayed
// stable across 20 polls over 3s, and a raw page.mouse.move/down/up sequence at
// those exact coordinates clicked it immediately and completed the import
// successfully. force: true skips only that specific occlusion pre-check; the
// very next assertion still proves the click had its real, intended effect.
await importButton.click({ force: true });
await expect(page.getByText(/geïmporteerd/)).toBeVisible();
// Confirm the imported page is now real and reachable via the ordinary Knowledge
// module, not just reported as a count — checked from a separate tab so the
// admin page's own in-memory import-job state (jobId, executed result) is never
// lost to a reload; LeafWikiImportPanel deliberately keeps this state
// client-side only, matching every other admin action on this page, so a real
// reload here would just as legitimately reset it as leaving the page normally
// would (an operator would in practice roll back immediately, not navigate away
// first).
const knowledgeTab = await page.context().newPage();
await expect(await findImportedPage(knowledgeTab, pageTitle)).toHaveCount(1);
await knowledgeTab.close();
// Roll the import back, still on the same admin page/tab, and confirm it
// disappears again — proving rollback actually removes what it created, matching
// RollbackImportUseCase's contract.
await page.getByRole("button", { name: "Importtaak ongedaan maken" }).click();
await expect(page.getByText(/pagina's verwijderd/)).toBeVisible();
const knowledgeTabAfterRollback = await page.context().newPage();
await expect(await findImportedPage(knowledgeTabAfterRollback, pageTitle)).toHaveCount(0);
await knowledgeTabAfterRollback.close();
});
});
/**
* Confirms an imported page really exists in the ordinary Knowledge module, using the
* module's own search rather than the navigation tree.
*
* The tree was the obvious place to look and is the wrong one here. It loads 100 roots
* per request behind a "Meer laden" control, and the verification workspace is
* deliberately never reset, so it passed that boundary long ago: a freshly imported
* root is simply not in the first batch. Paging through it was tried first and made the
* test worse rather than better -- it turned one deterministic assertion into a loop
* whose outcome depended on how many pages the workspace happened to hold that day, and
* it then failed for a different reason on each run.
*
* Search answers the actual question -- is this page real and findable? -- in a single
* request, independently of corpus size, and behaves identically on both viewports. The
* locator is returned either way, so the caller can assert presence and, after the
* rollback, absence.
*/
async function findImportedPage(page: Page, title: string) {
await page.goto("/search");
await page.getByLabel("Zoekterm").fill(title);
await page.getByRole("button", { name: "Zoekopdracht uitvoeren" }).click();
// Either a result list or an explicit "no results" line; both mean the search
// finished, which a bare locator check on the title alone could not distinguish from
// "the request has not come back yet".
await expect(
page.getByRole("list", { name: "Zoekresultaten" }).or(page.getByText("Geen resultaten gevonden.")),
).toBeVisible();
return page.getByRole("button", { name: new RegExp(escapeForRegExp(title)) });
}
function escapeForRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -0,0 +1,173 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
import { authenticatedStatePath } from "../global-setup";
import { expectAuthenticatedShell, openPrimaryNavigation } from "./support/v2";
async function postWithCsrf(page: Page, url: string, data: unknown) {
const csrfResponse = await page.request.get("/api/v1/auth/csrf");
const csrf = (await csrfResponse.json()) as { token: string; headerName: string };
return page.request.post(url, { headers: { [csrf.headerName]: csrf.token }, data });
}
/**
* V2-M8: accessibility, performance and visual fidelity.
*
* Closes concrete, previously-unverified gaps found while re-reading the full axe
* coverage map across every prior V2 spec (V2_EVIDENCE.md "V2-M8"): the Login screen
* itself was never axe-scanned (every prior scan happened only after reaching the
* authenticated shell); `/activity` was never axe-scanned at all; the command palette
* and mobile primary navigation were only ever scanned in their closed/hidden state,
* never open; and no test verified 200% zoom/reflow anywhere, a real WCAG 2.2 AA
* requirement (`CLAUDE.md` §12) distinct from the axe ruleset itself.
*
* 200% zoom is simulated by halving the viewport (1280x900 -> 640x450), not via
* Chromium's non-standard `document.body.style.zoom`. Verified directly that the
* `zoom` CSS property does not change `window.innerWidth`/media-query evaluation at
* all (confirmed via a throwaway debug script — scrollWidth/clientWidth stayed
* within a few px of each other purely from zoom's own sub-pixel rounding, while the
* page's real responsive breakpoints never engaged), so it would not have exercised
* this app's actual responsive CSS the way a real user's browser zoom does. A halved
* viewport is what a real 200%-zoomed 1280px-wide window is equivalent to in CSS
* pixels, and correctly triggers the same breakpoints `responsive.css` already
* defines for narrow real viewports.
*/
test.describe("V2-M8 accessibility: previously unscanned screens and states", () => {
test("the login screen itself is axe-clean, both in its default and its error state", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Aanmelden" })).toBeVisible();
const defaultAccessibility = await new AxeBuilder({ page }).analyze();
expect(defaultAccessibility.violations).toEqual([]);
await page.getByLabel("E-mailadres").fill("onbekend@itworx.e2e");
await page.getByLabel("Wachtwoord", { exact: true }).fill("whatever-password-value");
await page.getByRole("button", { name: "Aanmelden" }).click();
await expect(page.getByRole("alert")).toBeVisible();
const errorAccessibility = await new AxeBuilder({ page }).analyze();
expect(errorAccessibility.violations).toEqual([]);
});
test.describe("authenticated screens", () => {
test.use({ storageState: authenticatedStatePath });
test("the activity feed is axe-clean in both themes, not yet scanned by any prior milestone", async ({ page }) => {
await page.goto("/activity");
await expectAuthenticatedShell(page);
await expect(page.getByRole("heading", { name: "Activiteit" })).toBeVisible();
await page.waitForLoadState("networkidle");
const lightAccessibility = await new AxeBuilder({ page }).analyze();
expect(lightAccessibility.violations).toEqual([]);
await page.goto("/settings");
await page.getByRole("button", { name: "Donker", exact: true }).click();
await page.goto("/activity");
await page.waitForLoadState("networkidle");
const darkAccessibility = await new AxeBuilder({ page }).analyze();
expect(darkAccessibility.violations).toEqual([]);
});
test("the command palette is axe-clean while open, not only reachable", async ({ page }) => {
await page.goto("/");
await expectAuthenticatedShell(page);
await page.getByRole("button", { name: "Opdrachten en zoeken openen" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
});
test("the mobile primary navigation is axe-clean while open, not only while closed", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
await expectAuthenticatedShell(page);
await openPrimaryNavigation(page);
await expect(page.getByRole("navigation", { name: "Hoofdnavigatie" })).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
});
test("the dashboard causes no horizontal reflow and stays axe-clean at the equivalent of 200% zoom", async ({ page }) => {
// 1280x900 is this suite's own reference desktop width elsewhere; halved is
// the CSS-pixel equivalent of viewing that same window at 200% zoom.
await page.setViewportSize({ width: 640, height: 450 });
await page.goto("/");
await expectAuthenticatedShell(page);
// Scanning during the transient RouteStates.LoadingState (no heading yet) is a
// real, reproducible "page-has-heading-one" false failure, not an actual
// defect — wait for the real heading first, matching every other axe scan in
// this suite.
await expect(page.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible();
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
);
expect(overflow).toBeLessThanOrEqual(1);
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
});
test("the storage matrix causes no horizontal reflow at the equivalent of 200% zoom (the one real DataTable-backed route)", async ({ page }) => {
await page.setViewportSize({ width: 640, height: 450 });
await page.goto("/infrastructure/storage");
await expect(page.getByRole("heading", { name: "Opslag en capaciteit" })).toBeVisible();
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
);
expect(overflow).toBeLessThanOrEqual(1);
});
// Screen-map #11 "Deployment detail" has no dedicated route — it is the
// ServiceDetailPage "Deployments" tab (docs/design/v2/screen-map.md). Every other
// ServiceDetailPage tab was already axe-scanned (V2-M4's "Live facts" tab,
// V2-M4/M5's overview tab); this was the one remaining unscanned tab.
test("a service's Deployments tab is axe-clean, the one remaining unscanned ServiceDetailPage tab", async ({ page }) => {
const sourceKey = `docker:e2e-m8-deployments-${Date.now()}`;
const detectedName = `e2e-m8-deployments-${Date.now()}`;
const snapshotResponse = await postWithCsrf(page, "/api/v1/infrastructure/inventory/snapshots", {
sourceKey: "inventory-e2e",
sourceHash: `hash-m8-${Date.now()}`,
capturedAt: new Date().toISOString(),
hosts: [
{
sourceKey: "host:e2e-m8", hostname: "E2E-M8-Host", operatingSystem: null,
kernelVersion: null, architecture: null, logicalCpuCount: 2, totalMemoryBytes: 500_000
}
],
services: [
{
sourceKey, hostSourceKey: "host:e2e-m8", detectedName, runtimeKind: "container",
runtimeStatus: "running", health: "Healthy", imageReference: "example/e2e-m8:1",
imageDigest: null, runtimeUser: null, readOnlyRootFilesystem: true, noNewPrivileges: true,
privileged: false, confidence: "High", components: null, endpoints: null, networks: null,
volumes: null, databases: null, dependencies: null, healthObservations: null,
deployments: null, backups: null
}
]
});
expect(snapshotResponse.ok(), await snapshotResponse.text()).toBeTruthy();
await page.goto("/admin");
await expectAuthenticatedShell(page);
const proposalCard = page.locator(".inventory-proposals article", { hasText: detectedName });
await expect(proposalCard).toBeVisible();
await proposalCard.getByRole("button", { name: "Bevestigen" }).click();
await expect(proposalCard).toBeHidden();
await page.goto("/infrastructure/services");
await page.getByRole("link", { name: new RegExp(detectedName) }).click();
await expect(page.getByRole("heading", { name: detectedName })).toBeVisible();
await page.getByRole("tab", { name: "Deployments" }).click();
await expect(page.getByRole("heading", { name: "Deploymentflow" })).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
});
});
});
@@ -0,0 +1,132 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
import { authenticatedStatePath } from "../global-setup";
import { expectAuthenticatedShell } from "./support/v2";
/**
* V2-R3: infrastructure scale/durable-sync remediation's sibling milestone —
* "Stitch fidelity and daily UX" (docs/v2/V2_REMEDIATION_PLAN.md,
* docs/adr/ADR-027-v2-r3-context-inspector-and-daily-ux-completeness.md). This spec
* is the concrete evidence for the `implementation-fidelity-checklist.md` error/
* degraded-state row (#23): every route's own API-failure path shows a real,
* accessible, retry-capable error state, not just the few routes previously proven
* directly.
*/
test.use({ storageState: authenticatedStatePath });
async function forceApiFailure(page: Page, pathPattern: string | RegExp) {
await page.route(pathPattern, (route) =>
route.fulfill({
status: 500,
contentType: "application/problem+json",
body: JSON.stringify({ title: "Onverwachte serverfout.", status: 500, correlationId: "test-correlation-id" })
})
);
}
const errorStateRoutes: Array<{ path: string; apiPattern: RegExp; retryLabel: string }> = [
{ path: "/infrastructure/services", apiPattern: /\/api\/v1\/infrastructure\/services(\?.*)?$/, retryLabel: "Opnieuw" },
{ path: "/projects", apiPattern: /\/api\/v1\/projects(\?.*)?$/, retryLabel: "Opnieuw" },
{ path: "/runbooks", apiPattern: /\/api\/v1\/runbooks(\?.*)?$/, retryLabel: "Opnieuw" }
];
test.describe("V2-R3 error-state sweep: every listed route's own API failure shows a real, accessible, retryable error", () => {
for (const { path, apiPattern, retryLabel } of errorStateRoutes) {
test(`${path} shows a clear error state with a working retry when its own API call fails`, async ({ page }) => {
await forceApiFailure(page, apiPattern);
await page.goto(path);
await expectAuthenticatedShell(page);
const alert = page.getByRole("alert");
await expect(alert).toBeVisible();
const retryButton = alert.getByRole("button", { name: retryLabel });
await expect(retryButton).toBeVisible();
// Never a raw stack trace, exception message or private filesystem path shown
// to the user (CLAUDE.md security rules) — only the route's own honest,
// generic Dutch failure copy.
const alertText = await alert.textContent();
expect(alertText).not.toMatch(/Exception|StackTrace|System\.|at \w+\.\w+\(/);
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
// The retry actually re-issues the same request: clearing the forced failure
// and clicking retry must recover to the real, working screen, not stay stuck.
await page.unroute(apiPattern);
await retryButton.click();
await expect(page.getByRole("alert")).toHaveCount(0);
});
}
});
test.describe("V2-R3 offline banner: a genuine network-level failure is distinguishable from an ordinary server error", () => {
test("a request that fails at the network level (not just a 5xx) still surfaces a clear, non-raw error message", async ({ page }) => {
await page.route(/\/api\/v1\/infrastructure\/services(\?.*)?$/, (route) => route.abort("connectionrefused"));
await page.goto("/infrastructure/services");
await expectAuthenticatedShell(page);
const alert = page.getByRole("alert");
await expect(alert).toBeVisible();
const alertText = await alert.textContent();
expect(alertText).not.toMatch(/Failed to fetch|NetworkError|TypeError/);
});
});
// implementation-fidelity-checklist.md row #21 ("Empty states"): individually proven
// where directly exercised, but with no single dedicated cross-route sweep. Forcing a
// genuinely empty `{ items: [], nextCursor: null }` response via route interception
// (rather than relying on the shared dev database happening to be empty, which it is
// not after a long session's worth of prior specs seeding real content) makes this
// deterministic and repeatable regardless of what real data already exists.
const emptyStateRoutes: Array<{ path: string; apiPattern: RegExp; heading: string }> = [
{ path: "/projects", apiPattern: /\/api\/v1\/projects(\?.*)?$/, heading: "Nog geen projecten" },
{ path: "/runbooks", apiPattern: /\/api\/v1\/runbooks(\?.*)?$/, heading: "Nog geen runbooks" }
];
test.describe("V2-R3 empty-state sweep: a genuinely empty collection shows honest, accessible copy, not a blank screen", () => {
for (const { path, apiPattern, heading } of emptyStateRoutes) {
test(`${path} shows a clear empty state when there is genuinely nothing to list`, async ({ page }) => {
await page.route(apiPattern, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [], nextCursor: null }) })
);
await page.goto(path);
await expectAuthenticatedShell(page);
await expect(page.getByRole("heading", { name: heading, level: 2 })).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
});
}
});
// implementation-fidelity-checklist.md row #22 ("Loading/skeleton states"): the
// mid-load page-has-heading-one axe gap is already a known, accepted, purely
// transient false positive (every scan in this suite waits past it) — what was never
// given a dedicated pass is the skeleton's own markup while it is actually visible.
// Holding the route open (never fulfilling it for the duration of this test) keeps
// the app in its loading state deterministically, long enough to assert on it.
test.describe("V2-R3 loading-state check: the skeleton itself renders sensible, non-empty structure while data is still in flight", () => {
test("/runbooks shows a real, non-blank loading skeleton before its data arrives", async ({ page }) => {
let releaseRoute: (() => void) | undefined;
const held = new Promise<void>((resolve) => {
releaseRoute = resolve;
});
await page.route(/\/api\/v1\/runbooks(\?.*)?$/, async (route) => {
await held;
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [], nextCursor: null }) });
});
await page.goto("/runbooks");
await expectAuthenticatedShell(page);
// A real, visible loading indicator — not a blank main area — while the request
// is deliberately held open.
await expect(page.getByRole("status")).toBeVisible();
releaseRoute?.();
await expect(page.getByRole("heading", { name: "Nog geen runbooks", level: 2 })).toBeVisible();
});
});
@@ -0,0 +1,263 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
import { authenticatedStatePath } from "../global-setup";
import { followPrimaryNavigation, openPrimaryNavigation } from "./support/v2";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* V2-M2 routing, application-shell and visual-fidelity audit.
*
* This spec is the concrete evidence behind docs/v2/V2_EVIDENCE.md's "V2-M2" section
* and docs/v2/V2_ACCEPTANCE_MATRIX.md's routing/shell/fidelity rows — every assertion
* here corresponds to a specific requirement in that milestone's audit brief, not
* generic smoke coverage. Kept as permanent regression coverage, not a throwaway
* script, since real-router deep-link/back-forward/focus-management behavior is easy
* to silently regress.
*/
test.use({ storageState: authenticatedStatePath });
const screenshotDir = path.resolve(__dirname, "../../../artifacts/v2-m2/screenshots");
fs.mkdirSync(screenshotDir, { recursive: true });
async function setTheme(page: Page, theme: "Licht" | "Donker") {
await page.goto("/settings");
await page.getByRole("button", { name: theme, exact: true }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", theme === "Licht" ? "light" : "dark");
}
test.describe("V2-M2 routing audit: deep links, reload, back/forward, titles", () => {
const routes: Array<{ path: string; heading?: string; title: string }> = [
{ path: "/", heading: "Operationeel overzicht", title: "Home · ItWorx Knowledge Hub" },
{ path: "/infrastructure", title: "Infrastructuur · ItWorx Knowledge Hub" },
{ path: "/infrastructure/storage", title: "Opslag en capaciteit · ItWorx Knowledge Hub" },
{ path: "/infrastructure/services", title: "Services · ItWorx Knowledge Hub" },
{ path: "/projects", heading: "Projecten", title: "Projecten · ItWorx Knowledge Hub" },
{ path: "/runbooks", title: "Runbooks · ItWorx Knowledge Hub" },
{ path: "/knowledge", heading: "Kennisbank", title: "Kennisbank · ItWorx Knowledge Hub" },
{ path: "/search", title: "Zoeken · ItWorx Knowledge Hub" },
{ path: "/activity", title: "Activiteit · ItWorx Knowledge Hub" },
{ path: "/settings", heading: "Instellingen", title: "Instellingen · ItWorx Knowledge Hub" },
{ path: "/admin", title: "Beheer · ItWorx Knowledge Hub" }
];
for (const route of routes) {
test(`${route.path} is a real deep link: reachable directly, survives reload, has a distinct document title`, async ({ page }) => {
// Deep link: navigate straight to the URL, not via in-app clicks.
await page.goto(route.path);
if (route.heading) {
await expect(page.getByRole("heading", { name: route.heading })).toBeVisible();
}
await expect(page).toHaveTitle(route.title);
await expect(page).toHaveURL(new RegExp(route.path.replace(/[/]/g, "\\/") + "$"));
// Reload on the exact same URL must restore the same route, not bounce to "/".
await page.reload();
if (route.heading) {
await expect(page.getByRole("heading", { name: route.heading })).toBeVisible();
}
await expect(page).toHaveURL(new RegExp(route.path.replace(/[/]/g, "\\/") + "$"));
});
}
test("an unknown route renders the not-found page instead of a blank screen or a silent redirect", async ({ page }) => {
await page.goto("/this-route-does-not-exist");
await expect(page).toHaveURL(/\/this-route-does-not-exist$/);
await expect(page.getByRole("heading")).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
});
test("browser back and forward move between two real routes, not just app-internal state", async ({ page }) => {
await page.goto("/infrastructure");
await page.goto("/projects");
await page.goto("/runbooks");
await page.goBack();
await expect(page).toHaveURL(/\/projects$/);
await page.goBack();
await expect(page).toHaveURL(/\/infrastructure$/);
await page.goForward();
await expect(page).toHaveURL(/\/projects$/);
});
test("a non-Administrator deep-linking to /admin is redirected away, not shown admin content", async ({ page, baseURL }) => {
// Create a throwaway Reader account via the real admin API (the same account
// creation form AdminPage.tsx exposes), then verify the route guard against it in
// a fresh, separately authenticated context — not by trusting client-side hiding.
// page.request shares the authenticated owner's cookies with page's own context
// (per the storageState applied above), matching attachments.spec.ts's convention.
await page.goto("/");
const csrfResponse = await page.request.get("/api/v1/auth/csrf");
const csrf = (await csrfResponse.json()) as { token: string; headerName: string };
const readerEmail = `e2e-reader-${Date.now()}@itworx.e2e`;
const readerPassword = "correct-horse-battery-staple-reader";
const createResponse = await page.request.post("/api/v1/admin/users", {
headers: { [csrf.headerName]: csrf.token },
data: { displayName: "E2E Reader", email: readerEmail, password: readerPassword, role: "Reader" }
});
expect(createResponse.ok(), await createResponse.text()).toBeTruthy();
// A manually created browser context does not inherit the project's `baseURL`
// fixture — pass it explicitly, otherwise `goto("/")` resolves against no base.
// storageState is forced empty so this context never inherits the owner's
// session, regardless of the file-level `test.use({ storageState: ... })`.
const readerContext = await page.context().browser()!.newContext({
baseURL,
storageState: { cookies: [], origins: [] }
});
const readerPage = await readerContext.newPage();
await readerPage.goto("/");
await readerPage.getByLabel("E-mailadres").fill(readerEmail);
await readerPage.getByLabel("Wachtwoord", { exact: true }).fill(readerPassword);
await readerPage.getByRole("button", { name: "Aanmelden" }).click();
await expect(readerPage.getByRole("button", { name: "Accountmenu" })).toBeVisible();
await readerPage.goto("/admin");
await expect(readerPage).toHaveURL(/\/$/);
await expect(readerPage.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible();
await readerContext.close();
});
});
test.describe("V2-M2 application shell audit", () => {
test("a skip link is the first focusable element and jumps into the page's main content", async ({ page }) => {
// The skip link itself lives in index.html (outside the React root, so it works
// identically on the pre-auth login/setup screens too) and targets #main-content,
// which every route's own top-level <main> provides.
await page.goto("/infrastructure");
await page.keyboard.press("Tab");
await expect(page.getByRole("link", { name: "Ga naar de inhoud" })).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.locator("#main-content")).toBeFocused();
});
test("focus moves to the route content on in-app navigation, not just on skip-link activation", async ({ page }) => {
await page.goto("/");
await followPrimaryNavigation(page, "Projecten");
await expect(page).toHaveURL(/\/projects$/);
await expect(page.locator("#route-focus-target")).toBeFocused();
});
test("the active route is marked in the primary navigation for assistive technology, not just visually", async ({ page }) => {
await page.goto("/projects");
await openPrimaryNavigation(page);
await expect(
page.getByRole("navigation", { name: "Hoofdnavigatie" }).getByRole("link", { name: "Projecten", exact: true })
).toHaveAttribute("aria-current", "page");
await expect(
page.getByRole("navigation", { name: "Hoofdnavigatie" }).getByRole("link", { name: "Home", exact: true })
).not.toHaveAttribute("aria-current", "page");
});
test("the command palette entry point is reachable and keyboard-operable", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Opdrachten en zoeken openen" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toBeHidden();
});
test("no primary route causes page-level horizontal overflow at 1280px", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
for (const routePath of ["/", "/infrastructure", "/projects", "/runbooks", "/knowledge"]) {
await page.goto(routePath);
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow, `${routePath} caused ${overflow}px of horizontal overflow at 1280px`).toBeLessThanOrEqual(1);
}
});
});
test.describe("V2-M2 accessibility (axe) on primary routes, both themes", () => {
const routes = ["/", "/infrastructure", "/infrastructure/services", "/projects", "/runbooks", "/knowledge", "/settings"];
for (const theme of ["Licht", "Donker"] as const) {
test(`primary routes have zero axe violations in ${theme === "Licht" ? "light" : "dark"} mode`, async ({ page }) => {
await setTheme(page, theme);
for (const routePath of routes) {
await page.goto(routePath);
// Every route shows a transient RouteStates.LoadingState (role="status", no
// heading) while its own data fetch is in flight; scanning during that window
// is a real, reproducible false failure ("page-has-heading-one"), not an
// actual accessibility defect — wait for the fetch to settle first, matching
// how every route's own spec (e.g. project-hub.spec.ts) waits for its
// known heading before scanning, generalized here since routes vary.
await page.waitForLoadState("networkidle");
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations, `${routePath} (${theme}): ${JSON.stringify(accessibility.violations, null, 2)}`).toEqual([]);
}
});
}
test("reduced motion renders visualizations in their final state without violating axe", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/");
await expect(page.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible();
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
});
});
test.describe("V2-M2 visual fidelity capture (compared against artifacts/stitch/v2/screens by hand)", () => {
test("capture implementation screenshots for the required routes/breakpoints/themes", async ({ page }) => {
await setTheme(page, "Donker");
await page.setViewportSize({ width: 1600, height: 1000 });
await page.goto("/");
await expect(page.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible();
await page.waitForLoadState("networkidle");
await page.screenshot({ path: path.join(screenshotDir, "02-home-dashboard-dark-1600.png") });
await page.goto("/infrastructure");
await page.waitForLoadState("networkidle");
await page.screenshot({ path: path.join(screenshotDir, "04-infrastructure-overview-dark-1600.png") });
await page.goto("/infrastructure/services");
await page.waitForLoadState("networkidle");
await page.screenshot({ path: path.join(screenshotDir, "06-services-catalog-dark-1600.png") });
await page.goto("/projects");
await page.waitForLoadState("networkidle");
await page.screenshot({ path: path.join(screenshotDir, "09-projects-overview-dark-1600.png") });
await page.goto("/knowledge");
await page.waitForLoadState("networkidle");
await page.screenshot({ path: path.join(screenshotDir, "14-knowledge-tree-dark-1600.png") });
await setTheme(page, "Licht");
await page.setViewportSize({ width: 1600, height: 1000 });
await page.goto("/");
await page.waitForLoadState("networkidle");
await page.screenshot({ path: path.join(screenshotDir, "02-home-dashboard-light-1600.png") });
await page.goto("/projects");
await page.waitForLoadState("networkidle");
await page.screenshot({ path: path.join(screenshotDir, "09-projects-overview-light-1600.png") });
await page.setViewportSize({ width: 1024, height: 768 });
await page.goto("/infrastructure");
await page.waitForLoadState("networkidle");
await page.screenshot({ path: path.join(screenshotDir, "04-infrastructure-1024.png") });
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto("/infrastructure/services");
await page.waitForLoadState("networkidle");
await page.screenshot({ path: path.join(screenshotDir, "27-tablet-service-workspace-768.png") });
await setTheme(page, "Donker");
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
await page.waitForLoadState("networkidle");
await page.screenshot({ path: path.join(screenshotDir, "24-mobile-navigation-dark-390.png") });
await setTheme(page, "Licht");
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/knowledge");
await page.waitForLoadState("networkidle");
await page.screenshot({ path: path.join(screenshotDir, "25-mobile-knowledge-light-390.png") });
});
});
+232
View File
@@ -0,0 +1,232 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
import type {
IngestionProposalDetail,
IngestionProposalSummary,
PaginatedIngestionProposals,
} from "../../../src/frontend/src/api/intakeTypes";
import type { SessionResponse } from "../../../src/frontend/src/api/types";
import { authenticatedStatePath } from "../global-setup";
test.use({ storageState: authenticatedStatePath });
const proposalId = "8b8df97b-6015-4f7b-9bd0-9d9b2fb0e54a";
const correlationId = "e2e-v3-intake-pilot";
const proposalEndpoint = "/api/v1/ingestion-proposals";
function createProposalFixture(): IngestionProposalDetail {
const now = new Date().toISOString();
return {
id: proposalId,
correlationId,
status: "ReadyForReview",
revisionNumber: 7,
sourceCount: 1,
submitterId: "91d38469-1c14-43c5-ae32-c806b4906bd0",
createdAt: now,
destination: {
pageId: "e3f5b858-cd07-4e6e-9bb8-63e1e1712ae4",
parentPageId: null,
title: "Runbook bestandsherstel",
},
metadata: {
documentType: "Runbook",
documentStatus: "Draft",
projectId: null,
risk: "medium",
},
mutationKind: "UpdatePage",
manifest: "# Bestandsherstel\n\nControleer eerst de meest recente back-up.",
analysis: "De wijziging voegt herstelstappen toe en verwijst naar een controleerbare back-up.",
reviewerComment: "Controleer de herstelvolgorde en de genoemde revisie.",
retryCount: 0,
maxPublishRetries: 3,
isRetryEligible: false,
failedLifecycleStatus: null,
failedProcessingStage: null,
failureErrorType: null,
failureMessage: null,
sources: [{
id: "6dce39d5-d022-48b6-90d6-6f1ea8ad0c93",
fileName: "herstelstappen.md",
mimeType: "text/markdown",
byteSize: 512,
digestSha256: "a".repeat(64),
scanResult: "accepted",
attachmentReferenceId: "08ab9571-5fb5-4a77-b151-8bdc4ef302cc",
}],
revisions: [],
etag: "proposal-revision-7",
publishedPageId: null,
publishedPageRevisionNumber: null,
attachments: [],
expectedTargetETag: "page-revision-12",
citations: [{
label: "Back-upcontrole",
pageId: "e3f5b858-cd07-4e6e-9bb8-63e1e1712ae4",
excerpt: "Vergelijk de hash met de vorige volledige back-up.",
confidence: 0.94,
}],
warnings: [{
code: "manual-review",
message: "Handmatige beoordeling vereist voor deze wijziging.",
severity: "medium",
}],
duplicateCandidates: [],
conflictCandidates: [{
pageId: "e3f5b858-cd07-4e6e-9bb8-63e1e1712ae4",
title: "Runbook bestandsherstel",
kind: "target-page",
}],
diff: {
before: "De huidige proceduretekst.",
after: "De voorgestelde herstelprocedure met hashcontrole.",
summary: "Voegt controle van de herstelhash toe.",
},
permissions: { canEdit: true, canDecide: true, canPublish: false },
};
}
async function installProposalFixture(page: Page): Promise<void> {
let currentDetail = createProposalFixture();
const toSummary = (detail: IngestionProposalDetail): IngestionProposalSummary => ({
id: detail.id,
correlationId: detail.correlationId,
status: detail.status,
revisionNumber: detail.revisionNumber,
sourceCount: detail.sourceCount,
submitterId: detail.submitterId ?? "91d38469-1c14-43c5-ae32-c806b4906bd0",
createdAt: detail.createdAt ?? new Date().toISOString(),
destination: detail.destination ?? null,
metadata: detail.metadata,
mutationKind: detail.mutationKind,
});
const queue: PaginatedIngestionProposals = {
items: [toSummary(currentDetail)],
nextCursor: null,
page: 1,
pageSize: 50,
total: 1,
correlationId,
};
// Keep the pilot flag disabled by default in the running service; this synthetic
// UI contract explicitly opts the authenticated fixture session into the route.
await page.route("**/api/v1/auth/session", async (route) => {
const response = await route.fetch();
if (!response.ok()) {
await route.fulfill({ response });
return;
}
const session = await response.json() as SessionResponse;
await route.fulfill({
response,
json: { ...session, features: { ...session.features, ingestionProposals: true } },
});
});
await page.route(/\/api\/v1\/ingestion-proposals(?:\/|\?|$)/, async (route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (request.method() === "GET" && pathname === proposalEndpoint) {
await route.fulfill({ status: 200, json: { ...queue, items: [toSummary(currentDetail)] } });
return;
}
if (request.method() === "GET" && pathname === `${proposalEndpoint}/${proposalId}`) {
await route.fulfill({ status: 200, json: currentDetail });
return;
}
if (request.method() === "POST" && pathname === `${proposalEndpoint}/${proposalId}/approve`) {
currentDetail = {
...currentDetail,
status: "Approved",
revisionNumber: currentDetail.revisionNumber + 1,
etag: "proposal-revision-8",
};
await route.fulfill({ status: 200, json: currentDetail });
return;
}
await route.continue();
});
}
test.describe("V3 intakepilot: beoordeling op desktop en mobiel", () => {
test("reviewer kan waarschuwingen, verschil, bestemming en beslissing toetsen met toetsenbord", async ({ page }, testInfo) => {
const isMobile = testInfo.project.name === "chromium-mobile";
await page.setViewportSize(isMobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
await installProposalFixture(page);
await page.goto("/");
await expect(page.getByRole("heading", { name: "Operationeel overzicht" })).toBeVisible();
await expect(page.getByRole("region", { name: "Kennisintake" })).toHaveCount(0);
if (isMobile) await page.getByRole("button", { name: "Navigatie openen" }).click();
const intakeNavigationLink = page.getByRole("navigation", { name: "Hoofdnavigatie" }).getByRole("link", { name: "Intake", exact: true });
await expect(intakeNavigationLink).toHaveAttribute("href", "/knowledge/intake");
await intakeNavigationLink.click();
await expect(page.getByRole("heading", { name: "Intakewachtrij" })).toBeVisible();
const proposalLink = page.getByRole("link", { name: "Open voorstel Runbook bestandsherstel" });
await proposalLink.focus();
await page.keyboard.press("Enter");
await expect(page.getByLabel("Voorsteloverzicht").getByRole("heading", { name: "Voorstel beoordelen" })).toBeVisible();
await expect(page.locator('[data-status="ReadyForReview"]')).toContainText("Klaar voor beoordeling");
await expect(page.getByRole("heading", { name: "Bronnen en herkomst" })).toBeVisible();
if (isMobile) {
await expect(page.locator('[data-testid="ingestion-progress"]')).toHaveText("Stap 1 van 3: Bronnen");
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.getByRole("button", { name: "Volgende stap" }).press("Enter");
await expect(page.locator('[data-testid="ingestion-progress"]')).toHaveText("Stap 2 van 3: Onderbouwing");
await expect(page.locator('[data-mobile-step="sources"]')).toBeHidden();
await expect(page.locator('[data-mobile-step="evidence"]')).toBeVisible();
await expect(page.getByRole("heading", { name: "Waarschuwingen en conflicten" })).toBeVisible();
await expect(page.getByText("Handmatige beoordeling vereist voor deze wijziging.")).toBeVisible();
await expect(page.getByText("Tegenstrijdige pagina: Runbook bestandsherstel")).toBeVisible();
await expect(page.getByRole("heading", { name: "Voorgestelde inhoud" })).toBeVisible();
await expect(page.getByText("De huidige proceduretekst.", { exact: true })).toBeVisible();
await expect(page.getByText("De voorgestelde herstelprocedure met hashcontrole.", { exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Bestemming en beoordeling" })).toBeVisible();
await expect(page.getByText("Runbook bestandsherstel", { exact: true })).toBeVisible();
await expect(page.getByText("Verwachte paginaversie: page-revision-12")).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
const evidenceAccessibility = await new AxeBuilder({ page }).analyze();
expect(evidenceAccessibility.violations).toEqual([]);
// The mobile step moves focus to the active pane; normal Tab order then enters
// the reviewer controls after the read-only warning and diff evidence.
await page.keyboard.press("Tab");
await expect(page.getByRole("textbox", { name: "Voorstel (beoordelaar)" })).toBeFocused();
await page.keyboard.press("Tab");
await expect(page.getByRole("textbox", { name: "Onderbouwing (beoordelaar)" })).toBeFocused();
await page.getByRole("button", { name: "Volgende stap" }).press("Enter");
await expect(page.locator('[data-testid="ingestion-progress"]')).toHaveText("Stap 3 van 3: Beslissing");
await expect(page.locator('[data-mobile-step="evidence"]')).toBeHidden();
await expect(page.locator('[data-mobile-step="decision"]')).toBeVisible();
await expect(page.getByRole("button", { name: "Goedkeuren" })).toBeVisible();
const decisionAccessibility = await new AxeBuilder({ page }).analyze();
expect(decisionAccessibility.violations).toEqual([]);
} else {
await expect(page.getByRole("heading", { name: "Waarschuwingen en conflicten" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Voorgestelde inhoud" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Bestemming en beoordeling" })).toBeVisible();
}
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
await page.getByRole("button", { name: "Goedkeuren" }).focus();
await page.keyboard.press("Enter");
await expect(page.locator('[data-status="Approved"]')).toContainText("Goedgekeurd");
});
});