diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f028c2d..e728bd7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - Report elements that have cursor pointer style. +- Report elements with specific `role` attribute values. +- Include ARIA attributes for uniquely identifiable elements lacking an `id`. ## 0.1.8 - 2025-12-12 diff --git a/source/ContentScript/index.ts b/source/ContentScript/index.ts index 77346404..aa20cf39 100644 --- a/source/ContentScript/index.ts +++ b/source/ContentScript/index.ts @@ -205,6 +205,56 @@ function reportPointerElements( }); } +const STANDARD_INTERACTIVE_TAGS = [ + 'A', + 'BUTTON', + 'INPUT', + 'SELECT', + 'TEXTAREA', + 'FORM', +]; + +const INTERACTIVE_ARIA_ROLES = [ + 'button', + 'link', + 'checkbox', + 'radio', + 'switch', + 'tab', + 'menuitem', + 'menuitemcheckbox', + 'menuitemradio', + 'option', + 'treeitem', + 'combobox', + 'listbox', + 'slider', + 'spinbutton', + 'searchbox', + 'textbox', +]; + +function hasInteractiveAriaRole(element: Element): boolean { + const role = element.getAttribute('role'); + return role !== null && INTERACTIVE_ARIA_ROLES.includes(role.toLowerCase()); +} + +function reportAriaElements( + source: Element | Document, + fn: (re: ReportedObject) => void +): void { + const url = window.location.href; + + source.querySelectorAll('[role]').forEach((element) => { + if ( + hasInteractiveAriaRole(element) && + !STANDARD_INTERACTIVE_TAGS.includes(element.tagName.toUpperCase()) + ) { + fn(new ReportedElement(element, url)); + } + }); +} + function reportPageLoaded( doc: Document, fn: (re: ReportedObject) => void @@ -219,6 +269,7 @@ function reportPageLoaded( reportElements(doc.getElementsByTagName('input'), fn); reportElements(doc.getElementsByTagName('button'), fn); reportPointerElements(doc, fn); + reportAriaElements(doc, fn); reportStorage(LOCAL_STORAGE, localStorage, fn); reportStorage(SESSION_STORAGE, sessionStorage, fn); } @@ -232,12 +283,14 @@ const domMutated = function domMutation( reportPageLinks(document, reportObject); reportPageForms(document, reportObject); reportPointerElements(document, reportObject); + reportAriaElements(document, reportObject); for (const mutation of mutationList) { if (mutation.type === 'childList') { reportNodeElements(mutation.target, 'input', reportObject); reportNodeElements(mutation.target, 'button', reportObject); if (mutation.target.nodeType === Node.ELEMENT_NODE) { reportPointerElements(mutation.target as Element, reportObject); + reportAriaElements(mutation.target as Element, reportObject); } } } @@ -350,6 +403,7 @@ export { reportPageForms, reportNodeElements, reportStorage, + reportAriaElements, ReportedElement, ReportedObject, ReportedStorage, diff --git a/source/types/ReportedModel.ts b/source/types/ReportedModel.ts index 3b5baa27..a0035d6e 100644 --- a/source/types/ReportedModel.ts +++ b/source/types/ReportedModel.ts @@ -58,7 +58,7 @@ class ReportedObject { } public toShortString(): string { - return JSON.stringify(this, function replacer(k: string, v: string) { + return JSON.stringify(this, function replacer(k: string, v: unknown) { if (k === 'xpath') { // Dont return the xpath value - it can change too often in many cases return undefined; @@ -69,7 +69,7 @@ class ReportedObject { // Use this for tests public toNonTimestampString(): string { - return JSON.stringify(this, function replacer(k: string, v: string) { + return JSON.stringify(this, function replacer(k: string, v: unknown) { if (k === 'timestamp') { return undefined; } @@ -100,6 +100,10 @@ class ReportedElement extends ReportedObject { public formId: number | null; + public role: string | null; + + public ariaIdentification: Record | null; + public constructor(element: Element, url: string) { super( 'nodeAdded', @@ -128,10 +132,56 @@ class ReportedElement extends ReportedObject { } else if (element.hasAttribute('href')) { this.href = element.getAttribute('href'); } + + this.captureAriaInfo(element); + } + + private captureAriaInfo(element: Element): void { + const ariaLabel = element.getAttribute('aria-label'); + if (ariaLabel !== null) { + this.text = ariaLabel; + } + + const role = element.getAttribute('role'); + if (role !== null) { + this.role = role; + } + + if (!this.id) { + const ariaAttrs: Record = {}; + + Array.from(element.attributes) + .filter((attr) => attr.name.startsWith('aria-')) + .forEach((attr) => { + ariaAttrs[attr.name] = attr.value; + }); + + if (Object.keys(ariaAttrs).length > 0) { + if (this.isAriaSelectorUnique(ariaAttrs, element)) { + this.ariaIdentification = ariaAttrs; + } + } + } + } + + private isAriaSelectorUnique( + ariaAttrs: Record, + element: Element + ): boolean { + const doc = element.ownerDocument; + if (!doc || !element.isConnected) { + return true; + } + + const selector = Object.entries(ariaAttrs) + .map(([key, value]) => `[${key}="${CSS.escape(value)}"]`) + .join(''); + const elements = doc.querySelectorAll(selector); + return elements.length === 1; } public toShortString(): string { - return JSON.stringify(this, function replacer(k: string, v: string) { + return JSON.stringify(this, function replacer(k: string, v: unknown) { if (k === 'timestamp') { // No point reporting the same element lots of times return undefined; diff --git a/test/ContentScript/integrationTests.test.ts b/test/ContentScript/integrationTests.test.ts index 18af8770..b6df4328 100644 --- a/test/ContentScript/integrationTests.test.ts +++ b/test/ContentScript/integrationTests.test.ts @@ -900,6 +900,145 @@ function integrationTests( ]); }); + test('Should report ARIA elements', async () => { + // Given + await enableZapEvents(server, driver); + server.setRecordZapEvents(false); + const wd = await driver.getWebDriver(); + // When + await wd.get(`http://localhost:${_HTTPPORT}/webpages/ariaElements.html`); + await eventsProcessed(); + // Then + expect(actualData).toEqual( + expect.arrayContaining([ + reportEvent( + 'pageLoad', + 'http://localhost:1801/webpages/ariaElements.html' + ), + reportObject( + 'nodeAdded', + 'DIV', + 'aria-button-1', + 'DIV', + 'http://localhost:1801/webpages/ariaElements.html', + undefined, + 'Submit Form', + undefined, + 'button' + ), + reportObject( + 'nodeAdded', + 'SPAN', + 'aria-button-2', + 'SPAN', + 'http://localhost:1801/webpages/ariaElements.html', + undefined, + expect.stringContaining('Toggle Button'), + undefined, + 'button' + ), + reportObject( + 'nodeAdded', + 'DIV', + 'aria-link-1', + 'DIV', + 'http://localhost:1801/webpages/ariaElements.html', + undefined, + 'Go to homepage', + undefined, + 'link' + ), + reportObject( + 'nodeAdded', + 'DIV', + 'aria-checkbox-1', + 'DIV', + 'http://localhost:1801/webpages/ariaElements.html', + undefined, + 'Accept terms', + undefined, + 'checkbox' + ), + reportObject( + 'nodeAdded', + 'DIV', + 'aria-tab-1', + 'DIV', + 'http://localhost:1801/webpages/ariaElements.html', + undefined, + expect.stringContaining('Tab 1'), + undefined, + 'tab' + ), + reportObject( + 'nodeAdded', + 'DIV', + 'aria-menuitem-1', + 'DIV', + 'http://localhost:1801/webpages/ariaElements.html', + undefined, + expect.stringContaining('Edit'), + undefined, + 'menuitem' + ), + reportObject( + 'nodeAdded', + 'DIV', + '', + 'DIV', + 'http://localhost:1801/webpages/ariaElements.html', + undefined, + 'No ID Button', + { + 'aria-label': 'No ID Button', + 'aria-pressed': 'false', + }, + 'button' + ), + reportObject( + 'nodeAdded', + 'DIV', + '', + 'DIV', + 'http://localhost:1801/webpages/ariaElements.html', + undefined, + 'Duplicate Action', + undefined, + 'button' + ), + reportObject( + 'nodeAdded', + 'DIV', + '', + 'DIV', + 'http://localhost:1801/webpages/ariaElements.html', + undefined, + 'Duplicate Action', + undefined, + 'button' + ), + reportObject( + 'nodeAdded', + 'BUTTON', + 'standard-button', + 'BUTTON', + 'http://localhost:1801/webpages/ariaElements.html', + undefined, + expect.stringContaining('Standard Button') + ), + reportObject( + 'nodeAdded', + 'A', + 'standard-link', + 'A', + 'http://localhost:1801/webpages/ariaElements.html', + 'http://localhost:1801/webpages/ariaElements.html#test', + expect.stringContaining('Standard Link') + ), + ]) + ); + }); + test('Should ignore ZAP div', async () => { // Given / When await driver.toggleRecording(); diff --git a/test/ContentScript/utils.ts b/test/ContentScript/utils.ts index f5bdccba..7148b8a3 100644 --- a/test/ContentScript/utils.ts +++ b/test/ContentScript/utils.ts @@ -89,7 +89,9 @@ export function reportObject( nodeName: string, url: string, href: string | undefined, - text: string + text: string, + ariaIdentification?: Record, + role?: string ): object { const data = { action: {action: 'reportObject'}, @@ -103,6 +105,8 @@ export function reportObject( url, href, text, + role, + ariaIdentification, }, apikey: 'not set', }, @@ -110,6 +114,12 @@ export function reportObject( if (href === undefined) { delete data.body.objectJson.href; } + if (ariaIdentification === undefined) { + delete data.body.objectJson.ariaIdentification; + } + if (role === undefined) { + delete data.body.objectJson.role; + } return data; } diff --git a/test/ContentScript/webpages/ariaElements.html b/test/ContentScript/webpages/ariaElements.html new file mode 100644 index 00000000..f6f135cd --- /dev/null +++ b/test/ContentScript/webpages/ariaElements.html @@ -0,0 +1,124 @@ + + + + ARIA Elements Test Page + + + +

ARIA Elements Test Page

+ + +
+ Submit +
+ + Toggle Button + + + + + + Described Link + + + + + + + +
+ + +
+
Panel 1 Content
+ + + +
+ + +
+ + +
+
+ Option 1 +
+
+ Option 2 +
+
+ + +
+ Notifications: Off +
+ + +
+ + +
+ + + + + + + + + +
+ Click Me (No ID) +
+ + +
+ Duplicate Button 1 +
+
+ Duplicate Button 2 +
+ + +
Regular Div (no ARIA)
+ Regular Span + + + + Standard Link + + +