All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m19s
CI / Build container images (pull_request) Successful in 1m23s
CI / Auth e2e pack (pull_request) Successful in 8m55s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CD / Build and push images (push) Successful in 14s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m28s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m52s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m6s
CI / Import/export fidelity gate (push) Successful in 58s
Deleting a pond already had a strict prompt — typing the pond name, stricter than a confirm dialog. That was never the gap. The gap is that the person who deletes it loses access the moment they do: the pond leaves their view, only a Site Admin can bring it back, and the export is no longer reachable for them. So the archive is offered INSIDE the deletion flow, before the button. What it contains, and why it is not the existing export: - Every page the requester may read, as Markdown, as before. - **Every attachment of the pond**, not only the embedded ones. An attachment nobody put on a page would otherwise vanish unnoticed — which is the whole reason this issue exists. - `manifest.json`: pond settings (EFFECTIVE, defaults filled in — a preservation format must not require its reader to know Dorfteich's defaults), labels, the page hierarchy and sort keys, comments, and attachment metadata including the #199 hash so a reader can verify bytes. It extends the #210 manifest rather than adding a second descriptor, and carries an explicit `formatVersion`. - `README.txt`, because the manifest is for machines: whoever unpacks a folder of Markdown a year from now must not believe they hold a one-click restore. Decisions worth naming: - **"Complete" describes the RESULT, not the route.** A pond admin who may read every page gets `complete: true`; only an archive that actually leaves pages out is incomplete. The Site-Admin route skips the read filter (an archive taken before an irreversible purge must not depend on which ponds the operator happens to be a member of) — those are two different questions and the first version of this conflated them. - **The omission is named before the download**, with its number, in the UI and in the manifest. An archive silently missing content is worse than no archive, because it ends the search. - **Not downloading stays allowed.** A pond of test pages should not require one, and the server cannot tell whether a file arrived anyway — so the finality is stated in text instead of enforced. - **A plain link, not fetch-into-a-blob.** The api streams the ZIP; buffering a whole pond in the tab to draw a progress bar would trade memory for cosmetics. The browser reports progress and completion; what it cannot say — that the archive is being BUILT — is announced in a live region. - Read trail unchanged in kind (ADR 0023): one `export` event per classified page before any classified byte enters the stream. Attachments never travel without their page, so the same events cover them. - New audit action `pond.archived` (catalogue v1.7) with page and attachment counts, omitted pages, and completeness. Format documented in `docs/architecture/pond-archive-format.md`, including what is deliberately NOT in it (history, permissions, trash). Verified by hand, not only asserted: a real pond's archive downloaded and unpacked — README, manifest, three page files, the media file; the manifest's effective settings, per-page classification, the VS-NfD frontmatter and marking preserved in the classified page's Markdown, and the attachment's sha256 present. Plus six api tests (including that an unembedded attachment travels and that a Site Admin gets a complete archive without membership) and the a11y pack 11/11 in both schemes, which now also scans the pond settings screen. Not done, because there is nothing to attach it to: the Site Admin's purge dialog (#193) exists only as an api endpoint — there is no pond-trash UI in the web app. The api half is here and tested, so it becomes a link when that screen is built.
222 lines
9.9 KiB
TypeScript
222 lines
9.9 KiB
TypeScript
import AxeBuilder from '@axe-core/playwright';
|
||
import { expect, test, type Page } from '@playwright/test';
|
||
|
||
import { contextForUser } from './helpers';
|
||
|
||
/**
|
||
* A11y-Smoke-Pack (issue #171): axe-core-Scan der Kern-Oberflächen gegen
|
||
* WCAG 2.1 A/AA. Regressionsschutz für das Audit vom 21.07.2026 (Bericht im
|
||
* Workspace, Befunde A11Y-001…024) — die Screens hier waren nach den Fixes
|
||
* der Issues #162–#170 verletzungsfrei; jede neue Verletzung bricht den
|
||
* Build. Best-Practice-Regeln (axe-Tag best-practice) prüfen wir hier
|
||
* bewusst NICHT, nur normative WCAG-Kriterien.
|
||
*
|
||
* Seit issue #180 läuft jeder Scan in BEIDEN Farbschemata: emulateMedia
|
||
* setzt prefers-color-scheme, theme-init.js löst den Default „System“ zum
|
||
* konkreten data-theme auf, axe misst dann die echten Dark-Token-Farben.
|
||
*/
|
||
|
||
const BASE = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||
const TAGS = ['wcag2a', 'wcag21a', 'wcag2aa', 'wcag21aa'];
|
||
const SCHEMES = ['light', 'dark'] as const;
|
||
|
||
/** Bewusst tolerierte Regel-IDs — nur mit Begründung ergänzen. */
|
||
const ALLOWED_RULES: string[] = [];
|
||
|
||
async function expectClean(page: Page, label: string): Promise<void> {
|
||
const results = await new AxeBuilder({ page }).withTags(TAGS).analyze();
|
||
const violations = results.violations.filter((v) => !ALLOWED_RULES.includes(v.id));
|
||
expect(
|
||
violations.map((v) => ({
|
||
rule: v.id,
|
||
impact: v.impact,
|
||
help: v.help,
|
||
nodes: v.nodes.slice(0, 5).map((n) => n.target),
|
||
})),
|
||
`axe-Verletzungen auf ${label}`,
|
||
).toEqual([]);
|
||
}
|
||
|
||
for (const scheme of SCHEMES) {
|
||
test.describe(`${scheme} scheme`, () => {
|
||
test(`login page passes the axe WCAG A/AA scan (${scheme})`, async ({ page }) => {
|
||
await page.emulateMedia({ colorScheme: scheme });
|
||
await page.goto('/login');
|
||
await page.waitForLoadState('networkidle');
|
||
await expectClean(page, `/login (${scheme})`);
|
||
});
|
||
|
||
test(`reading and editing a page passes the axe WCAG A/AA scan (${scheme})`, async ({
|
||
browser,
|
||
}) => {
|
||
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||
const page = await context.newPage();
|
||
await page.emulateMedia({ colorScheme: scheme });
|
||
await page.goto('/p/content-fixtures/every-element');
|
||
await page.waitForLoadState('networkidle');
|
||
await expectClean(page, `Lesemodus every-element (${scheme})`);
|
||
|
||
await page.locator('.editor-page__mode-toggle').click();
|
||
await page.locator('.ProseMirror[contenteditable="true"]').waitFor({ timeout: 10_000 });
|
||
await page.waitForTimeout(500);
|
||
await expectClean(page, `Editor every-element (${scheme})`);
|
||
await context.close();
|
||
});
|
||
|
||
test(`a classified page shows the marking top+bottom and passes axe (${scheme})`, async ({
|
||
browser,
|
||
}) => {
|
||
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||
const page = await context.newPage();
|
||
await page.emulateMedia({ colorScheme: scheme });
|
||
await page.goto('/p/content-fixtures/classified-note');
|
||
await page.waitForLoadState('networkidle');
|
||
// Kennzeichnung oben UND unten (issue #206, ADR 0022) — fester
|
||
// Wortlaut, nicht lokalisiert.
|
||
const banners = page.locator('.classification-banner');
|
||
await expect(banners).toHaveCount(2);
|
||
await expect(banners.first()).toContainText('VS – NUR FÜR DEN DIENSTGEBRAUCH');
|
||
await expect(banners.last()).toContainText('VS – NUR FÜR DEN DIENSTGEBRAUCH');
|
||
await expectClean(page, `Eingestufte Seite classified-note (${scheme})`);
|
||
await context.close();
|
||
});
|
||
|
||
test(`user settings pass the axe WCAG A/AA scan (${scheme})`, async ({ browser }) => {
|
||
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||
const page = await context.newPage();
|
||
await page.emulateMedia({ colorScheme: scheme });
|
||
await page.goto('/settings');
|
||
await page.waitForLoadState('networkidle');
|
||
await expectClean(page, `/settings (${scheme})`);
|
||
|
||
// Lizenzseite im selben Kontext (issue #304: sie trägt seit den
|
||
// eigenen Schriften zwei Tabellen samt Scroll-Regionen). Bewusst
|
||
// KEIN eigener Test — jeder zusätzliche Login im Pack bringt die
|
||
// CI zwei Packs später ans Rate-Limit (Lehre aus #301).
|
||
await page.goto('/fonts');
|
||
await page.waitForLoadState('networkidle');
|
||
await expectClean(page, `/fonts (${scheme})`);
|
||
|
||
// Teich-Einstellungen im selben Kontext (fixture-user besitzt den
|
||
// Fixture-Teich): dort sitzt seit issue #305 das Archiv-Angebot in der
|
||
// Löschzone. Wieder KEIN eigener Test — zusätzliche Logins kippen die
|
||
// CI zwei Packs später am Rate-Limit (Lehre aus #301).
|
||
await page.goto('/p/content-fixtures/settings');
|
||
await page.waitForLoadState('networkidle');
|
||
await page.locator('.pond-archive__download').waitFor();
|
||
await expectClean(page, `Teich-Einstellungen (${scheme})`);
|
||
await context.close();
|
||
});
|
||
|
||
test(`admin area passes the axe WCAG A/AA scan (${scheme})`, async ({ browser }) => {
|
||
const context = await contextForUser(browser, BASE, 'fixture-admin');
|
||
const page = await context.newPage();
|
||
await page.emulateMedia({ colorScheme: scheme });
|
||
await page.goto('/admin');
|
||
await page.waitForLoadState('networkidle');
|
||
// Personenliste sichtbar, inkl. der Icon-Aktionen (issue #175).
|
||
await page.locator('.user-manager__table .user-row').first().waitFor();
|
||
// Schriftverwaltung mitgeladen (issue #304) — ohne diese Zusicherung
|
||
// liefe der Scan auch dann grün, wenn der Abschnitt gar nicht rendert.
|
||
await page.locator('.custom-fonts__upload input[type="file"]').first().waitFor();
|
||
// Dasselbe für den Branding-Abschnitt (issue #306). Der Zuschnitt ist
|
||
// erst nach Dateiwahl sichtbar; geprüft wird die Dateiauswahl.
|
||
await page.locator('.branding .crop-field input[type="file"]').first().waitFor();
|
||
await expectClean(page, `/admin (${scheme})`);
|
||
await context.close();
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Reflow (WCAG 2.1 SC 1.4.10, issue #301): bei 320 px CSS-Breite — was 400 %
|
||
* Zoom auf 1280 px entspricht — darf die Seite nicht seitenweit horizontal
|
||
* scrollen. axe prüft das NICHT, das Kriterium ist nicht maschinell aus dem
|
||
* DOM ableitbar; deshalb ein eigener Zaun.
|
||
*
|
||
* Schlägt er an, nennt er die überstehenden Elemente. Ohne diese Diagnose
|
||
* weiß man nur DASS es überläuft und muss im Browser bisektieren.
|
||
*/
|
||
const NARROW = { width: 320, height: 800 };
|
||
|
||
async function expectNoHorizontalScroll(page: Page, label: string): Promise<void> {
|
||
const report = await page.evaluate(() => {
|
||
const doc = document.documentElement;
|
||
const limit = doc.clientWidth;
|
||
|
||
const describe = (el: Element): string => {
|
||
const cls =
|
||
el.className && typeof el.className === 'string'
|
||
? `.${el.className.trim().split(/\s+/).join('.')}`
|
||
: '';
|
||
return `${el.tagName.toLowerCase()}${cls}`;
|
||
};
|
||
|
||
// Every element whose own content is wider than its box. One of these is
|
||
// the source; the ones that scroll it away on purpose are marked.
|
||
const overflowing: string[] = [];
|
||
for (const el of Array.from(document.querySelectorAll('*'))) {
|
||
if (el.scrollWidth > el.clientWidth + 1 && el.clientWidth > 0) {
|
||
const overflowX = getComputedStyle(el).overflowX;
|
||
overflowing.push(
|
||
`${describe(el)} client=${el.clientWidth} scroll=${el.scrollWidth} overflow-x=${overflowX}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/** Content inside a scroll container may exceed the viewport — that is
|
||
* the remedy. But only when the CONTAINER fits: a scroller that is
|
||
* itself too wide still pushes the page. */
|
||
const insideFittingScroller = (el: Element): boolean => {
|
||
for (let node = el.parentElement; node && node !== doc; node = node.parentElement) {
|
||
const ox = getComputedStyle(node).overflowX;
|
||
if (ox === 'auto' || ox === 'scroll' || ox === 'hidden') {
|
||
return node.getBoundingClientRect().right <= limit + 1;
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// Widest reach first, so a long tail of clipped children cannot bury the
|
||
// one box that actually pushes the page.
|
||
const past = Array.from(document.querySelectorAll('body *'))
|
||
.map((el) => ({ el, rect: el.getBoundingClientRect() }))
|
||
.filter(({ rect }) => rect.width > 0 && rect.right > limit + 1)
|
||
.sort((a, b) => b.rect.right - a.rect.right)
|
||
.map(
|
||
({ el, rect }) =>
|
||
`${describe(el)} right=${Math.round(rect.right)} w=${Math.round(rect.width)}` +
|
||
`${insideFittingScroller(el) ? ' [in fitting scroller]' : ' <-- pushes page'}`,
|
||
);
|
||
|
||
return {
|
||
overflowBy: doc.scrollWidth - limit,
|
||
viewport: `html client=${limit} scroll=${doc.scrollWidth} | body client=${document.body.clientWidth} scroll=${document.body.scrollWidth} rect=${Math.round(document.body.getBoundingClientRect().width)}`,
|
||
overflowing: overflowing.slice(0, 15),
|
||
past: past.slice(0, 40),
|
||
};
|
||
});
|
||
const diagnosis = [
|
||
`${label}: horizontaler Überlauf bei 320 px`,
|
||
report.viewport,
|
||
`eigener Inhaltsüberlauf: ${JSON.stringify(report.overflowing, null, 1)}`,
|
||
`Boxen über dem Rand: ${JSON.stringify(report.past, null, 1)}`,
|
||
].join('\n');
|
||
expect({ overflowBy: report.overflowBy }, diagnosis).toEqual({ overflowBy: 0 });
|
||
}
|
||
|
||
test.describe('reflow at 320px', () => {
|
||
test('user settings do not scroll horizontally at 320px', async ({ browser }) => {
|
||
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||
const page = await context.newPage();
|
||
await page.setViewportSize(NARROW);
|
||
await page.goto('/settings');
|
||
await page.waitForLoadState('networkidle');
|
||
// Die Sitzungstabelle rendert asynchron und ist der breiteste Inhalt —
|
||
// ohne sie misst der Zaun eine halb aufgebaute Seite.
|
||
await page.locator('.table tbody tr').first().waitFor();
|
||
await expectNoHorizontalScroll(page, '/settings');
|
||
await context.close();
|
||
});
|
||
});
|