From 7af661bda3fd626253912fedbf0a594015e93bd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristj=C3=A1n=20Oddsson?= Date: Fri, 13 Mar 2026 11:56:16 +0000 Subject: [PATCH] Enable ACT tests for html-has-lang, html-lang-valid, empty-heading, duplicate-id - Remove b5c3f8, bf051a, ffd0e9, 3ea0c8 from rulesToIgnore in generate-act-tests.js - Register html-has-lang rule in scanner.ts (was implemented but not imported) - Fix html-lang-valid to validate only the primary language subtag per BCP 47 (e.g. "en-US-GB" is valid because primary subtag "en" is valid) - Update unit test for html-lang-valid to match corrected behavior - Mark html-has-lang, html-lang-valid, empty-heading, duplicate-id as implemented in rules.json - Note: ffd0e9 (empty-heading) and 3ea0c8 (duplicate-id) have no WCAG-mapped ACT test cases, so no test files are generated for them Part of #348 Co-Authored-By: Claude Opus 4.6 --- generate-act-tests.js | 4 ---- rules.json | 8 +++---- src/rules/aria-allowed-attr.ts | 24 ++++++++++++------- src/rules/html-lang-valid.ts | 11 +++++++-- src/scanner.ts | 2 ++ ...fac26928e2bf6b7db6c7f46a1e0ab50aaa8a7c1.ts | 23 ++++++++++++++++++ ...73352935acf2463b14dbd8e38073e913eeb5c08.ts | 23 ++++++++++++++++++ ...ea0280617a1b71dcc327356484f8767919b0f40.ts | 23 ++++++++++++++++++ ...f94c3e26f43701d91db403fe26cd8894bdc8ccf.ts | 23 ++++++++++++++++++ ...8681b2a7949e49b2da1b353f70e688528fe7ddc.ts | 23 ++++++++++++++++++ ...f73e7179e17f050380f0ea350d2551611820fd5.ts | 23 ++++++++++++++++++ ...c998eef8cb13a8f577dade1a3b9fe591bc69204.ts | 19 +++++++++++++++ ...d8c4fd028c504d10c4e5e9bd7183c139549e1a1.ts | 19 +++++++++++++++ ...49f11c86ad81c4d42700dfca58a7eeec377f02e.ts | 19 +++++++++++++++ ...64d767d873269ff00966630e34ab198fc24368f.ts | 23 ++++++++++++++++++ ...7a35f8080e756776877bca013a910dafde8ef73.ts | 19 +++++++++++++++ tests/html-lang-valid.ts | 16 ++++--------- 17 files changed, 273 insertions(+), 29 deletions(-) create mode 100644 tests/act/tests/b5c3f8/0fac26928e2bf6b7db6c7f46a1e0ab50aaa8a7c1.ts create mode 100644 tests/act/tests/b5c3f8/473352935acf2463b14dbd8e38073e913eeb5c08.ts create mode 100644 tests/act/tests/b5c3f8/4ea0280617a1b71dcc327356484f8767919b0f40.ts create mode 100644 tests/act/tests/b5c3f8/4f94c3e26f43701d91db403fe26cd8894bdc8ccf.ts create mode 100644 tests/act/tests/b5c3f8/98681b2a7949e49b2da1b353f70e688528fe7ddc.ts create mode 100644 tests/act/tests/bf051a/0f73e7179e17f050380f0ea350d2551611820fd5.ts create mode 100644 tests/act/tests/bf051a/5c998eef8cb13a8f577dade1a3b9fe591bc69204.ts create mode 100644 tests/act/tests/bf051a/7d8c4fd028c504d10c4e5e9bd7183c139549e1a1.ts create mode 100644 tests/act/tests/bf051a/a49f11c86ad81c4d42700dfca58a7eeec377f02e.ts create mode 100644 tests/act/tests/bf051a/b64d767d873269ff00966630e34ab198fc24368f.ts create mode 100644 tests/act/tests/bf051a/b7a35f8080e756776877bca013a910dafde8ef73.ts diff --git a/generate-act-tests.js b/generate-act-tests.js index 2423b1cf..60cfdfa8 100644 --- a/generate-act-tests.js +++ b/generate-act-tests.js @@ -114,7 +114,6 @@ const rulesToIgnore = [ "97a4e1", // Button has non-empty accessible name - not implemented "cae760", // Iframe element has non-empty accessible name - not implemented "e086e5", // Form field has non-empty accessible name - not implemented - "ffd0e9", // Heading has non-empty accessible name - not implemented "m6b1q3", // Menuitem has non-empty accessible name - not implemented // --- Not implemented - ARIA rules not yet fully supported --- @@ -137,8 +136,6 @@ const rulesToIgnore = [ // --- Not implemented - page-level and structural rules --- "2779a5", // HTML page has non-empty title - not implemented in ACT test format - "b5c3f8", // HTML page has lang attribute - not implemented in ACT test format - "bf051a", // HTML page lang attribute has valid language tag - not implemented in ACT test format "off6ek", // HTML element language subtag matches language - not implemented "ucwvc8", // HTML page language subtag matches default language - not implemented "c4a8a4", // HTML page title is descriptive - not implemented, requires human judgment @@ -160,7 +157,6 @@ const rulesToIgnore = [ "efbfc7", // Text content that changes automatically can be paused, stopped or hidden - not implemented // --- Unknown or deprecated ACT rules --- - "3ea0c8", // Unknown ACT rule - not in current testcases "e6952f", // Unknown ACT rule - not in current testcases ]; diff --git a/rules.json b/rules.json index e8799b5b..a22c9da9 100644 --- a/rules.json +++ b/rules.json @@ -273,7 +273,7 @@ "ACT Rules": "[3ea0c8](https://act-rules.github.io/rules/3ea0c8)" }, { - "implemented": "❌", + "implemented": "✅", "id": "duplicate-id", "url": "https://dequeuniversity.com/rules/axe/4.11/duplicate-id?application=RuleDescription", "Description": "Ensures every id attribute value is unique", @@ -323,7 +323,7 @@ "ACT Rules": "[cae760](https://act-rules.github.io/rules/cae760)" }, { - "implemented": "❌", + "implemented": "✅", "id": "html-has-lang", "url": "https://dequeuniversity.com/rules/axe/4.11/html-has-lang?application=RuleDescription", "Description": "Ensures every HTML document has a lang attribute", @@ -333,7 +333,7 @@ "ACT Rules": "[b5c3f8](https://act-rules.github.io/rules/b5c3f8)" }, { - "implemented": "❌", + "implemented": "✅", "id": "html-lang-valid", "url": "https://dequeuniversity.com/rules/axe/4.11/html-lang-valid?application=RuleDescription", "Description": "Ensures the lang attribute of the <html> element has a valid value", @@ -653,7 +653,7 @@ "ACT Rules": "" }, { - "implemented": "❌", + "implemented": "✅", "id": "empty-heading", "url": "https://dequeuniversity.com/rules/axe/4.11/empty-heading?application=RuleDescription", "Description": "Ensures headings have discernible text", diff --git a/src/rules/aria-allowed-attr.ts b/src/rules/aria-allowed-attr.ts index f59b6eb4..4eb220af 100644 --- a/src/rules/aria-allowed-attr.ts +++ b/src/rules/aria-allowed-attr.ts @@ -99,25 +99,33 @@ const implicitRoles: Record = { function getInputImplicitRole(element: Element): string | undefined { const type = (element.getAttribute("type") || "text").toLowerCase(); switch (type) { - case "checkbox": + case "checkbox": { return "checkbox"; - case "radio": + } + case "radio": { return "radio"; - case "range": + } + case "range": { return "slider"; - case "number": + } + case "number": { return "spinbutton"; - case "search": + } + case "search": { return "searchbox"; + } case "button": case "image": case "reset": - case "submit": + case "submit": { return "button"; - case "hidden": + } + case "hidden": { return undefined; - default: + } + default: { return "textbox"; + } } } diff --git a/src/rules/html-lang-valid.ts b/src/rules/html-lang-valid.ts index 00363481..bb0d81e9 100644 --- a/src/rules/html-lang-valid.ts +++ b/src/rules/html-lang-valid.ts @@ -5,12 +5,19 @@ const text = "The lang attribute of the element must have a valid value"; const url = `https://dequeuniversity.com/rules/axe/4.11/${id}`; function langIsValid(locale: string): boolean { + // Extract the primary language subtag (before any hyphen). + // BCP 47 requires only the primary subtag to be a valid ISO 639 code; + // additional subtags (region, variant, etc.) don't affect validity for + // this rule. + const primarySubtag = locale.split("-")[0]; + if (!primarySubtag) return false; + try { - const foundLocales = Intl.DisplayNames.supportedLocalesOf([locale], { + const foundLocales = Intl.DisplayNames.supportedLocalesOf([primarySubtag], { localeMatcher: "lookup", }); if (foundLocales.length !== 1) return false; - return foundLocales[0].toLowerCase() === locale.toLowerCase(); + return foundLocales[0].toLowerCase() === primarySubtag.toLowerCase(); } catch { return false; } diff --git a/src/scanner.ts b/src/scanner.ts index 50212ba3..13f41165 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -39,6 +39,7 @@ import list from "./rules/list"; import dlitem from "./rules/dlitem"; import nestedInteractive from "./rules/nested-interactive"; import validLang from "./rules/valid-lang"; +import htmlHasLang from "./rules/html-has-lang"; import htmlLangValid from "./rules/html-lang-valid"; import htmlXmlLangMismatch from "./rules/html-xml-lang-mismatch"; import colorContrast from "./rules/color-contrast"; @@ -156,6 +157,7 @@ export const allRules: Rule[] = [ frameTitle, frameTitleUnique, headingOrder, + htmlHasLang, htmlLangValid, htmlXmlLangMismatch, identicalLinksSamePurpose, diff --git a/tests/act/tests/b5c3f8/0fac26928e2bf6b7db6c7f46a1e0ab50aaa8a7c1.ts b/tests/act/tests/b5c3f8/0fac26928e2bf6b7db6c7f46a1e0ab50aaa8a7c1.ts new file mode 100644 index 00000000..9ea16bfe --- /dev/null +++ b/tests/act/tests/b5c3f8/0fac26928e2bf6b7db6c7f46a1e0ab50aaa8a7c1.ts @@ -0,0 +1,23 @@ +import { expect } from "@open-wc/testing"; +import { scan } from "../../../../src/scanner"; + +const parser = new DOMParser(); + +describe("[b5c3f8]HTML page has lang attribute", function () { + it("Passed Example 1 (https://www.w3.org/WAI/content-assets/wcag-act-rules/testcases/b5c3f8/0fac26928e2bf6b7db6c7f46a1e0ab50aaa8a7c1.html)", async () => { + const document = parser.parseFromString(` + + + The quick brown fox jumps over the lazy dog. + +`, 'text/html'); + + const results = (await scan(document.body)).map(({ text, url }) => { + return { text, url }; + }); + + const expectedUrls = ["https://dequeuniversity.com/rules/axe/4.11/html-has-lang"]; + const relevant = results.filter(r => expectedUrls.includes(r.url)); + expect(relevant).to.be.empty; + }); +}); diff --git a/tests/act/tests/b5c3f8/473352935acf2463b14dbd8e38073e913eeb5c08.ts b/tests/act/tests/b5c3f8/473352935acf2463b14dbd8e38073e913eeb5c08.ts new file mode 100644 index 00000000..ec0ce22f --- /dev/null +++ b/tests/act/tests/b5c3f8/473352935acf2463b14dbd8e38073e913eeb5c08.ts @@ -0,0 +1,23 @@ +import { expect } from "@open-wc/testing"; +import { scan } from "../../../../src/scanner"; + +const parser = new DOMParser(); + +describe("[b5c3f8]HTML page has lang attribute", function () { + it("Failed Example 1 (https://www.w3.org/WAI/content-assets/wcag-act-rules/testcases/b5c3f8/473352935acf2463b14dbd8e38073e913eeb5c08.html)", async () => { + const document = parser.parseFromString(` + + + The quick brown fox jumps over the lazy dog. + +`, 'text/html'); + + const results = (await scan(document.body)).map(({ text, url }) => { + return { text, url }; + }); + + expect(results).to.not.be.empty; + const expectedUrls = ["https://dequeuniversity.com/rules/axe/4.11/html-has-lang"]; + expect(results.some(r => expectedUrls.includes(r.url))).to.be.true; + }); +}); diff --git a/tests/act/tests/b5c3f8/4ea0280617a1b71dcc327356484f8767919b0f40.ts b/tests/act/tests/b5c3f8/4ea0280617a1b71dcc327356484f8767919b0f40.ts new file mode 100644 index 00000000..cc348b79 --- /dev/null +++ b/tests/act/tests/b5c3f8/4ea0280617a1b71dcc327356484f8767919b0f40.ts @@ -0,0 +1,23 @@ +import { expect } from "@open-wc/testing"; +import { scan } from "../../../../src/scanner"; + +const parser = new DOMParser(); + +describe("[b5c3f8]HTML page has lang attribute", function () { + it("Failed Example 3 (https://www.w3.org/WAI/content-assets/wcag-act-rules/testcases/b5c3f8/4ea0280617a1b71dcc327356484f8767919b0f40.html)", async () => { + const document = parser.parseFromString(` + + + The quick brown fox jumps over the lazy dog. + +`, 'text/html'); + + const results = (await scan(document.body)).map(({ text, url }) => { + return { text, url }; + }); + + expect(results).to.not.be.empty; + const expectedUrls = ["https://dequeuniversity.com/rules/axe/4.11/html-has-lang"]; + expect(results.some(r => expectedUrls.includes(r.url))).to.be.true; + }); +}); diff --git a/tests/act/tests/b5c3f8/4f94c3e26f43701d91db403fe26cd8894bdc8ccf.ts b/tests/act/tests/b5c3f8/4f94c3e26f43701d91db403fe26cd8894bdc8ccf.ts new file mode 100644 index 00000000..2a0ae6d6 --- /dev/null +++ b/tests/act/tests/b5c3f8/4f94c3e26f43701d91db403fe26cd8894bdc8ccf.ts @@ -0,0 +1,23 @@ +import { expect } from "@open-wc/testing"; +import { scan } from "../../../../src/scanner"; + +const parser = new DOMParser(); + +describe("[b5c3f8]HTML page has lang attribute", function () { + it("Failed Example 4 (https://www.w3.org/WAI/content-assets/wcag-act-rules/testcases/b5c3f8/4f94c3e26f43701d91db403fe26cd8894bdc8ccf.html)", async () => { + const document = parser.parseFromString(` + + + The quick brown fox jumps over the lazy dog. + +`, 'text/html'); + + const results = (await scan(document.body)).map(({ text, url }) => { + return { text, url }; + }); + + expect(results).to.not.be.empty; + const expectedUrls = ["https://dequeuniversity.com/rules/axe/4.11/html-has-lang"]; + expect(results.some(r => expectedUrls.includes(r.url))).to.be.true; + }); +}); diff --git a/tests/act/tests/b5c3f8/98681b2a7949e49b2da1b353f70e688528fe7ddc.ts b/tests/act/tests/b5c3f8/98681b2a7949e49b2da1b353f70e688528fe7ddc.ts new file mode 100644 index 00000000..bb8e0f57 --- /dev/null +++ b/tests/act/tests/b5c3f8/98681b2a7949e49b2da1b353f70e688528fe7ddc.ts @@ -0,0 +1,23 @@ +import { expect } from "@open-wc/testing"; +import { scan } from "../../../../src/scanner"; + +const parser = new DOMParser(); + +describe("[b5c3f8]HTML page has lang attribute", function () { + it("Failed Example 2 (https://www.w3.org/WAI/content-assets/wcag-act-rules/testcases/b5c3f8/98681b2a7949e49b2da1b353f70e688528fe7ddc.html)", async () => { + const document = parser.parseFromString(` + + + The quick brown fox jumps over the lazy dog. + +`, 'text/html'); + + const results = (await scan(document.body)).map(({ text, url }) => { + return { text, url }; + }); + + expect(results).to.not.be.empty; + const expectedUrls = ["https://dequeuniversity.com/rules/axe/4.11/html-has-lang"]; + expect(results.some(r => expectedUrls.includes(r.url))).to.be.true; + }); +}); diff --git a/tests/act/tests/bf051a/0f73e7179e17f050380f0ea350d2551611820fd5.ts b/tests/act/tests/bf051a/0f73e7179e17f050380f0ea350d2551611820fd5.ts new file mode 100644 index 00000000..977ade64 --- /dev/null +++ b/tests/act/tests/bf051a/0f73e7179e17f050380f0ea350d2551611820fd5.ts @@ -0,0 +1,23 @@ +import { expect } from "@open-wc/testing"; +import { scan } from "../../../../src/scanner"; + +const parser = new DOMParser(); + +describe("[bf051a]HTML page `lang` attribute has valid language tag", function () { + it("Failed Example 3 (https://www.w3.org/WAI/content-assets/wcag-act-rules/testcases/bf051a/0f73e7179e17f050380f0ea350d2551611820fd5.html)", async () => { + const document = parser.parseFromString(` + + +

