From e4adc338716ba72770bf8b31d15e4f3aa0973810 Mon Sep 17 00:00:00 2001 From: cx-danielg <115538361+cx-daniel-gabay@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:28:48 +0200 Subject: [PATCH 1/4] Add ARIA element detection for client spider crawling This change enables the browser extension to detect and report elements with interactive ARIA roles (e.g., role="button", role="link") so the client spider can crawl pages that use non-standard interactive elements. Key changes: - Add INTERACTIVE_ARIA_ROLES array and hasInteractiveAriaRole() helper - Detect elements with interactive ARIA roles (button, link, checkbox, etc.) - Capture ariaIdentification for elements lacking an ID attribute - Use aria-label as text for better diagnostics - Add unit and integration tests with ariaElements.html test page - Update toShortString() to filter null ariaIdentification for consistency The ariaIdentification field is only populated for elements without an ID, providing alternative identification attributes for the client spider to locate elements during crawling. Signed-off-by: cx-danielg <115538361+cx-daniel-gabay@users.noreply.github.com> --- source/ContentScript/index.ts | 48 ++++++++ source/types/ReportedModel.ts | 49 +++++++- test/ContentScript/integrationTests.test.ts | 105 ++++++++++++++++ test/ContentScript/unitTests.test.ts | 76 ++++++++---- test/ContentScript/utils.ts | 7 +- test/ContentScript/webpages/ariaElements.html | 116 ++++++++++++++++++ 6 files changed, 372 insertions(+), 29 deletions(-) create mode 100644 test/ContentScript/webpages/ariaElements.html diff --git a/source/ContentScript/index.ts b/source/ContentScript/index.ts index 77346404..3baf2c29 100644 --- a/source/ContentScript/index.ts +++ b/source/ContentScript/index.ts @@ -205,6 +205,50 @@ 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 +263,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 +277,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 +397,7 @@ export { reportPageForms, reportNodeElements, reportStorage, + reportAriaElements, ReportedElement, ReportedObject, ReportedStorage, diff --git a/source/types/ReportedModel.ts b/source/types/ReportedModel.ts index 3b5baa27..ce37c401 100644 --- a/source/types/ReportedModel.ts +++ b/source/types/ReportedModel.ts @@ -54,7 +54,12 @@ class ReportedObject { } public toString(): string { - return JSON.stringify(this); + return JSON.stringify(this, function replacer(k: string, v: unknown) { + if (k === 'ariaIdentification' && v === null) { + return undefined; + } + return v; + }); } public toShortString(): string { @@ -68,11 +73,14 @@ class ReportedObject { } // Use this for tests - public toNonTimestampString(): string { - return JSON.stringify(this, function replacer(k: string, v: string) { + public toTestString(): string { + return JSON.stringify(this, function replacer(k: string, v: unknown) { if (k === 'timestamp') { return undefined; } + if (k === 'ariaIdentification' && v === null) { + return undefined; + } return v; }); } @@ -100,6 +108,8 @@ class ReportedElement extends ReportedObject { public formId: number | null; + public ariaIdentification: Record | null = null; + public constructor(element: Element, url: string) { super( 'nodeAdded', @@ -128,14 +138,45 @@ 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; + } + + if (!this.id) { + const ariaAttrs: Record = {}; + + const role = element.getAttribute('role'); + if (role !== null) { + ariaAttrs['role'] = role; + } + + Array.from(element.attributes) + .filter((attr) => attr.name.startsWith('aria-')) + .forEach((attr) => { + ariaAttrs[attr.name] = attr.value; + }); + + if (Object.keys(ariaAttrs).length > 0) { + this.ariaIdentification = ariaAttrs; + } + } } 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; } + if (k === 'ariaIdentification' && v === null) { + return undefined; + } return v; }); } diff --git a/test/ContentScript/integrationTests.test.ts b/test/ContentScript/integrationTests.test.ts index 18af8770..1b14e870 100644 --- a/test/ContentScript/integrationTests.test.ts +++ b/test/ContentScript/integrationTests.test.ts @@ -900,6 +900,111 @@ 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' + ), + reportObject( + 'nodeAdded', + 'SPAN', + 'aria-button-2', + 'SPAN', + 'http://localhost:1801/webpages/ariaElements.html', + undefined, + expect.stringContaining('Toggle Button') + ), + reportObject( + 'nodeAdded', + 'DIV', + 'aria-link-1', + 'DIV', + 'http://localhost:1801/webpages/ariaElements.html', + undefined, + 'Go to homepage' + ), + reportObject( + 'nodeAdded', + 'DIV', + 'aria-checkbox-1', + 'DIV', + 'http://localhost:1801/webpages/ariaElements.html', + undefined, + 'Accept terms' + ), + reportObject( + 'nodeAdded', + 'DIV', + 'aria-tab-1', + 'DIV', + 'http://localhost:1801/webpages/ariaElements.html', + undefined, + expect.stringContaining('Tab 1') + ), + reportObject( + 'nodeAdded', + 'DIV', + 'aria-menuitem-1', + 'DIV', + 'http://localhost:1801/webpages/ariaElements.html', + undefined, + expect.stringContaining('Edit') + ), + reportObject( + 'nodeAdded', + 'DIV', + '', + 'DIV', + 'http://localhost:1801/webpages/ariaElements.html', + undefined, + 'No ID Button', + { + role: 'button', + 'aria-label': 'No ID Button', + 'aria-pressed': 'false', + } + ), + 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/unitTests.test.ts b/test/ContentScript/unitTests.test.ts index f384ba12..b39224d0 100644 --- a/test/ContentScript/unitTests.test.ts +++ b/test/ContentScript/unitTests.test.ts @@ -52,7 +52,7 @@ test('ReportedObject toString as expected', () => { ); // Then - expect(ro.toNonTimestampString()).toBe( + expect(ro.toTestString()).toBe( '{"type":"a","tagName":"b","id":"c","nodeName":"d","url":"http://localhost/","text":"e"}' ); }); @@ -66,7 +66,7 @@ test('ReportedElement P toString as expected', () => { ); // Then - expect(ro.toNonTimestampString()).toBe( + expect(ro.toTestString()).toBe( '{"type":"nodeAdded","tagName":"P","id":"","nodeName":"P","url":"http://localhost/","text":""}' ); }); @@ -83,11 +83,30 @@ test('ReportedElement A toString as expected', () => { ); // Then - expect(ro.toNonTimestampString()).toBe( + expect(ro.toTestString()).toBe( '{"type":"nodeAdded","tagName":"A","id":"","nodeName":"A","url":"http://localhost/","href":"https://example.com/","text":"Title"}' ); }); +test('ReportedElement with ARIA attributes', () => { + const btnWithId: Element = document.createElement('button'); + btnWithId.setAttribute('id', 'close-btn'); + btnWithId.setAttribute('aria-label', 'Close dialog'); + const roWithId: src.ReportedElement = new src.ReportedElement(btnWithId, 'http://localhost/'); + expect(roWithId.toTestString()).toBe( + '{"type":"nodeAdded","tagName":"BUTTON","id":"close-btn","nodeName":"BUTTON","url":"http://localhost/","text":"Close dialog"}' + ); + + const divNoId: Element = document.createElement('div'); + divNoId.setAttribute('role', 'button'); + divNoId.setAttribute('aria-label', 'Submit'); + divNoId.setAttribute('aria-controls', 'form1'); + const roNoId: src.ReportedElement = new src.ReportedElement(divNoId, 'http://localhost/'); + expect(roNoId.toTestString()).toBe( + '{"type":"nodeAdded","tagName":"DIV","id":"","nodeName":"DIV","url":"http://localhost/","text":"Submit","ariaIdentification":{"role":"button","aria-label":"Submit","aria-controls":"form1"}}' + ); +}); + test('Report no document links', () => { // Given const dom: JSDOM = new JSDOM( @@ -114,10 +133,10 @@ test('Report standard page links', () => { // Then expect(mockFn.mock.calls.length).toBe(2); - expect(mockFn.mock.calls[0][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[0][0].toTestString()).toBe( '{"type":"nodeAdded","tagName":"A","id":"","nodeName":"A","url":"http://localhost/","href":"https://www.example.com/1","text":"link1"}' ); - expect(mockFn.mock.calls[1][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[1][0].toTestString()).toBe( '{"type":"nodeAdded","tagName":"A","id":"","nodeName":"A","url":"http://localhost/","href":"https://www.example.com/2","text":"link2"}' ); }); @@ -134,10 +153,10 @@ test('Report area page links', () => { // Then expect(mockFn.mock.calls.length).toBe(2); - expect(mockFn.mock.calls[0][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[0][0].toTestString()).toBe( '{"type":"nodeAdded","tagName":"AREA","id":"","nodeName":"AREA","url":"http://localhost/","href":"https://www.example.com/1","text":""}' ); - expect(mockFn.mock.calls[1][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[1][0].toTestString()).toBe( '{"type":"nodeAdded","tagName":"AREA","id":"","nodeName":"AREA","url":"http://localhost/","href":"https://www.example.com/2","text":""}' ); }); @@ -168,10 +187,10 @@ test('Report page forms', () => { // Then expect(mockFn.mock.calls.length).toBe(2); - expect(mockFn.mock.calls[0][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[0][0].toTestString()).toBe( '{"type":"nodeAdded","tagName":"FORM","id":"form1","nodeName":"FORM","url":"http://localhost/","text":"Content1","formId":-1}' ); - expect(mockFn.mock.calls[1][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[1][0].toTestString()).toBe( '{"type":"nodeAdded","tagName":"FORM","id":"form2","nodeName":"FORM","url":"http://localhost/","text":"Content2","formId":-1}' ); }); @@ -199,10 +218,10 @@ test('Report node elements', () => { // Then expect(mockFn.mock.calls.length).toBe(2); - expect(mockFn.mock.calls[0][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[0][0].toTestString()).toBe( '{"type":"nodeAdded","tagName":"INPUT","id":"input1","nodeName":"INPUT","url":"http://localhost/","text":"","tagType":"text","formId":-1}' ); - expect(mockFn.mock.calls[1][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[1][0].toTestString()).toBe( '{"type":"nodeAdded","tagName":"INPUT","id":"input2","nodeName":"INPUT","url":"http://localhost/","text":"","tagType":"text","formId":-1}' ); }); @@ -220,13 +239,13 @@ test('Report storage', () => { // Then expect(mockFn.mock.calls.length).toBe(3); - expect(mockFn.mock.calls[0][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[0][0].toTestString()).toBe( '{"type":"localStorage","tagName":"","id":"item1","nodeName":"","url":"http://localhost/","text":"value1"}' ); - expect(mockFn.mock.calls[1][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[1][0].toTestString()).toBe( '{"type":"localStorage","tagName":"","id":"item2","nodeName":"","url":"http://localhost/","text":"value2"}' ); - expect(mockFn.mock.calls[2][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[2][0].toTestString()).toBe( '{"type":"localStorage","tagName":"","id":"item3","nodeName":"","url":"http://localhost/","text":"value3"}' ); @@ -245,7 +264,10 @@ test('Reported page loaded', () => { '' + '' + '' + - '' + '' + + '
Click
' + + 'ARIA Link' + + '' ); const mockFn = jest.fn(); localStorage.setItem('lsKey', 'value1'); @@ -255,29 +277,35 @@ test('Reported page loaded', () => { src.reportPageLoaded(dom.window.document, mockFn); // Then - expect(mockFn.mock.calls.length).toBe(8); - expect(mockFn.mock.calls[0][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls.length).toBe(10); + expect(mockFn.mock.calls[0][0].toTestString()).toBe( '{"type":"nodeAdded","tagName":"A","id":"","nodeName":"A","url":"http://localhost/","href":"https://www.example.com/1","text":"link1"}' ); - expect(mockFn.mock.calls[1][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[1][0].toTestString()).toBe( '{"type":"nodeAdded","tagName":"AREA","id":"","nodeName":"AREA","url":"http://localhost/","href":"https://www.example.com/1","text":""}' ); - expect(mockFn.mock.calls[2][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[2][0].toTestString()).toBe( '{"type":"nodeAdded","tagName":"FORM","id":"form1","nodeName":"FORM","url":"http://localhost/","text":"FormContent","formId":-1}' ); - expect(mockFn.mock.calls[3][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[3][0].toTestString()).toBe( '{"type":"nodeAdded","tagName":"INPUT","id":"input1","nodeName":"INPUT","url":"http://localhost/","text":"default","tagType":"text"}' ); - expect(mockFn.mock.calls[4][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[4][0].toTestString()).toBe( '{"type":"nodeAdded","tagName":"INPUT","id":"submit","nodeName":"INPUT","url":"http://localhost/","text":"Submit","tagType":"submit"}' ); - expect(mockFn.mock.calls[5][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[5][0].toTestString()).toBe( '{"type":"nodeAdded","tagName":"BUTTON","id":"button1","nodeName":"BUTTON","url":"http://localhost/","text":"Button"}' ); - expect(mockFn.mock.calls[6][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[6][0].toTestString()).toBe( + '{"type":"nodeAdded","tagName":"DIV","id":"","nodeName":"DIV","url":"http://localhost/","text":"ARIA Button","ariaIdentification":{"role":"button","aria-label":"ARIA Button"}}' + ); + expect(mockFn.mock.calls[7][0].toTestString()).toBe( + '{"type":"nodeAdded","tagName":"SPAN","id":"","nodeName":"SPAN","url":"http://localhost/","text":"ARIA Link","ariaIdentification":{"role":"link","aria-pressed":"true"}}' + ); + expect(mockFn.mock.calls[8][0].toTestString()).toBe( '{"type":"localStorage","tagName":"","id":"lsKey","nodeName":"","url":"http://localhost/","text":"value1"}' ); - expect(mockFn.mock.calls[7][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[9][0].toTestString()).toBe( '{"type":"sessionStorage","tagName":"","id":"ssKey","nodeName":"","url":"http://localhost/","text":"value2"}' ); diff --git a/test/ContentScript/utils.ts b/test/ContentScript/utils.ts index f5bdccba..48f0ee9a 100644 --- a/test/ContentScript/utils.ts +++ b/test/ContentScript/utils.ts @@ -89,7 +89,8 @@ export function reportObject( nodeName: string, url: string, href: string | undefined, - text: string + text: string, + ariaIdentification?: Record ): object { const data = { action: {action: 'reportObject'}, @@ -103,6 +104,7 @@ export function reportObject( url, href, text, + ariaIdentification, }, apikey: 'not set', }, @@ -110,6 +112,9 @@ export function reportObject( if (href === undefined) { delete data.body.objectJson.href; } + if (ariaIdentification === undefined) { + delete data.body.objectJson.ariaIdentification; + } return data; } diff --git a/test/ContentScript/webpages/ariaElements.html b/test/ContentScript/webpages/ariaElements.html new file mode 100644 index 00000000..8efadbbd --- /dev/null +++ b/test/ContentScript/webpages/ariaElements.html @@ -0,0 +1,116 @@ + + + + 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) +
+ + +
Regular Div (no ARIA)
+ Regular Span + + + + Standard Link + + + From d9593acf54e42354820031ffbe3ab64fc4b9e019 Mon Sep 17 00:00:00 2001 From: cx-danielg <115538361+cx-daniel-gabay@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:41:44 +0200 Subject: [PATCH 2/4] Refactor ARIA element detection: add dedicated role field - Add separate 'role' field to ReportedElement for clickability checks - Keep ariaIdentification for aria-* attributes only (no role duplication) - Remove null initialization from role/ariaIdentification fields - Simplify JSON serialization (undefined values auto-excluded) - Update tests to reflect new data structure Signed-off-by: cx-danielg <115538361+cx-daniel-gabay@users.noreply.github.com> --- source/ContentScript/index.ts | 10 ++- source/types/ReportedModel.ts | 31 ++++------ test/ContentScript/integrationTests.test.ts | 28 ++++++--- test/ContentScript/unitTests.test.ts | 68 +++++++++++---------- test/ContentScript/utils.ts | 7 ++- 5 files changed, 82 insertions(+), 62 deletions(-) diff --git a/source/ContentScript/index.ts b/source/ContentScript/index.ts index 3baf2c29..aa20cf39 100644 --- a/source/ContentScript/index.ts +++ b/source/ContentScript/index.ts @@ -205,7 +205,14 @@ function reportPointerElements( }); } -const STANDARD_INTERACTIVE_TAGS = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'FORM']; +const STANDARD_INTERACTIVE_TAGS = [ + 'A', + 'BUTTON', + 'INPUT', + 'SELECT', + 'TEXTAREA', + 'FORM', +]; const INTERACTIVE_ARIA_ROLES = [ 'button', @@ -248,7 +255,6 @@ function reportAriaElements( }); } - function reportPageLoaded( doc: Document, fn: (re: ReportedObject) => void diff --git a/source/types/ReportedModel.ts b/source/types/ReportedModel.ts index ce37c401..a0e86b3b 100644 --- a/source/types/ReportedModel.ts +++ b/source/types/ReportedModel.ts @@ -54,16 +54,11 @@ class ReportedObject { } public toString(): string { - return JSON.stringify(this, function replacer(k: string, v: unknown) { - if (k === 'ariaIdentification' && v === null) { - return undefined; - } - return v; - }); + return JSON.stringify(this); } 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; @@ -73,14 +68,11 @@ class ReportedObject { } // Use this for tests - public toTestString(): string { + public toNonTimestampString(): string { return JSON.stringify(this, function replacer(k: string, v: unknown) { if (k === 'timestamp') { return undefined; } - if (k === 'ariaIdentification' && v === null) { - return undefined; - } return v; }); } @@ -108,7 +100,9 @@ class ReportedElement extends ReportedObject { public formId: number | null; - public ariaIdentification: Record | null = null; + public role: string | null; + + public ariaIdentification: Record | null; public constructor(element: Element, url: string) { super( @@ -148,14 +142,14 @@ class ReportedElement extends ReportedObject { this.text = ariaLabel; } + const role = element.getAttribute('role'); + if (role !== null) { + this.role = role; + } + if (!this.id) { const ariaAttrs: Record = {}; - const role = element.getAttribute('role'); - if (role !== null) { - ariaAttrs['role'] = role; - } - Array.from(element.attributes) .filter((attr) => attr.name.startsWith('aria-')) .forEach((attr) => { @@ -174,9 +168,6 @@ class ReportedElement extends ReportedObject { // No point reporting the same element lots of times return undefined; } - if (k === 'ariaIdentification' && v === null) { - return undefined; - } return v; }); } diff --git a/test/ContentScript/integrationTests.test.ts b/test/ContentScript/integrationTests.test.ts index 1b14e870..c9aab049 100644 --- a/test/ContentScript/integrationTests.test.ts +++ b/test/ContentScript/integrationTests.test.ts @@ -922,7 +922,9 @@ function integrationTests( 'DIV', 'http://localhost:1801/webpages/ariaElements.html', undefined, - 'Submit Form' + 'Submit Form', + undefined, + 'button' ), reportObject( 'nodeAdded', @@ -931,7 +933,9 @@ function integrationTests( 'SPAN', 'http://localhost:1801/webpages/ariaElements.html', undefined, - expect.stringContaining('Toggle Button') + expect.stringContaining('Toggle Button'), + undefined, + 'button' ), reportObject( 'nodeAdded', @@ -940,7 +944,9 @@ function integrationTests( 'DIV', 'http://localhost:1801/webpages/ariaElements.html', undefined, - 'Go to homepage' + 'Go to homepage', + undefined, + 'link' ), reportObject( 'nodeAdded', @@ -949,7 +955,9 @@ function integrationTests( 'DIV', 'http://localhost:1801/webpages/ariaElements.html', undefined, - 'Accept terms' + 'Accept terms', + undefined, + 'checkbox' ), reportObject( 'nodeAdded', @@ -958,7 +966,9 @@ function integrationTests( 'DIV', 'http://localhost:1801/webpages/ariaElements.html', undefined, - expect.stringContaining('Tab 1') + expect.stringContaining('Tab 1'), + undefined, + 'tab' ), reportObject( 'nodeAdded', @@ -967,7 +977,9 @@ function integrationTests( 'DIV', 'http://localhost:1801/webpages/ariaElements.html', undefined, - expect.stringContaining('Edit') + expect.stringContaining('Edit'), + undefined, + 'menuitem' ), reportObject( 'nodeAdded', @@ -978,10 +990,10 @@ function integrationTests( undefined, 'No ID Button', { - role: 'button', 'aria-label': 'No ID Button', 'aria-pressed': 'false', - } + }, + 'button' ), reportObject( 'nodeAdded', diff --git a/test/ContentScript/unitTests.test.ts b/test/ContentScript/unitTests.test.ts index b39224d0..42f290a3 100644 --- a/test/ContentScript/unitTests.test.ts +++ b/test/ContentScript/unitTests.test.ts @@ -52,7 +52,7 @@ test('ReportedObject toString as expected', () => { ); // Then - expect(ro.toTestString()).toBe( + expect(ro.toNonTimestampString()).toBe( '{"type":"a","tagName":"b","id":"c","nodeName":"d","url":"http://localhost/","text":"e"}' ); }); @@ -66,7 +66,7 @@ test('ReportedElement P toString as expected', () => { ); // Then - expect(ro.toTestString()).toBe( + expect(ro.toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"P","id":"","nodeName":"P","url":"http://localhost/","text":""}' ); }); @@ -83,7 +83,7 @@ test('ReportedElement A toString as expected', () => { ); // Then - expect(ro.toTestString()).toBe( + expect(ro.toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"A","id":"","nodeName":"A","url":"http://localhost/","href":"https://example.com/","text":"Title"}' ); }); @@ -92,8 +92,11 @@ test('ReportedElement with ARIA attributes', () => { const btnWithId: Element = document.createElement('button'); btnWithId.setAttribute('id', 'close-btn'); btnWithId.setAttribute('aria-label', 'Close dialog'); - const roWithId: src.ReportedElement = new src.ReportedElement(btnWithId, 'http://localhost/'); - expect(roWithId.toTestString()).toBe( + const roWithId: src.ReportedElement = new src.ReportedElement( + btnWithId, + 'http://localhost/' + ); + expect(roWithId.toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"BUTTON","id":"close-btn","nodeName":"BUTTON","url":"http://localhost/","text":"Close dialog"}' ); @@ -101,9 +104,12 @@ test('ReportedElement with ARIA attributes', () => { divNoId.setAttribute('role', 'button'); divNoId.setAttribute('aria-label', 'Submit'); divNoId.setAttribute('aria-controls', 'form1'); - const roNoId: src.ReportedElement = new src.ReportedElement(divNoId, 'http://localhost/'); - expect(roNoId.toTestString()).toBe( - '{"type":"nodeAdded","tagName":"DIV","id":"","nodeName":"DIV","url":"http://localhost/","text":"Submit","ariaIdentification":{"role":"button","aria-label":"Submit","aria-controls":"form1"}}' + const roNoId: src.ReportedElement = new src.ReportedElement( + divNoId, + 'http://localhost/' + ); + expect(roNoId.toNonTimestampString()).toBe( + '{"type":"nodeAdded","tagName":"DIV","id":"","nodeName":"DIV","url":"http://localhost/","text":"Submit","role":"button","ariaIdentification":{"aria-label":"Submit","aria-controls":"form1"}}' ); }); @@ -133,10 +139,10 @@ test('Report standard page links', () => { // Then expect(mockFn.mock.calls.length).toBe(2); - expect(mockFn.mock.calls[0][0].toTestString()).toBe( + expect(mockFn.mock.calls[0][0].toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"A","id":"","nodeName":"A","url":"http://localhost/","href":"https://www.example.com/1","text":"link1"}' ); - expect(mockFn.mock.calls[1][0].toTestString()).toBe( + expect(mockFn.mock.calls[1][0].toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"A","id":"","nodeName":"A","url":"http://localhost/","href":"https://www.example.com/2","text":"link2"}' ); }); @@ -153,10 +159,10 @@ test('Report area page links', () => { // Then expect(mockFn.mock.calls.length).toBe(2); - expect(mockFn.mock.calls[0][0].toTestString()).toBe( + expect(mockFn.mock.calls[0][0].toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"AREA","id":"","nodeName":"AREA","url":"http://localhost/","href":"https://www.example.com/1","text":""}' ); - expect(mockFn.mock.calls[1][0].toTestString()).toBe( + expect(mockFn.mock.calls[1][0].toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"AREA","id":"","nodeName":"AREA","url":"http://localhost/","href":"https://www.example.com/2","text":""}' ); }); @@ -187,10 +193,10 @@ test('Report page forms', () => { // Then expect(mockFn.mock.calls.length).toBe(2); - expect(mockFn.mock.calls[0][0].toTestString()).toBe( + expect(mockFn.mock.calls[0][0].toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"FORM","id":"form1","nodeName":"FORM","url":"http://localhost/","text":"Content1","formId":-1}' ); - expect(mockFn.mock.calls[1][0].toTestString()).toBe( + expect(mockFn.mock.calls[1][0].toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"FORM","id":"form2","nodeName":"FORM","url":"http://localhost/","text":"Content2","formId":-1}' ); }); @@ -218,10 +224,10 @@ test('Report node elements', () => { // Then expect(mockFn.mock.calls.length).toBe(2); - expect(mockFn.mock.calls[0][0].toTestString()).toBe( + expect(mockFn.mock.calls[0][0].toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"INPUT","id":"input1","nodeName":"INPUT","url":"http://localhost/","text":"","tagType":"text","formId":-1}' ); - expect(mockFn.mock.calls[1][0].toTestString()).toBe( + expect(mockFn.mock.calls[1][0].toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"INPUT","id":"input2","nodeName":"INPUT","url":"http://localhost/","text":"","tagType":"text","formId":-1}' ); }); @@ -239,13 +245,13 @@ test('Report storage', () => { // Then expect(mockFn.mock.calls.length).toBe(3); - expect(mockFn.mock.calls[0][0].toTestString()).toBe( + expect(mockFn.mock.calls[0][0].toNonTimestampString()).toBe( '{"type":"localStorage","tagName":"","id":"item1","nodeName":"","url":"http://localhost/","text":"value1"}' ); - expect(mockFn.mock.calls[1][0].toTestString()).toBe( + expect(mockFn.mock.calls[1][0].toNonTimestampString()).toBe( '{"type":"localStorage","tagName":"","id":"item2","nodeName":"","url":"http://localhost/","text":"value2"}' ); - expect(mockFn.mock.calls[2][0].toTestString()).toBe( + expect(mockFn.mock.calls[2][0].toNonTimestampString()).toBe( '{"type":"localStorage","tagName":"","id":"item3","nodeName":"","url":"http://localhost/","text":"value3"}' ); @@ -278,34 +284,34 @@ test('Reported page loaded', () => { // Then expect(mockFn.mock.calls.length).toBe(10); - expect(mockFn.mock.calls[0][0].toTestString()).toBe( + expect(mockFn.mock.calls[0][0].toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"A","id":"","nodeName":"A","url":"http://localhost/","href":"https://www.example.com/1","text":"link1"}' ); - expect(mockFn.mock.calls[1][0].toTestString()).toBe( + expect(mockFn.mock.calls[1][0].toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"AREA","id":"","nodeName":"AREA","url":"http://localhost/","href":"https://www.example.com/1","text":""}' ); - expect(mockFn.mock.calls[2][0].toTestString()).toBe( + expect(mockFn.mock.calls[2][0].toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"FORM","id":"form1","nodeName":"FORM","url":"http://localhost/","text":"FormContent","formId":-1}' ); - expect(mockFn.mock.calls[3][0].toTestString()).toBe( + expect(mockFn.mock.calls[3][0].toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"INPUT","id":"input1","nodeName":"INPUT","url":"http://localhost/","text":"default","tagType":"text"}' ); - expect(mockFn.mock.calls[4][0].toTestString()).toBe( + expect(mockFn.mock.calls[4][0].toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"INPUT","id":"submit","nodeName":"INPUT","url":"http://localhost/","text":"Submit","tagType":"submit"}' ); - expect(mockFn.mock.calls[5][0].toTestString()).toBe( + expect(mockFn.mock.calls[5][0].toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"BUTTON","id":"button1","nodeName":"BUTTON","url":"http://localhost/","text":"Button"}' ); - expect(mockFn.mock.calls[6][0].toTestString()).toBe( - '{"type":"nodeAdded","tagName":"DIV","id":"","nodeName":"DIV","url":"http://localhost/","text":"ARIA Button","ariaIdentification":{"role":"button","aria-label":"ARIA Button"}}' + expect(mockFn.mock.calls[6][0].toNonTimestampString()).toBe( + '{"type":"nodeAdded","tagName":"DIV","id":"","nodeName":"DIV","url":"http://localhost/","text":"ARIA Button","role":"button","ariaIdentification":{"aria-label":"ARIA Button"}}' ); - expect(mockFn.mock.calls[7][0].toTestString()).toBe( - '{"type":"nodeAdded","tagName":"SPAN","id":"","nodeName":"SPAN","url":"http://localhost/","text":"ARIA Link","ariaIdentification":{"role":"link","aria-pressed":"true"}}' + expect(mockFn.mock.calls[7][0].toNonTimestampString()).toBe( + '{"type":"nodeAdded","tagName":"SPAN","id":"","nodeName":"SPAN","url":"http://localhost/","text":"ARIA Link","role":"link","ariaIdentification":{"aria-pressed":"true"}}' ); - expect(mockFn.mock.calls[8][0].toTestString()).toBe( + expect(mockFn.mock.calls[8][0].toNonTimestampString()).toBe( '{"type":"localStorage","tagName":"","id":"lsKey","nodeName":"","url":"http://localhost/","text":"value1"}' ); - expect(mockFn.mock.calls[9][0].toTestString()).toBe( + expect(mockFn.mock.calls[9][0].toNonTimestampString()).toBe( '{"type":"sessionStorage","tagName":"","id":"ssKey","nodeName":"","url":"http://localhost/","text":"value2"}' ); diff --git a/test/ContentScript/utils.ts b/test/ContentScript/utils.ts index 48f0ee9a..7148b8a3 100644 --- a/test/ContentScript/utils.ts +++ b/test/ContentScript/utils.ts @@ -90,7 +90,8 @@ export function reportObject( url: string, href: string | undefined, text: string, - ariaIdentification?: Record + ariaIdentification?: Record, + role?: string ): object { const data = { action: {action: 'reportObject'}, @@ -104,6 +105,7 @@ export function reportObject( url, href, text, + role, ariaIdentification, }, apikey: 'not set', @@ -115,6 +117,9 @@ export function reportObject( if (ariaIdentification === undefined) { delete data.body.objectJson.ariaIdentification; } + if (role === undefined) { + delete data.body.objectJson.role; + } return data; } From 5d7d6c154faa9440029f0b710d29a08b841eca9f Mon Sep 17 00:00:00 2001 From: cx-danielg <115538361+cx-daniel-gabay@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:45:38 +0200 Subject: [PATCH 3/4] Add uniqueness check for ARIA identification attributes Only report ariaIdentification when the aria attributes can uniquely identify the element in the DOM. This prevents the spider from potentially interacting with the wrong element when multiple elements share the same aria attributes. Signed-off-by: cx-danielg <115538361+cx-daniel-gabay@users.noreply.github.com> Co-authored-by: Cursor --- CHANGELOG.md | 3 ++ source/types/ReportedModel.ts | 20 +++++++++- test/ContentScript/integrationTests.test.ts | 22 ++++++++++ test/ContentScript/unitTests.test.ts | 40 ++----------------- test/ContentScript/webpages/ariaElements.html | 10 ++++- 5 files changed, 56 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f028c2d..9a322fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - Report elements that have cursor pointer style. +### Changed +- Only report ARIA identification attributes when they uniquely identify the element. + ## 0.1.8 - 2025-12-12 ### Changed diff --git a/source/types/ReportedModel.ts b/source/types/ReportedModel.ts index a0e86b3b..a0035d6e 100644 --- a/source/types/ReportedModel.ts +++ b/source/types/ReportedModel.ts @@ -157,11 +157,29 @@ class ReportedElement extends ReportedObject { }); if (Object.keys(ariaAttrs).length > 0) { - this.ariaIdentification = ariaAttrs; + 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: unknown) { if (k === 'timestamp') { diff --git a/test/ContentScript/integrationTests.test.ts b/test/ContentScript/integrationTests.test.ts index c9aab049..b6df4328 100644 --- a/test/ContentScript/integrationTests.test.ts +++ b/test/ContentScript/integrationTests.test.ts @@ -995,6 +995,28 @@ function integrationTests( }, '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', diff --git a/test/ContentScript/unitTests.test.ts b/test/ContentScript/unitTests.test.ts index 42f290a3..f384ba12 100644 --- a/test/ContentScript/unitTests.test.ts +++ b/test/ContentScript/unitTests.test.ts @@ -88,31 +88,6 @@ test('ReportedElement A toString as expected', () => { ); }); -test('ReportedElement with ARIA attributes', () => { - const btnWithId: Element = document.createElement('button'); - btnWithId.setAttribute('id', 'close-btn'); - btnWithId.setAttribute('aria-label', 'Close dialog'); - const roWithId: src.ReportedElement = new src.ReportedElement( - btnWithId, - 'http://localhost/' - ); - expect(roWithId.toNonTimestampString()).toBe( - '{"type":"nodeAdded","tagName":"BUTTON","id":"close-btn","nodeName":"BUTTON","url":"http://localhost/","text":"Close dialog"}' - ); - - const divNoId: Element = document.createElement('div'); - divNoId.setAttribute('role', 'button'); - divNoId.setAttribute('aria-label', 'Submit'); - divNoId.setAttribute('aria-controls', 'form1'); - const roNoId: src.ReportedElement = new src.ReportedElement( - divNoId, - 'http://localhost/' - ); - expect(roNoId.toNonTimestampString()).toBe( - '{"type":"nodeAdded","tagName":"DIV","id":"","nodeName":"DIV","url":"http://localhost/","text":"Submit","role":"button","ariaIdentification":{"aria-label":"Submit","aria-controls":"form1"}}' - ); -}); - test('Report no document links', () => { // Given const dom: JSDOM = new JSDOM( @@ -270,10 +245,7 @@ test('Reported page loaded', () => { '' + '' + '' + - '' + - '
Click
' + - 'ARIA Link' + - '' + '' ); const mockFn = jest.fn(); localStorage.setItem('lsKey', 'value1'); @@ -283,7 +255,7 @@ test('Reported page loaded', () => { src.reportPageLoaded(dom.window.document, mockFn); // Then - expect(mockFn.mock.calls.length).toBe(10); + expect(mockFn.mock.calls.length).toBe(8); expect(mockFn.mock.calls[0][0].toNonTimestampString()).toBe( '{"type":"nodeAdded","tagName":"A","id":"","nodeName":"A","url":"http://localhost/","href":"https://www.example.com/1","text":"link1"}' ); @@ -303,15 +275,9 @@ test('Reported page loaded', () => { '{"type":"nodeAdded","tagName":"BUTTON","id":"button1","nodeName":"BUTTON","url":"http://localhost/","text":"Button"}' ); expect(mockFn.mock.calls[6][0].toNonTimestampString()).toBe( - '{"type":"nodeAdded","tagName":"DIV","id":"","nodeName":"DIV","url":"http://localhost/","text":"ARIA Button","role":"button","ariaIdentification":{"aria-label":"ARIA Button"}}' - ); - expect(mockFn.mock.calls[7][0].toNonTimestampString()).toBe( - '{"type":"nodeAdded","tagName":"SPAN","id":"","nodeName":"SPAN","url":"http://localhost/","text":"ARIA Link","role":"link","ariaIdentification":{"aria-pressed":"true"}}' - ); - expect(mockFn.mock.calls[8][0].toNonTimestampString()).toBe( '{"type":"localStorage","tagName":"","id":"lsKey","nodeName":"","url":"http://localhost/","text":"value1"}' ); - expect(mockFn.mock.calls[9][0].toNonTimestampString()).toBe( + expect(mockFn.mock.calls[7][0].toNonTimestampString()).toBe( '{"type":"sessionStorage","tagName":"","id":"ssKey","nodeName":"","url":"http://localhost/","text":"value2"}' ); diff --git a/test/ContentScript/webpages/ariaElements.html b/test/ContentScript/webpages/ariaElements.html index 8efadbbd..f6f135cd 100644 --- a/test/ContentScript/webpages/ariaElements.html +++ b/test/ContentScript/webpages/ariaElements.html @@ -99,11 +99,19 @@

ARIA Elements Test Page

This is the expandable content. - +
Click Me (No ID)
+ +
+ Duplicate Button 1 +
+
+ Duplicate Button 2 +
+
Regular Div (no ARIA)
Regular Span From d923fe32999afa3aa10abf4df9c29c58ac8638e1 Mon Sep 17 00:00:00 2001 From: cx-daniel-gabay <115538361+cx-daniel-gabay@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:06:05 +0200 Subject: [PATCH 4/4] Changelog - add ARIA support to Unreleased section - Include reporting of elements with specific role attributes - Add ARIA attributes for uniquely identifiable elements without an id Signed-off-by: cx-daniel-gabay <115538361+cx-daniel-gabay@users.noreply.github.com> Co-authored-by: Cursor --- CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a322fe1..e728bd7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +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. - -### Changed -- Only report ARIA identification attributes when they uniquely identify the element. +- Report elements with specific `role` attribute values. +- Include ARIA attributes for uniquely identifiable elements lacking an `id`. ## 0.1.8 - 2025-12-12