From c1033fa9614268dfde8915214393432f89a25e92 Mon Sep 17 00:00:00 2001 From: quality Date: Sat, 3 Oct 2026 21:26:22 -0400 Subject: [PATCH] test: add static markup gate for ids, ARIA idrefs, img alt and script data-* hooks Adds scripts/page-markup.test.mjs, a zero-dependency gate over every committed non-redirect HTML page: ids are unique, ARIA idrefs and label[for] resolve, every img has alt, the root html element declares lang, role=tab/tabpanel carry their ARIA pair, and every [data-*] selector an inline script queries exists in the markup. Each rule has a fixture self-test. Picked up by the existing scripts/*.test.mjs glob in the links workflow test job; no workflow change. Closes #83 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: quality --- README.md | 7 + scripts/page-markup.test.mjs | 287 +++++++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 scripts/page-markup.test.mjs diff --git a/README.md b/README.md index 8f74a72..2519289 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,13 @@ entry has no committed page, or a committed page differs from what the script em page: each inline ` + +
+

x

+ +ab +B logo +`; + +test("fixture: clean page passes every rule", () => { + assert.deepEqual(duplicateIds(CLEAN), []); + assert.deepEqual(danglingIdrefs(CLEAN), []); + assert.deepEqual(imagesWithoutAlt(CLEAN), []); + assert.deepEqual(missingHtmlLang(CLEAN), []); + assert.deepEqual(tablistWiring(CLEAN), []); + assert.deepEqual(scriptDataHooks(CLEAN), ["data-item", "data-root"]); + assert.deepEqual(missingDataHooks(CLEAN), []); +}); + +test("fixture: duplicate and empty ids are reported with both lines", () => { + const html = `\n

1

\n

2

\n

3

`; + assert.deepEqual(duplicateIds(html), [ + 'line 3: duplicate id "a" (first at line 2)', + "line 4: empty id attribute", + ]); +}); + +test("fixture: idrefs that do not resolve are reported, including one token of a list", () => { + const html = `\n\n\n\n\n`; + assert.deepEqual(danglingIdrefs(html), [ + 'line 2: aria-controls="nope" does not match any id on the page', + 'line 3: aria-describedby="h2" does not match any id on the page', + 'line 5: for="missing" does not match any id on the page', + ]); +}); + +test("fixture: ids declared only inside \n
`; + assert.equal(danglingIdrefs(html).length, 1); +}); + +test("fixture: without alt is reported; alt=\"\" and unquoted alt are fine", () => { + const html = `\n\n\nx\n`; + assert.deepEqual(imagesWithoutAlt(html), [ + "line 2: without alt attribute", + "line 5: without alt attribute", + ]); +}); + +test("fixture: missing or empty is reported", () => { + assert.deepEqual(missingHtmlLang(``), ["line 1: has no lang attribute"]); + assert.deepEqual(missingHtmlLang(``), ["line 1: has no lang attribute"]); + assert.deepEqual(missingHtmlLang(``), ["no element"]); + assert.deepEqual(missingHtmlLang(``), []); +}); + +test("fixture: tabs and tabpanels missing their ARIA pair are reported", () => { + const html = `\n\n
\n
`; + assert.deepEqual(tablistWiring(html), [ + 'line 2: role="tab" without aria-controls', + 'line 3: role="tabpanel" without aria-labelledby', + ]); +}); + +test("fixture: data-* hooks are collected only from runnable inline scripts", () => { + const html = ` + + + + +`; + assert.deepEqual(scriptDataHooks(html), ["data-a", "data-b"]); + assert.deepEqual(missingDataHooks(html), [ + "inline script queries [data-b] but no element in the page carries data-b", + ]); +}); + +test("fixture: a data-* hook mentioned only in CSS or script text does not count as present", () => { + const html = `\n\n`; + assert.equal(missingDataHooks(html).length, 1); +}); + +test("fixture: a hook prefix does not satisfy a longer hook name", () => { + const html = `\n`; + assert.equal(missingDataHooks(html).length, 1); +}); + +// --------------------------------------------------------------------------- +// Live gate over the committed pages. +// --------------------------------------------------------------------------- + +const pages = htmlFiles().filter((p) => !isRedirectPage(readFileSync(p, "utf8"))); + +test("the site has committed non-redirect HTML pages to check", () => { + assert.ok(pages.includes(join(ROOT, "index.html"))); + assert.ok(pages.includes(join(ROOT, "stories", "index.html"))); +}); + +for (const page of pages) { + const rel = relative(ROOT, page); + const html = readFileSync(page, "utf8"); + + test(`${rel}: ids are unique`, () => { + assert.deepEqual(duplicateIds(html), []); + }); + + test(`${rel}: every ARIA idref and label[for] resolves to an id on the page`, () => { + assert.deepEqual(danglingIdrefs(html), []); + }); + + test(`${rel}: every has an alt attribute`, () => { + assert.deepEqual(imagesWithoutAlt(html), []); + }); + + test(`${rel}: declares lang`, () => { + assert.deepEqual(missingHtmlLang(html), []); + }); + + test(`${rel}: role=tab has aria-controls and role=tabpanel has aria-labelledby`, () => { + assert.deepEqual(tablistWiring(html), []); + }); + + test(`${rel}: every data-* hook queried by an inline script exists in the markup`, () => { + assert.deepEqual(missingDataHooks(html), []); + }); +} + +test("index.html: the interactive scripts' data-* hooks are the ones the stub-DOM tests exercise", () => { + const hooks = scriptDataHooks(readFileSync(join(ROOT, "index.html"), "utf8")); + for (const expected of ["data-hero-carousel", "data-hero-slide", "data-acmm-levels", "data-acmm-panel", "data-carousel", "data-car-track"]) { + assert.ok(hooks.includes(expected), `expected inline scripts to query [${expected}]; got ${hooks.join(", ")}`); + } +});