diff --git a/packages/testing/playwright/AGENTS.md b/packages/testing/playwright/AGENTS.md index 8baa1e84315..3dd4e9bf771 100644 --- a/packages/testing/playwright/AGENTS.md +++ b/packages/testing/playwright/AGENTS.md @@ -243,6 +243,34 @@ When a journey acts as another user, `n8n.start.withUser()` returns a new `n8nPage`. Point the checker at it with `a11y.for(otherN8n)` - the derived checker reports into the same scan list. +### Main landmark structure + +`utils/a11y-landmark-check.ts` checks the one structural rule axe cannot see on a +composed layout: the page must have exactly one `
` element, that `
` +must not sit inside another landmark, and `id="content"` must be unique. It walks +the composed DOM - the document plus every open shadow tree, and slotted content +from where it renders, not from where it is written - runs no axe rules, and +leaves the default WCAG 2.1 A + AA tag selection alone. + +A `
` or `
` is a landmark only once it has an accessible name, so the +check computes one: `aria-label`, an `aria-labelledby` reference resolved in the +element's own root, or `title`. A reference resolves through the same routine, so +the referenced element can name through its own reference, a naming attribute such +as `img[alt]`, or its text. A reference to a missing or empty element names +nothing, and a `
` full of text stays unnamed, because `region` does not +take its name from content. + +```typescript +import { assertMainLandmarkStructure } from '../../../utils/a11y-landmark-check'; + +await assertMainLandmarkStructure(n8n.page); +``` + +`assertMainLandmarkStructure(page)` throws with each problem it found. +`checkMainLandmarkStructure(page)` returns `{ ok, problems }` instead, for a +caller that wants to report rather than fail. Regression cover for both lives in +`tests/e2e/a11y/a11y-landmark-structure.spec.ts`, against synthetic markup. + ### Report and failure budget Every scan is attached to its test, so the raw axe results are always available diff --git a/packages/testing/playwright/tests/e2e/a11y/a11y-landmark-structure.spec.ts b/packages/testing/playwright/tests/e2e/a11y/a11y-landmark-structure.spec.ts new file mode 100644 index 00000000000..d9bcfa09814 --- /dev/null +++ b/packages/testing/playwright/tests/e2e/a11y/a11y-landmark-structure.spec.ts @@ -0,0 +1,222 @@ +import { expect, test } from '../../../fixtures/base'; +import { + assertMainLandmarkStructure, + checkMainLandmarkStructure, +} from '../../../utils/a11y-landmark-check'; + +/** + * Regression cover for the main landmark structure check. Every case is synthetic + * markup rendered with `page.setContent`, so no application route is loaded and no + * axe scan runs - the check is the only thing under test. + */ +test.describe( + 'the main landmark structure check', + { + annotation: [{ type: 'owner', description: 'Catalysts' }], + }, + () => { + test('fails a page with two
elements', async ({ page }) => { + await page.setContent('
first
second
'); + + const { ok, problems } = await checkMainLandmarkStructure(page); + + expect(ok).toBe(false); + expect(problems.map((problem) => problem.rule)).toContain('single-main'); + await expect(assertMainLandmarkStructure(page)).rejects.toThrow(/single-main/); + }); + + test('fails a page with a
nested inside another landmark', async ({ page }) => { + await page.setContent(''); + + const { ok, problems } = await checkMainLandmarkStructure(page); + + expect(ok).toBe(false); + expect(problems.map((problem) => problem.rule)).toContain('main-not-nested'); + await expect(assertMainLandmarkStructure(page)).rejects.toThrow(/main-not-nested/); + }); + + test('fails a page with two elements sharing id="content"', async ({ page }) => { + await page.setContent('
first
second
'); + + const { ok, problems } = await checkMainLandmarkStructure(page); + + expect(ok).toBe(false); + expect(problems.map((problem) => problem.rule)).toContain('unique-content-id'); + await expect(assertMainLandmarkStructure(page)).rejects.toThrow(/unique-content-id/); + }); + + test('fails a page whose second
is inside a shadow root', async ({ page }) => { + await page.setContent('
light tree
'); + await page.evaluate(() => { + const host = document.querySelector('#host'); + host?.attachShadow({ mode: 'open' }).appendChild(document.createElement('main')); + }); + + const { ok, problems } = await checkMainLandmarkStructure(page); + + expect(ok).toBe(false); + expect(problems.map((problem) => problem.rule)).toContain('single-main'); + await expect(assertMainLandmarkStructure(page)).rejects.toThrow(/single-main/); + }); + + test('fails a page whose
is slotted into a landmark in a shadow root', async ({ + page, + }) => { + await page.setContent('
slotted
'); + await page.evaluate(() => { + const host = document.querySelector('#host'); + const shadow = host?.attachShadow({ mode: 'open' }); + if (shadow) shadow.innerHTML = ''; + }); + + const { ok, problems } = await checkMainLandmarkStructure(page); + + expect(ok).toBe(false); + expect(problems.map((problem) => problem.rule)).toContain('main-not-nested'); + await expect(assertMainLandmarkStructure(page)).rejects.toThrow(/main-not-nested/); + }); + + test('fails a page whose
is inside a
named by aria-labelledby', async ({ + page, + }) => { + await page.setContent( + '
' + + '

Settings

' + + '
nested
' + + '
', + ); + + const { ok, problems } = await checkMainLandmarkStructure(page); + + expect(ok).toBe(false); + expect(problems.map((problem) => problem.rule)).toContain('main-not-nested'); + await expect(assertMainLandmarkStructure(page)).rejects.toThrow(/main-not-nested/); + }); + + test('fails a page whose
is inside a named by aria-labelledby', async ({ + page, + }) => { + await page.setContent( + '' + + '

Filters

' + + '
nested
' + + '', + ); + + const { ok, problems } = await checkMainLandmarkStructure(page); + + expect(ok).toBe(false); + expect(problems.map((problem) => problem.rule)).toContain('main-not-nested'); + await expect(assertMainLandmarkStructure(page)).rejects.toThrow(/main-not-nested/); + }); + + test('fails a page whose
is named by a referenced image alt', async ({ page }) => { + await page.setContent( + '
' + + 'Settings' + + '
nested
' + + '
', + ); + + const { ok, problems } = await checkMainLandmarkStructure(page); + + expect(ok).toBe(false); + expect(problems.map((problem) => problem.rule)).toContain('main-not-nested'); + await expect(assertMainLandmarkStructure(page)).rejects.toThrow(/main-not-nested/); + }); + + test('fails a page whose
is named through a chain of aria-labelledby', async ({ + page, + }) => { + await page.setContent( + '
' + + '' + + 'Settings' + + '
nested
' + + '
', + ); + + const { ok, problems } = await checkMainLandmarkStructure(page); + + expect(ok).toBe(false); + expect(problems.map((problem) => problem.rule)).toContain('main-not-nested'); + await expect(assertMainLandmarkStructure(page)).rejects.toThrow(/main-not-nested/); + }); + + test('passes a page whose
aria-labelledby points at a missing id', async ({ + page, + }) => { + await page.setContent( + '
' + + '
not nested in a landmark
' + + '
', + ); + + const { ok, problems } = await checkMainLandmarkStructure(page); + + expect(problems).toEqual([]); + expect(ok).toBe(true); + }); + + test('passes a page whose
aria-labelledby points at an empty element', async ({ + page, + }) => { + await page.setContent( + '' + + ' ' + + '
not nested in a landmark
' + + '
', + ); + + const { ok, problems } = await checkMainLandmarkStructure(page); + + expect(problems).toEqual([]); + expect(ok).toBe(true); + }); + + test('passes a page whose
aria-labelledby references form a cycle', async ({ + page, + }) => { + await page.setContent( + '
' + + '' + + '' + + '
not nested in a landmark
' + + '
', + ); + + const { ok, problems } = await checkMainLandmarkStructure(page); + + expect(problems).toEqual([]); + expect(ok).toBe(true); + }); + + test('passes a page whose
is inside an unnamed
that has text', async ({ + page, + }) => { + await page.setContent( + '

Section text

not nested in a landmark
', + ); + + const { ok, problems } = await checkMainLandmarkStructure(page); + + expect(problems).toEqual([]); + expect(ok).toBe(true); + }); + + test('passes a page with one top-level
and a unique id="content"', async ({ page }) => { + await page.setContent( + '
banner
' + + '' + + '

Title

' + + '
contentinfo
', + ); + + const { ok, problems } = await checkMainLandmarkStructure(page); + + expect(problems).toEqual([]); + expect(ok).toBe(true); + await expect(assertMainLandmarkStructure(page)).resolves.toBeUndefined(); + }); + }, +); diff --git a/packages/testing/playwright/utils/a11y-landmark-check.ts b/packages/testing/playwright/utils/a11y-landmark-check.ts new file mode 100644 index 00000000000..ba71d6398bd --- /dev/null +++ b/packages/testing/playwright/utils/a11y-landmark-check.ts @@ -0,0 +1,243 @@ +import type { Page } from '@playwright/test'; + +import { TestError } from '../Types'; + +/** The structural rule a problem breaks, so a caller can assert on one of them. */ +export type MainLandmarkRule = 'single-main' | 'main-not-nested' | 'unique-content-id'; + +export type MainLandmarkProblem = { + rule: MainLandmarkRule; + message: string; +}; + +export type MainLandmarkStructure = { + ok: boolean; + /** Empty when the page is conforming. One entry for each rule the page breaks. */ + problems: MainLandmarkProblem[]; +}; + +/** + * Reads the main landmark structure of the composed DOM - the document plus every + * open shadow tree in it, which is where a composed layout puts half its markup. + * + * The walk is plain DOM, so it runs no axe rules at all and leaves the axe fixture's + * default tag selection (`DEFAULT_A11Y_TAGS` in `fixtures/a11y.ts`) untouched. + */ +export async function checkMainLandmarkStructure(page: Page): Promise { + const snapshot = await page.evaluate(() => { + // Roles that make an element a landmark. `main` is one of them, so a
+ // inside another
is reported as a nesting problem as well as a duplicate. + const landmarkRoles = new Set([ + 'banner', + 'complementary', + 'contentinfo', + 'form', + 'main', + 'navigation', + 'region', + 'search', + ]); + //
and