I love ACT rules!

+ +`, 'text/html'); + + const results = (await scan(document.body)).map(({ text, url }) => { + return { text, url }; + }); + + expect(results).to.not.be.empty; + const expectedUrls = ["https://dequeuniversity.com/rules/axe/4.11/html-lang-valid"]; + expect(results.some(r => expectedUrls.includes(r.url))).to.be.true; + }); +}); diff --git a/tests/act/tests/bf051a/5c998eef8cb13a8f577dade1a3b9fe591bc69204.ts b/tests/act/tests/bf051a/5c998eef8cb13a8f577dade1a3b9fe591bc69204.ts new file mode 100644 index 00000000..8ffb5360 --- /dev/null +++ b/tests/act/tests/bf051a/5c998eef8cb13a8f577dade1a3b9fe591bc69204.ts @@ -0,0 +1,19 @@ +import { expect } from "@open-wc/testing"; +import { scan } from "../../../../src/scanner"; + +const parser = new DOMParser(); + +describe("[bf051a]HTML page `lang` attribute has valid language tag", function () { + it("Failed Example 2 (https://www.w3.org/WAI/content-assets/wcag-act-rules/testcases/bf051a/5c998eef8cb13a8f577dade1a3b9fe591bc69204.html)", async () => { + const document = parser.parseFromString(` +`, 'text/html'); + + const results = (await scan(document.body)).map(({ text, url }) => { + return { text, url }; + }); + + expect(results).to.not.be.empty; + const expectedUrls = ["https://dequeuniversity.com/rules/axe/4.11/html-lang-valid"]; + expect(results.some(r => expectedUrls.includes(r.url))).to.be.true; + }); +}); diff --git a/tests/act/tests/bf051a/7d8c4fd028c504d10c4e5e9bd7183c139549e1a1.ts b/tests/act/tests/bf051a/7d8c4fd028c504d10c4e5e9bd7183c139549e1a1.ts new file mode 100644 index 00000000..5b984e11 --- /dev/null +++ b/tests/act/tests/bf051a/7d8c4fd028c504d10c4e5e9bd7183c139549e1a1.ts @@ -0,0 +1,19 @@ +import { expect } from "@open-wc/testing"; +import { scan } from "../../../../src/scanner"; + +const parser = new DOMParser(); + +describe("[bf051a]HTML page `lang` attribute has valid language tag", function () { + it("Passed Example 1 (https://www.w3.org/WAI/content-assets/wcag-act-rules/testcases/bf051a/7d8c4fd028c504d10c4e5e9bd7183c139549e1a1.html)", async () => { + const document = parser.parseFromString(` +`, 'text/html'); + + const results = (await scan(document.body)).map(({ text, url }) => { + return { text, url }; + }); + + const expectedUrls = ["https://dequeuniversity.com/rules/axe/4.11/html-lang-valid"]; + const relevant = results.filter(r => expectedUrls.includes(r.url)); + expect(relevant).to.be.empty; + }); +}); diff --git a/tests/act/tests/bf051a/a49f11c86ad81c4d42700dfca58a7eeec377f02e.ts b/tests/act/tests/bf051a/a49f11c86ad81c4d42700dfca58a7eeec377f02e.ts new file mode 100644 index 00000000..72469710 --- /dev/null +++ b/tests/act/tests/bf051a/a49f11c86ad81c4d42700dfca58a7eeec377f02e.ts @@ -0,0 +1,19 @@ +import { expect } from "@open-wc/testing"; +import { scan } from "../../../../src/scanner"; + +const parser = new DOMParser(); + +describe("[bf051a]HTML page `lang` attribute has valid language tag", function () { + it("Passed Example 2 (https://www.w3.org/WAI/content-assets/wcag-act-rules/testcases/bf051a/a49f11c86ad81c4d42700dfca58a7eeec377f02e.html)", async () => { + const document = parser.parseFromString(` +`, 'text/html'); + + const results = (await scan(document.body)).map(({ text, url }) => { + return { text, url }; + }); + + const expectedUrls = ["https://dequeuniversity.com/rules/axe/4.11/html-lang-valid"]; + const relevant = results.filter(r => expectedUrls.includes(r.url)); + expect(relevant).to.be.empty; + }); +}); diff --git a/tests/act/tests/bf051a/b64d767d873269ff00966630e34ab198fc24368f.ts b/tests/act/tests/bf051a/b64d767d873269ff00966630e34ab198fc24368f.ts new file mode 100644 index 00000000..8f6688f6 --- /dev/null +++ b/tests/act/tests/bf051a/b64d767d873269ff00966630e34ab198fc24368f.ts @@ -0,0 +1,23 @@ +import { expect } from "@open-wc/testing"; +import { scan } from "../../../../src/scanner"; + +const parser = new DOMParser(); + +describe("[bf051a]HTML page `lang` attribute has valid language tag", function () { + it("Failed Example 4 (https://www.w3.org/WAI/content-assets/wcag-act-rules/testcases/bf051a/b64d767d873269ff00966630e34ab198fc24368f.html)", async () => { + const document = parser.parseFromString(` + + +

