test: Add structural main-landmark check with regression tests (#37755)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
n8n-cat-bot[bot] 2026-09-24 12:37:51 +00:00 committed by GitHub
parent 6c592bdb33
commit 64d20004c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 493 additions and 0 deletions

View File

@ -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 `<main>` element, that `<main>`
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 `<section>` or `<form>` 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 `<section>` 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

View File

@ -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 <main> elements', async ({ page }) => {
await page.setContent('<main id="content">first</main><main>second</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 with a <main> nested inside another landmark', async ({ page }) => {
await page.setContent('<nav aria-label="Primary"><main id="content">nested</main></nav>');
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('<main id="content">first</main><div id="content">second</div>');
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 <main> is inside a shadow root', async ({ page }) => {
await page.setContent('<main id="content">light tree</main><div id="host"></div>');
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 <main> is slotted into a landmark in a shadow root', async ({
page,
}) => {
await page.setContent('<div id="host"><main id="content">slotted</main></div>');
await page.evaluate(() => {
const host = document.querySelector('#host');
const shadow = host?.attachShadow({ mode: 'open' });
if (shadow) shadow.innerHTML = '<nav aria-label="Primary"><slot></slot></nav>';
});
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 <main> is inside a <section> named by aria-labelledby', async ({
page,
}) => {
await page.setContent(
'<section aria-labelledby="section-title">' +
'<h2 id="section-title">Settings</h2>' +
'<main id="content">nested</main>' +
'</section>',
);
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 <main> is inside a <form> named by aria-labelledby', async ({
page,
}) => {
await page.setContent(
'<form aria-labelledby="form-title">' +
'<h2 id="form-title">Filters</h2>' +
'<main id="content">nested</main>' +
'</form>',
);
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 <section> is named by a referenced image alt', async ({ page }) => {
await page.setContent(
'<section aria-labelledby="image-title">' +
'<img id="image-title" alt="Settings" />' +
'<main id="content">nested</main>' +
'</section>',
);
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 <section> is named through a chain of aria-labelledby', async ({
page,
}) => {
await page.setContent(
'<section aria-labelledby="proxy-title">' +
'<span id="proxy-title" aria-labelledby="real-title"></span>' +
'<span id="real-title">Settings</span>' +
'<main id="content">nested</main>' +
'</section>',
);
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 <section> aria-labelledby points at a missing id', async ({
page,
}) => {
await page.setContent(
'<section aria-labelledby="missing-title">' +
'<main id="content">not nested in a landmark</main>' +
'</section>',
);
const { ok, problems } = await checkMainLandmarkStructure(page);
expect(problems).toEqual([]);
expect(ok).toBe(true);
});
test('passes a page whose <form> aria-labelledby points at an empty element', async ({
page,
}) => {
await page.setContent(
'<form aria-labelledby="empty-title">' +
'<span id="empty-title"> </span>' +
'<main id="content">not nested in a landmark</main>' +
'</form>',
);
const { ok, problems } = await checkMainLandmarkStructure(page);
expect(problems).toEqual([]);
expect(ok).toBe(true);
});
test('passes a page whose <section> aria-labelledby references form a cycle', async ({
page,
}) => {
await page.setContent(
'<section aria-labelledby="first-title">' +
'<span id="first-title" aria-labelledby="second-title"></span>' +
'<span id="second-title" aria-labelledby="first-title"></span>' +
'<main id="content">not nested in a landmark</main>' +
'</section>',
);
const { ok, problems } = await checkMainLandmarkStructure(page);
expect(problems).toEqual([]);
expect(ok).toBe(true);
});
test('passes a page whose <main> is inside an unnamed <section> that has text', async ({
page,
}) => {
await page.setContent(
'<section><p>Section text</p><main id="content">not nested in a landmark</main></section>',
);
const { ok, problems } = await checkMainLandmarkStructure(page);
expect(problems).toEqual([]);
expect(ok).toBe(true);
});
test('passes a page with one top-level <main> and a unique id="content"', async ({ page }) => {
await page.setContent(
'<header>banner</header>' +
'<nav aria-label="Primary">navigation</nav>' +
'<main id="content"><h1>Title</h1></main>' +
'<footer>contentinfo</footer>',
);
const { ok, problems } = await checkMainLandmarkStructure(page);
expect(problems).toEqual([]);
expect(ok).toBe(true);
await expect(assertMainLandmarkStructure(page)).resolves.toBeUndefined();
});
},
);

