213 lines
11 KiB
TypeScript
213 lines
11 KiB
TypeScript
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();
|
|
const savedSeparator = page.waitForResponse((response) => {
|
|
const request = response.request();
|
|
return request.method() === "PUT"
|
|
&& request.url().includes("/api/v1/drafts/")
|
|
&& (request.postDataJSON() as { markdown?: string }).markdown === "Voor de afbeelding: ";
|
|
});
|
|
await page.keyboard.type("Voor de afbeelding: ");
|
|
|
|
const draftResponse = await savedSeparator;
|
|
expect(draftResponse.status()).toBe(200);
|
|
await expect.poll(() => readMarkdown(page)).toBe("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 searchResultsGroup = page.getByRole("group", { name: "Zoekresultaten" });
|
|
await expect(searchResultsGroup.getByRole("option", { name: new RegExp(uniqueTitle) })).toBeVisible();
|
|
|
|
const accessibility = await new AxeBuilder({ page }).analyze();
|
|
expect(accessibility.violations).toEqual([]);
|
|
|
|
await searchResultsGroup.getByRole("option", { name: new RegExp(uniqueTitle) }).click();
|
|
await expect(page.getByRole("heading", { name: uniqueTitle })).toBeVisible();
|
|
await expect(page.getByRole("dialog")).toBeHidden();
|
|
});
|
|
});
|