Lëtzebuerg ass e Land an Europa.

+ +`, 'text/html'); + + const results = (await scan(document.body)).map(({ text, url }) => { + return { text, url }; + }); + + expect(results).to.not.be.empty; + const expectedUrls = ["https://dequeuniversity.com/rules/axe/4.11/html-lang-valid"]; + expect(results.some(r => expectedUrls.includes(r.url))).to.be.true; + }); +}); diff --git a/tests/act/tests/bf051a/b7a35f8080e756776877bca013a910dafde8ef73.ts b/tests/act/tests/bf051a/b7a35f8080e756776877bca013a910dafde8ef73.ts new file mode 100644 index 00000000..e0210c2a --- /dev/null +++ b/tests/act/tests/bf051a/b7a35f8080e756776877bca013a910dafde8ef73.ts @@ -0,0 +1,19 @@ +import { expect } from "@open-wc/testing"; +import { scan } from "../../../../src/scanner"; + +const parser = new DOMParser(); + +describe("[bf051a]HTML page `lang` attribute has valid language tag", function () { + it("Failed Example 1 (https://www.w3.org/WAI/content-assets/wcag-act-rules/testcases/bf051a/b7a35f8080e756776877bca013a910dafde8ef73.html)", async () => { + const document = parser.parseFromString(` +`, 'text/html'); + + const results = (await scan(document.body)).map(({ text, url }) => { + return { text, url }; + }); + + expect(results).to.not.be.empty; + const expectedUrls = ["https://dequeuniversity.com/rules/axe/4.11/html-lang-valid"]; + expect(results.some(r => expectedUrls.includes(r.url))).to.be.true; + }); +}); diff --git a/tests/html-lang-valid.ts b/tests/html-lang-valid.ts index 1137cb33..f140526c 100644 --- a/tests/html-lang-valid.ts +++ b/tests/html-lang-valid.ts @@ -24,7 +24,9 @@ describe("html-lang-valid", function () { ]); }); - it("detects malformed lang attribute", () => { + it("passes for lang attribute with extra subtags (primary subtag is valid)", () => { + // BCP 47 validity only requires the primary language subtag to be valid. + // "en-US-GB" has a valid primary subtag "en", so it should pass. const parser = new DOMParser(); const doc = parser.parseFromString( '', @@ -32,16 +34,8 @@ describe("html-lang-valid", function () { ); const htmlElement = doc.documentElement; - const results = htmlLangValid(htmlElement).map(({ text, url }) => { - return { text, url }; - }); - - expect(results).to.eql([ - { - text: "The lang attribute of the element must have a valid value", - url: "https://dequeuniversity.com/rules/axe/4.11/html-lang-valid", - }, - ]); + const results = htmlLangValid(htmlElement); + expect(results).to.be.empty; }); it("detects numeric lang attribute", () => {