View File

@ -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<MainLandmarkStructure> {
const snapshot = await page.evaluate(() => {
// Roles that make an element a landmark. `main` is one of them, so a <main>
// inside another <main> is reported as a nesting problem as well as a duplicate.
const landmarkRoles = new Set([
'banner',
'complementary',
'contentinfo',
'form',
'main',
'navigation',
'region',
'search',
]);
// <header> and <footer> only map to banner/contentinfo outside sectioning content.
const sectioningTags = new Set(['ARTICLE', 'ASIDE', 'MAIN', 'NAV', 'SECTION']);
// Composed-tree parent. A slotted element is rendered where its <slot> sits, so the
// walk continues there; an element at the root of a shadow tree continues at its host.
const parentOf = (element: Element): Element | null => {
if (element.assignedSlot) return element.assignedSlot;
const parent: ParentNode | null = element.parentNode;
if (parent instanceof ShadowRoot) return parent.host;
return parent instanceof Element ? parent : null;
};
// ID references resolve inside the element's own root, so a shadow tree
// resolves against itself and not against the document.
const rootOf = (element: Element): Document | ShadowRoot => {
const root = element.getRootNode();
return root instanceof ShadowRoot ? root : document;
};
// The naming attribute an element carries instead of text content.
const nameFromAttributes = (element: Element): string => {
switch (element.tagName) {
case 'IMG':
case 'AREA':
return element.getAttribute('alt')?.trim() ?? '';
case 'INPUT': {
const type = element.getAttribute('type')?.trim().toLowerCase();
if (type === 'image') return element.getAttribute('alt')?.trim() ?? '';
if (type === 'button' || type === 'submit' || type === 'reset')
return element.getAttribute('value')?.trim() ?? '';
return '';
}
default:
return '';
}
};
/**
* The accessible name of an element, enough of the computation for this check:
* `aria-label`, then `aria-labelledby`, then a naming attribute, then - only for an
* element reached through a reference - its content, and `title` as the last resort.
*
* `fromReference` keeps name-from-content off the element being classified, because
* `region` and `form` do not take their name from content: a plain `<section>` full of
* text stays unnamed, and so is not a landmark. A referenced element does name through
* its content, which is how `<h2 id="title">Settings</h2>` names the section pointing at
* it, and how an `<img alt="...">` inside that element still names it.
*
* `visited` bounds the walk, so a reference cycle ends instead of recursing forever.
*/
const accessibleNameOf = (
element: Element,
visited: Set<Element>,
fromReference: boolean,
): string => {
if (visited.has(element)) return '';
visited.add(element);
const ariaLabel = element.getAttribute('aria-label')?.trim();
if (ariaLabel) return ariaLabel;
// A reference to a missing element, or to elements that name nothing, names nothing.
const labelledBy = element.getAttribute('aria-labelledby')?.trim();
if (labelledBy) {
const root = rootOf(element);
const referenced = labelledBy
.split(/\s+/)
.map((id) => root.getElementById(id))
.map((target) => (target ? accessibleNameOf(target, visited, true) : ''))
.filter(Boolean)
.join(' ');
if (referenced) return referenced;
}
const fromAttributes = nameFromAttributes(element);
if (fromAttributes) return fromAttributes;
if (fromReference) {
const fromContent = Array.from(element.childNodes)
.map((child) =>
child instanceof Element
? accessibleNameOf(child, visited, true)
: (child.textContent ?? ''),
)
.filter((name) => name.trim())
.join(' ')
.trim();
if (fromContent) return fromContent;
}
return element.getAttribute('title')?.trim() ?? '';
};
const hasAccessibleName = (element: Element) => !!accessibleNameOf(element, new Set(), false);
const landmarkRoleOf = (element: Element): string | undefined => {
// An explicit role wins over the tag, including when it is not a landmark.
const explicit = element.getAttribute('role')?.trim().toLowerCase();
if (explicit) return landmarkRoles.has(explicit) ? explicit : undefined;
switch (element.tagName) {
case 'MAIN':
return 'main';
case 'NAV':
return 'navigation';
case 'ASIDE':
return 'complementary';
case 'HEADER':
case 'FOOTER': {
for (let ancestor = parentOf(element); ancestor; ancestor = parentOf(ancestor)) {
if (sectioningTags.has(ancestor.tagName)) return undefined;
}
return element.tagName === 'HEADER' ? 'banner' : 'contentinfo';
}
// <section> and <form> are landmarks only once they carry an accessible name.
case 'SECTION':
return hasAccessibleName(element) ? 'region' : undefined;
case 'FORM':
return hasAccessibleName(element) ? 'form' : undefined;
default:
return undefined;
}
};
// Enough to point a reader at the element without dumping its markup.
const labelFor = (element: Element) => {
const id = element.id ? `#${element.id}` : '';
const classes =
typeof element.className === 'string'
? element.className.trim().split(/\s+/).filter(Boolean)
: [];
const firstClass = !id && classes.length > 0 ? `.${classes[0]}` : '';
return `${element.tagName.toLowerCase()}${id}${firstClass}`;
};
const landmarkAncestorOf = (element: Element): string | null => {
for (let ancestor = parentOf(element); ancestor; ancestor = parentOf(ancestor)) {
const role = landmarkRoleOf(ancestor);
if (role) return `${labelFor(ancestor)} (${role})`;
}
return null;
};
const elements: Element[] = [];
const collect = (root: Document | ShadowRoot) => {
for (const element of root.querySelectorAll('*')) {
elements.push(element);
if (element.shadowRoot) collect(element.shadowRoot);
}
};
collect(document);
return {
mains: elements
.filter((element) => element.tagName === 'MAIN')
.map((main) => ({ element: labelFor(main), landmarkAncestor: landmarkAncestorOf(main) })),
contentIds: elements.filter((element) => element.id === 'content').map(labelFor),
};
});
const problems: MainLandmarkProblem[] = [];
const listed = (elements: string[]) => (elements.length > 0 ? `: ${elements.join(', ')}` : '');
if (snapshot.mains.length !== 1) {
problems.push({
rule: 'single-main',
message: `Expected exactly one <main> element, found ${snapshot.mains.length}${listed(
snapshot.mains.map((main) => main.element),
)}`,
});
}
for (const main of snapshot.mains) {
if (!main.landmarkAncestor) continue;
problems.push({
rule: 'main-not-nested',
message: `<main> element ${main.element} is inside another landmark ${main.landmarkAncestor}`,
});
}
if (snapshot.contentIds.length > 1) {
problems.push({
rule: 'unique-content-id',
message: `Expected id="content" on at most one element, found ${
snapshot.contentIds.length
}${listed(snapshot.contentIds)}`,
});
}
return { ok: problems.length === 0, problems };
}
/**
* Same check as {@link checkMainLandmarkStructure}, as an assertion: it throws with
* every problem it found, so a journey can fail on the structure without reading the
* result itself.
*/
export async function assertMainLandmarkStructure(page: Page): Promise<void> {
const { ok, problems } = await checkMainLandmarkStructure(page);
if (ok) return;
const detail = problems.map((problem) => ` [${problem.rule}] ${problem.message}`).join('\n');
throw new TestError(`Main landmark structure check failed at ${page.url()}:\n${detail}`);
}