diff --git a/packages/dom/package.json b/packages/dom/package.json index 33b275d..c922ab5 100644 --- a/packages/dom/package.json +++ b/packages/dom/package.json @@ -35,6 +35,7 @@ "test": "NODE_ENV=test mocha" }, "dependencies": { + "dom-accessibility-api": "^0.5.16", "fast-deep-equal": "^3.1.3", "tslib": "^2.8.1" }, diff --git a/packages/dom/src/lib/ElementAssertion.ts b/packages/dom/src/lib/ElementAssertion.ts index 8207cc9..71aa40e 100644 --- a/packages/dom/src/lib/ElementAssertion.ts +++ b/packages/dom/src/lib/ElementAssertion.ts @@ -1,7 +1,12 @@ import { Assertion, AssertionError } from "@assertive-ts/core"; +import { computeAccessibleDescription } from "dom-accessibility-api"; import equal from "fast-deep-equal"; -import { getAccessibleDescription, isValidAriaPressed } from "./helpers/accessibility"; +import { + describeAccessibleExpectation, + isValidAriaPressed, + matchesAccessibleExpectation, +} from "./helpers/accessibility"; import { isButtonElement, isElementEmpty, normalizeHtml } from "./helpers/dom"; import { getExpectedAndReceivedStyles } from "./helpers/styles"; @@ -293,63 +298,51 @@ export class ElementAssertion extends Assertion { /** * Asserts that the element has an accessible description. * - * The accessible description is computed from the `aria-describedby` - * attribute, which references one or more elements by ID. The text - * content of those elements is combined to form the description. + * The accessible description is computed following the + * [accname](https://www.w3.org/TR/accname/) specification, taking + * into account `aria-describedby`, `aria-description`, and `title`, + * among others. * * @example * ``` * // Check if element has any description - * expect(element).toHaveDescription(); + * expect(element).toHaveAccessibleDescription(); * * // Check if element has specific description text - * expect(element).toHaveDescription('Expected description text'); + * expect(element).toHaveAccessibleDescription("Expected description text"); * * // Check if element description matches a regex pattern - * expect(element).toHaveDescription(/description pattern/i); + * expect(element).toHaveAccessibleDescription(/description pattern/i); * ``` * * @param expectedDescription * - Optional expected description (string or RegExp). * @returns the assertion instance. */ - - public toHaveDescription(expectedDescription?: RegExp | string): this { - const description = getAccessibleDescription(this.actual); + public toHaveAccessibleDescription(expectedDescription?: RegExp | string): this { + const description = computeAccessibleDescription(this.actual); const hasExpectedValue = expectedDescription !== undefined; - const matchesExpectation = (desc: string): boolean => { - if (!hasExpectedValue) { - return Boolean(desc); - } - return expectedDescription instanceof RegExp - ? expectedDescription.test(desc) - : desc === expectedDescription; - }; - - const formatExpectation = (isRegExp: boolean): string => - isRegExp ? `matching ${expectedDescription}` : `"${expectedDescription}"`; + const expectation = describeAccessibleExpectation(expectedDescription); const error = new AssertionError({ actual: description, expected: expectedDescription, message: hasExpectedValue - ? `Expected the element to have description ${formatExpectation(expectedDescription instanceof RegExp)}, ` - + `but received "${description}"` - : "Expected the element to have a description", + ? `Expected the element to have accessible description ${expectation}, but received "${description}"` + : "Expected the element to have an accessible description", }); const invertedError = new AssertionError({ actual: description, expected: expectedDescription, message: hasExpectedValue - ? `Expected the element NOT to have description ${formatExpectation(expectedDescription instanceof RegExp)}, ` - + `but received "${description}"` - : `Expected the element NOT to have a description, but received "${description}"`, + ? `Expected the element NOT to have accessible description ${expectation}, but received "${description}"` + : `Expected the element NOT to have an accessible description, but received "${description}"`, }); return this.execute({ - assertWhen: matchesExpectation(description), + assertWhen: matchesAccessibleExpectation(description, expectedDescription), error, invertedError, }); diff --git a/packages/dom/src/lib/helpers/accessibility.ts b/packages/dom/src/lib/helpers/accessibility.ts index b3e3f7a..421ac83 100644 --- a/packages/dom/src/lib/helpers/accessibility.ts +++ b/packages/dom/src/lib/helpers/accessibility.ts @@ -1,35 +1,24 @@ -function normalizeText(text: string): string { - return text.replace(/\s+/g, " ").trim(); +export function isValidAriaPressed(element: Element): boolean { + const pressedAttribute = element.getAttribute("aria-pressed"); + return pressedAttribute !== null && ["true", "false", "mixed"].includes(pressedAttribute); } -export function getAccessibleDescription(actual: Element): string { - const ariaDescribedBy = actual.getAttribute("aria-describedby"); - - if (!ariaDescribedBy) { - return ""; +export function matchesAccessibleExpectation(actual: string, expected?: RegExp | string): boolean { + if (expected === undefined) { + return Boolean(actual); } - const descriptionIds = ariaDescribedBy.split(/\s+/).filter(Boolean); - - const getElementText = (id: string): null | string => { - const element = actual.ownerDocument.getElementById(id); - - if (!element || !element.textContent) { - return null; - } - - return element.textContent; - }; - - const combinedText = descriptionIds - .map(getElementText) - .filter((text): text is string => text !== null) - .join(" "); - - return normalizeText(combinedText); + return expected instanceof RegExp + ? expected.test(actual) + : actual === expected; } -export function isValidAriaPressed(element: Element): boolean { - const pressedAttribute = element.getAttribute("aria-pressed"); - return pressedAttribute !== null && ["true", "false", "mixed"].includes(pressedAttribute); +export function describeAccessibleExpectation(expected?: RegExp | string): string { + if (expected === undefined) { + return ""; + } + + return expected instanceof RegExp + ? `matching ${expected}` + : `"${expected}"`; } diff --git a/packages/dom/src/lib/helpers/dom.ts b/packages/dom/src/lib/helpers/dom.ts index 7850bc8..4eb8616 100644 --- a/packages/dom/src/lib/helpers/dom.ts +++ b/packages/dom/src/lib/helpers/dom.ts @@ -5,6 +5,8 @@ export function isElementEmpty(element: Element): boolean { return nonCommentChildNodes.length === 0; } +// Callers are internal: the public assertions already reject non-string +// and empty `htmlText` values before reaching here. export function normalizeHtml(htmlText: string, ownerDocument: Document): string { const div = ownerDocument.createElement("div"); div.innerHTML = htmlText; diff --git a/packages/dom/test/unit/lib/ElementAssertion.test.tsx b/packages/dom/test/unit/lib/ElementAssertion.test.tsx index 4b101a0..56ba294 100644 --- a/packages/dom/test/unit/lib/ElementAssertion.test.tsx +++ b/packages/dom/test/unit/lib/ElementAssertion.test.tsx @@ -469,7 +469,7 @@ describe("[Unit] ElementAssertion.test.ts", () => { }); }); - describe(".toHaveDescription", () => { + describe(".toHaveAccessibleDescription", () => { context("when checking for any description", () => { context("when the element has a description", () => { it("returns the assertion instance", () => { @@ -477,11 +477,14 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-single"); const test = new ElementAssertion(button); - expect(test.toHaveDescription()).toBeEqual(test); + expect(test.toHaveAccessibleDescription()).toBeEqual(test); - expect(() => test.not.toHaveDescription()) + expect(() => test.not.toHaveAccessibleDescription()) .toThrowError(AssertionError) - .toHaveMessage('Expected the element NOT to have a description, but received "This is a description"'); + .toHaveMessage( + "Expected the element NOT to have an accessible description, " + + 'but received "This is a description"', + ); }); }); @@ -491,11 +494,11 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-no-description"); const test = new ElementAssertion(button); - expect(() => test.toHaveDescription()) + expect(() => test.toHaveAccessibleDescription()) .toThrowError(AssertionError) - .toHaveMessage("Expected the element to have a description"); + .toHaveMessage("Expected the element to have an accessible description"); - expect(test.not.toHaveDescription()).toBeEqual(test); + expect(test.not.toHaveAccessibleDescription()).toBeEqual(test); }); }); }); @@ -507,12 +510,12 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-single"); const test = new ElementAssertion(button); - expect(test.toHaveDescription("This is a description")).toBeEqual(test); + expect(test.toHaveAccessibleDescription("This is a description")).toBeEqual(test); - expect(() => test.not.toHaveDescription("This is a description")) + expect(() => test.not.toHaveAccessibleDescription("This is a description")) .toThrowError(AssertionError) .toHaveMessage( - 'Expected the element NOT to have description "This is a description", ' + 'Expected the element NOT to have accessible description "This is a description", ' + 'but received "This is a description"', ); }); @@ -524,12 +527,12 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-multiple"); const test = new ElementAssertion(button); - expect(test.toHaveDescription("This is a description Additional info")).toBeEqual(test); + expect(test.toHaveAccessibleDescription("This is a description Additional info")).toBeEqual(test); - expect(() => test.not.toHaveDescription("This is a description Additional info")) + expect(() => test.not.toHaveAccessibleDescription("This is a description Additional info")) .toThrowError(AssertionError) .toHaveMessage( - 'Expected the element NOT to have description "This is a description Additional info", ' + 'Expected the element NOT to have accessible description "This is a description Additional info", ' + 'but received "This is a description Additional info"', ); }); @@ -541,13 +544,14 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-single"); const test = new ElementAssertion(button); - expect(() => test.toHaveDescription("Wrong description")) + expect(() => test.toHaveAccessibleDescription("Wrong description")) .toThrowError(AssertionError) .toHaveMessage( - 'Expected the element to have description "Wrong description", but received "This is a description"', + 'Expected the element to have accessible description "Wrong description", ' + + 'but received "This is a description"', ); - expect(test.not.toHaveDescription("Wrong description")).toBeEqual(test); + expect(test.not.toHaveAccessibleDescription("Wrong description")).toBeEqual(test); }); }); }); @@ -559,12 +563,12 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-single"); const test = new ElementAssertion(button); - expect(test.toHaveDescription(/description/i)).toBeEqual(test); + expect(test.toHaveAccessibleDescription(/description/i)).toBeEqual(test); - expect(() => test.not.toHaveDescription(/description/i)) + expect(() => test.not.toHaveAccessibleDescription(/description/i)) .toThrowError(AssertionError) .toHaveMessage( - "Expected the element NOT to have description matching /description/i, " + "Expected the element NOT to have accessible description matching /description/i, " + 'but received "This is a description"', ); }); @@ -576,14 +580,14 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-single"); const test = new ElementAssertion(button); - expect(() => test.toHaveDescription(/wrong pattern/)) + expect(() => test.toHaveAccessibleDescription(/wrong pattern/)) .toThrowError(AssertionError) .toHaveMessage( - "Expected the element to have description matching /wrong pattern/, " + "Expected the element to have accessible description matching /wrong pattern/, " + 'but received "This is a description"', ); - expect(test.not.toHaveDescription(/wrong pattern/)).toBeEqual(test); + expect(test.not.toHaveAccessibleDescription(/wrong pattern/)).toBeEqual(test); }); }); }); diff --git a/yarn.lock b/yarn.lock index 3b068dd..b1b15d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -51,6 +51,7 @@ __metadata: "@types/react": "npm:^19.2.0" "@types/react-dom": "npm:^18.3.0" "@types/react-test-renderer": "npm:^19.1.0" + dom-accessibility-api: "npm:^0.5.16" fast-deep-equal: "npm:^3.1.3" jsdom: "npm:^24.0.0" jsdom-global: "npm:^3.0.2" @@ -4140,7 +4141,7 @@ __metadata: languageName: node linkType: hard -"accepts@npm:~1.3.8": +"accepts@npm:~1.3.7": version: 1.3.8 resolution: "accepts@npm:1.3.8" dependencies: @@ -4379,9 +4380,9 @@ __metadata: linkType: hard "appdirsjs@npm:^1.2.4": - version: 1.2.8 - resolution: "appdirsjs@npm:1.2.8" - checksum: 10/fcb1475c0510bf94904fa4ab4ccd5eb2ef150ec86f078b1116f71271d11dfcb17246ce610f3136d7f8d3bc81c31e8aea71593f48925391c9b950c416be6efa5c + version: 1.2.7 + resolution: "appdirsjs@npm:1.2.7" + checksum: 10/8f6cb9cc18de2b38e2f5efddf764c5f0331aba4168ee28cb7370b98e1dc69316352b9a936acf4d628b4dcc510d77b1645ed4b68ab2231e302f835d35e11348d3 languageName: node linkType: hard @@ -5771,17 +5772,17 @@ __metadata: linkType: hard "compression@npm:^1.7.1": - version: 1.8.1 - resolution: "compression@npm:1.8.1" + version: 1.7.5 + resolution: "compression@npm:1.7.5" dependencies: bytes: "npm:3.1.2" compressible: "npm:~2.0.18" debug: "npm:2.6.9" negotiator: "npm:~0.6.4" - on-headers: "npm:~1.1.0" + on-headers: "npm:~1.0.2" safe-buffer: "npm:5.2.1" vary: "npm:~1.1.2" - checksum: 10/e7552bfbd780f2003c6fe8decb44561f5cc6bc82f0c61e81122caff5ec656f37824084f52155b1e8ef31d7656cecbec9a2499b7a68e92e20780ffb39b479abb7 + checksum: 10/c69cf6da151db6f9db2e242b6a0039ad41975ee886c385cff2920c5f8f7050678e0ee9a021437af033536c451791de529de376851b8d31fee42ca2d6adca03f0 languageName: node linkType: hard @@ -6130,9 +6131,9 @@ __metadata: linkType: hard "dayjs@npm:^1.8.15": - version: 1.11.23 - resolution: "dayjs@npm:1.11.23" - checksum: 10/61d29544ca1553369f75541b5abf9c1bb6a978991498923a2d5721c9df3eca513b2c15ff5534de6930dc553de5d6f2b8f8047149f3528df906b21f51df637463 + version: 1.11.13 + resolution: "dayjs@npm:1.11.13" + checksum: 10/7374d63ab179b8d909a95e74790def25c8986e329ae989840bacb8b1888be116d20e1c4eee75a69ea0dfbae13172efc50ef85619d304ee7ca3c01d5878b704f5 languageName: node linkType: hard @@ -6396,7 +6397,7 @@ __metadata: languageName: node linkType: hard -"dom-accessibility-api@npm:^0.5.9": +"dom-accessibility-api@npm:^0.5.16, dom-accessibility-api@npm:^0.5.9": version: 0.5.16 resolution: "dom-accessibility-api@npm:0.5.16" checksum: 10/377b4a7f9eae0a5d72e1068c369c99e0e4ca17fdfd5219f3abd32a73a590749a267475a59d7b03a891f9b673c27429133a818c44b2e47e32fec024b34274e2ca @@ -6606,12 +6607,12 @@ __metadata: linkType: hard "errorhandler@npm:^1.5.1": - version: 1.5.2 - resolution: "errorhandler@npm:1.5.2" + version: 1.5.1 + resolution: "errorhandler@npm:1.5.1" dependencies: - accepts: "npm:~1.3.8" + accepts: "npm:~1.3.7" escape-html: "npm:~1.0.3" - checksum: 10/7ce0a598cc2c52840e32b46d2da8c7b0a4594aa67e93db46112cf791d4c8a4a1299af7f7aa65253d2e9d42af4d275c96387c0d186427df5ee93d33670bdac541 + checksum: 10/73b7abb08fb751107e9bebecc33c40c0641a54be8bda8e4a045f3f5cb7b805041927fef5629ea39b1737799eb52fe2499ca531f11ac51b0294ccc4667d72cb91 languageName: node linkType: hard @@ -9874,15 +9875,15 @@ __metadata: linkType: hard "joi@npm:^17.2.1": - version: 17.13.6 - resolution: "joi@npm:17.13.6" + version: 17.13.3 + resolution: "joi@npm:17.13.3" dependencies: "@hapi/hoek": "npm:^9.3.0" "@hapi/topo": "npm:^5.1.0" "@sideway/address": "npm:^4.1.5" "@sideway/formula": "npm:^3.0.1" "@sideway/pinpoint": "npm:^2.0.0" - checksum: 10/9fd7f2ecdb39deaf298b12de824181e5f50e6d562dc1d05512ee3963e607994b54621299ce956b0e3b12c982f8eb71a7126883d3311ccdda1d6efff1986f2276 + checksum: 10/4c150db0c820c3a52f4a55c82c1fc5e144a5b5f4da9ffebc7339a15469d1a447ebb427ced446efcb9709ab56bd71a06c4c67c9381bc1b9f9ae63fc7c89209bdf languageName: node linkType: hard @@ -10729,9 +10730,9 @@ __metadata: linkType: hard "marky@npm:^1.2.2": - version: 1.3.0 - resolution: "marky@npm:1.3.0" - checksum: 10/c90687e18dbcfbe501e4161c3dbddfe0dcc715ad7643b5b06f17cebcb3c8cc8c12f34ddc3a2a1c0432b74b207a6ebaac6466a240794e0e04b0afb287cfed4933 + version: 1.2.5 + resolution: "marky@npm:1.2.5" + checksum: 10/ae775cea935859a26560c807be226737e97a533913232f7d4648ab6237fc43e3175fa34664901b5aaed2027f355baa254ded1f6d828394e4abbd222a53b66bcf languageName: node linkType: hard @@ -11612,9 +11613,9 @@ __metadata: linkType: hard "node-stream-zip@npm:^1.9.1": - version: 1.16.0 - resolution: "node-stream-zip@npm:1.16.0" - checksum: 10/172af3d7856d33706dadd1fe299c9a8d6a13a5fdd270abf8e4e8c4c62f0c02722815f3b0966e862746546fc77142dcc58ce087d5702627f7ee7dfdca0f4b98dc + version: 1.15.0 + resolution: "node-stream-zip@npm:1.15.0" + checksum: 10/3fb56144d23456e1b42fe9d24656999e4ef6aeccce3cae43fc97ba6c341ee448aeceb4dc8fb57ee78eab1a6da49dd46c9650fdb2f16b137630a335df9560c647 languageName: node linkType: hard @@ -12012,10 +12013,10 @@ __metadata: languageName: node linkType: hard -"on-headers@npm:~1.1.0": - version: 1.1.0 - resolution: "on-headers@npm:1.1.0" - checksum: 10/98aa64629f986fb8cc4517dd8bede73c980e31208cba97f4442c330959f60ced3dc6214b83420491f5111fc7c4f4343abe2ea62c85f505cf041d67850f238776 +"on-headers@npm:~1.0.2": + version: 1.0.2 + resolution: "on-headers@npm:1.0.2" + checksum: 10/870766c16345855e2012e9422ba1ab110c7e44ad5891a67790f84610bd70a72b67fdd71baf497295f1d1bf38dd4c92248f825d48729c53c0eae5262fb69fa171 languageName: node linkType: hard @@ -14440,11 +14441,11 @@ __metadata: linkType: hard "stacktrace-parser@npm:^0.1.10": - version: 0.1.11 - resolution: "stacktrace-parser@npm:0.1.11" + version: 0.1.10 + resolution: "stacktrace-parser@npm:0.1.10" dependencies: type-fest: "npm:^0.7.1" - checksum: 10/1120cf716606ec6a8e25cc9b6ada79d7b91e6a599bba1a6664e6badc8b5f37987d7df7d9ad0344f717a042781fd8e1e999de08614a5afea451b68902421036b5 + checksum: 10/f4fbddfc09121d91e587b60de4beb4941108e967d71ad3a171812dc839b010ca374d064ad0a296295fed13acd103609d99a4224a25b4e67de13cae131f1901ee languageName: node linkType: hard @@ -14717,9 +14718,9 @@ __metadata: linkType: hard "strnum@npm:^1.0.5": - version: 1.1.2 - resolution: "strnum@npm:1.1.2" - checksum: 10/ccd6297a1fdaf0fc8ea0ea904acdae76878d49a4b0d98a70155df4bc081fd88eac5ec99fb150f3d1d1af065c1898d38420705259ba6c39aa850c671bcd54e35d + version: 1.0.5 + resolution: "strnum@npm:1.0.5" + checksum: 10/d3117975db8372d4d7b2c07601ed2f65bf21cc48d741f37a8617b76370d228f2ec26336e53791ebc3638264d23ca54e6c241f57f8c69bd4941c63c79440525ca languageName: node linkType: hard @@ -15559,9 +15560,9 @@ __metadata: linkType: hard "unicode-property-aliases-ecmascript@npm:^2.0.0": - version: 2.2.0 - resolution: "unicode-property-aliases-ecmascript@npm:2.2.0" - checksum: 10/0dd0f6e70130c59b4a841bac206758f70227b113145e4afe238161e3e8540e8eb79963e7a228cd90ad13d499e96f7ef4ee8940835404b2181ad9bf9c174818e3 + version: 2.1.0 + resolution: "unicode-property-aliases-ecmascript@npm:2.1.0" + checksum: 10/243524431893649b62cc674d877bd64ef292d6071dd2fd01ab4d5ad26efbc104ffcd064f93f8a06b7e4ec54c172bf03f6417921a0d8c3a9994161fe1f88f815b languageName: node linkType: hard @@ -16221,22 +16222,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^7": - version: 7.5.13 - resolution: "ws@npm:7.5.13" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10/0873c813ce75c466b026ce0a4ce9a8b5b5f1dc9ef0dd7ae9690fa4025c66afb165d3a45550357080b29d66a0e3028cd13c72ccefc83ce10723b4822a574ebc99 - languageName: node - linkType: hard - -"ws@npm:^7.5.10": +"ws@npm:^7, ws@npm:^7.5.10": version: 7.5.10 resolution: "ws@npm:7.5.10" peerDependencies: