Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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") });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user