From de0f07cec534ab84e8e78524fc6e7f1577c198f9 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sun, 10 May 2026 19:08:26 +0200 Subject: [PATCH 01/42] feat(protocol): add WebViewInfo, WebViewBridge, and WebViewSession types Defines the protocol-layer interfaces for webview support. MobilewrightDriver gains an optional webViewBridge property so existing drivers compile unchanged. --- packages/protocol/src/driver.ts | 24 ++++++++++++++++++++++++ packages/protocol/src/types.ts | 9 +++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/protocol/src/driver.ts b/packages/protocol/src/driver.ts index 581ec3d..9f88ce3 100644 --- a/packages/protocol/src/driver.ts +++ b/packages/protocol/src/driver.ts @@ -15,8 +15,29 @@ import type { SwipeDirection, SwipeOptions, ViewNode, + WebViewInfo, } from './types.js'; +export interface WebViewSession { + evaluate(expr: string): Promise; + querySelectorAll(selector: string): Promise; + click(nodeId: string): Promise; + type(nodeId: string, text: string): Promise; + getAttribute(nodeId: string, name: string): Promise; + getText(nodeId: string): Promise; + goto(url: string): Promise; + url(): Promise; + title(): Promise; + reload(): Promise; + waitForLoadState(state?: 'load' | 'domcontentloaded'): Promise; + close(): Promise; +} + +export interface WebViewBridge { + listWebViews(): Promise; + attachWebView(id: string): Promise; +} + export interface MobilewrightDriver { // Connection connect(config: ConnectionConfig): Promise; @@ -55,4 +76,7 @@ export interface MobilewrightDriver { // Recording startRecording(opts: RecordingOptions): Promise; stopRecording(): Promise; + + // WebView (optional — drivers that don't support it omit this) + webViewBridge?: WebViewBridge; } diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 60db3bd..f88e390 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -148,6 +148,15 @@ export interface ListDevicesOptions { state?: DeviceState; } +// ─── WebView ───────────────────────────────────────────────────── + +export interface WebViewInfo { + id: string; + url: string; + title: string; + nativeBounds?: Bounds; +} + // ─── Recording ────────────────────────────────────────────────── export interface RecordingOptions { From a2c68f3387576173aae4703dcf0be2370b66fccd Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sun, 10 May 2026 19:12:14 +0200 Subject: [PATCH 02/42] feat(query-engine): add webview strategy kind Adds { kind: 'webview' } to LocatorStrategy with exact type matching for WKWebView, XCUIElementTypeWebView, android.webkit.WebView, RCTWebView, and RNCWebView. Includes unit tests for all five types, chaining, and empty results. --- .../src/query-engine.test.ts | 77 +++++++++++++++++++ .../mobilewright-core/src/query-engine.ts | 12 +++ 2 files changed, 89 insertions(+) diff --git a/packages/mobilewright-core/src/query-engine.test.ts b/packages/mobilewright-core/src/query-engine.test.ts index e36d9a5..579ff8e 100644 --- a/packages/mobilewright-core/src/query-engine.test.ts +++ b/packages/mobilewright-core/src/query-engine.test.ts @@ -351,6 +351,83 @@ test.describe('placeholder strategy', () => { }); }); +test.describe('webview strategy', () => { + function webviewNode(type: string, identifier: string): ViewNode { + return node({ type, identifier, bounds: { x: 0, y: 100, width: 390, height: 600 } }); + } + + const treeWithWebViews: ViewNode[] = [ + node({ type: 'Application', children: [ + node({ type: 'Window', children: [ + node({ type: 'Button', label: 'Open', identifier: 'openBtn' }), + webviewNode('WKWebView', 'webview1'), + webviewNode('WKWebView', 'webview2'), + ]}), + ]}), + ]; + + test('finds WKWebView by webview strategy', () => { + const results = queryAll(treeWithWebViews, { kind: 'webview' }); + expect(results).toHaveLength(2); + expect(results[0].identifier).toBe('webview1'); + expect(results[1].identifier).toBe('webview2'); + }); + + test('finds XCUIElementTypeWebView', () => { + const results = queryAll([webviewNode('XCUIElementTypeWebView', 'wv')], { kind: 'webview' }); + expect(results).toHaveLength(1); + }); + + test('finds android.webkit.WebView', () => { + const results = queryAll([webviewNode('android.webkit.WebView', 'wv')], { kind: 'webview' }); + expect(results).toHaveLength(1); + }); + + test('finds RCTWebView', () => { + const results = queryAll([webviewNode('RCTWebView', 'wv')], { kind: 'webview' }); + expect(results).toHaveLength(1); + }); + + test('finds RNCWebView', () => { + const results = queryAll([webviewNode('RNCWebView', 'wv')], { kind: 'webview' }); + expect(results).toHaveLength(1); + }); + + test('does not match non-webview types', () => { + const results = queryAll(treeWithWebViews, { kind: 'webview' }); + const types = results.map((n) => n.type); + expect(types.every((t) => WEBVIEW_TYPES_FOR_TEST.has(t))).toBe(true); + }); + + test('chained parent getByWebView finds webview inside a container', () => { + const tree: ViewNode[] = [ + node({ type: 'View', identifier: 'tab1', bounds: { x: 0, y: 0, width: 390, height: 800 }, children: [ + webviewNode('WKWebView', 'wv-in-tab1'), + ]}), + node({ type: 'View', identifier: 'tab2', bounds: { x: 390, y: 0, width: 390, height: 800 }, children: [ + webviewNode('WKWebView', 'wv-in-tab2'), + ]}), + ]; + const strategy: LocatorStrategy = { + kind: 'chain', + parent: { kind: 'testId', value: 'tab2' }, + child: { kind: 'webview' }, + }; + const results = queryAll(tree, strategy); + expect(results).toHaveLength(1); + expect(results[0].identifier).toBe('wv-in-tab2'); + }); + + test('returns empty when no webviews in tree', () => { + const results = queryAll(sampleTree, { kind: 'webview' }); + expect(results).toHaveLength(0); + }); +}); + +const WEBVIEW_TYPES_FOR_TEST = new Set([ + 'WKWebView', 'XCUIElementTypeWebView', 'android.webkit.WebView', 'RCTWebView', 'RNCWebView', +]); + test.describe('testId with resourceId', () => { const tree: ViewNode[] = [ node({ diff --git a/packages/mobilewright-core/src/query-engine.ts b/packages/mobilewright-core/src/query-engine.ts index dc479a7..8bfa0d2 100644 --- a/packages/mobilewright-core/src/query-engine.ts +++ b/packages/mobilewright-core/src/query-engine.ts @@ -8,6 +8,7 @@ export type LocatorStrategy = | { kind: 'type'; value: string } | { kind: 'role'; value: string; name?: string | RegExp } | { kind: 'placeholder'; value: string; exact?: boolean } + | { kind: 'webview' } | { kind: 'chain'; parent: LocatorStrategy; child: LocatorStrategy } | { kind: 'nth'; parent: LocatorStrategy; index: number }; @@ -119,6 +120,9 @@ function matchesStrategy( ? node.placeholder.toLowerCase().includes(strategy.value.toLowerCase()) : node.placeholder === strategy.value; + case 'webview': + return WEBVIEW_TYPES.has(node.type); + case 'chain': // Handled above in queryAll return false; @@ -128,6 +132,14 @@ function matchesStrategy( } } +const WEBVIEW_TYPES = new Set([ + 'WKWebView', + 'XCUIElementTypeWebView', + 'android.webkit.WebView', + 'RCTWebView', + 'RNCWebView', +]); + const ROLE_TYPE_MAP: Record = { button: ['button', 'imagebutton'], textfield: ['textfield', 'securetextfield', 'edittext', 'searchfield', 'reactedittext'], From 4ba901a2b58d35cb4887d5cf834bd06d1cb33e9b Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sun, 10 May 2026 19:24:59 +0200 Subject: [PATCH 03/42] feat(core): add WebViewLocator, Page stub, and screen.getByWebView() WebViewLocator extends Locator and adds page() which attaches to the bridge by matching the native webview index to bridge.listWebViews(). first/last/nth are overridden to preserve WebViewLocator type; other chaining methods return plain Locator. Page is a minimal stub to be filled in step 4. --- packages/mobilewright-core/src/locator.ts | 8 +-- packages/mobilewright-core/src/page.ts | 9 +++ packages/mobilewright-core/src/screen.ts | 11 ++- .../mobilewright-core/src/webview-locator.ts | 68 +++++++++++++++++++ 4 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 packages/mobilewright-core/src/page.ts create mode 100644 packages/mobilewright-core/src/webview-locator.ts diff --git a/packages/mobilewright-core/src/locator.ts b/packages/mobilewright-core/src/locator.ts index b31cd2f..659f951 100644 --- a/packages/mobilewright-core/src/locator.ts +++ b/packages/mobilewright-core/src/locator.ts @@ -27,9 +27,9 @@ export class Locator { } constructor( - private readonly driver: MobilewrightDriver, - private readonly strategy: LocatorStrategy, - private readonly options: LocatorOptions = {}, + protected readonly driver: MobilewrightDriver, + protected readonly strategy: LocatorStrategy, + protected readonly options: LocatorOptions = {}, ) {} // ─── Chaining ──────────────────────────────────────────────── @@ -58,7 +58,7 @@ export class Locator { return this.child({ kind: 'placeholder', value: placeholder, exact: opts?.exact }); } - private child(childStrategy: LocatorStrategy): Locator { + protected child(childStrategy: LocatorStrategy): Locator { return new Locator( this.driver, { kind: 'chain', parent: this.strategy, child: childStrategy }, diff --git a/packages/mobilewright-core/src/page.ts b/packages/mobilewright-core/src/page.ts new file mode 100644 index 0000000..1587da3 --- /dev/null +++ b/packages/mobilewright-core/src/page.ts @@ -0,0 +1,9 @@ +import type { WebViewSession } from '@mobilewright/protocol'; + +export class Page { + constructor(readonly session: WebViewSession) {} + + async close(): Promise { + await this.session.close(); + } +} diff --git a/packages/mobilewright-core/src/screen.ts b/packages/mobilewright-core/src/screen.ts index f551e29..27bcf26 100644 --- a/packages/mobilewright-core/src/screen.ts +++ b/packages/mobilewright-core/src/screen.ts @@ -9,13 +9,14 @@ import type { ViewNode, } from '@mobilewright/protocol'; import { Locator, type LocatorOptions } from './locator.js'; +import { WebViewLocator } from './webview-locator.js'; export class Screen { private readonly root: Locator; constructor( private readonly driver: MobilewrightDriver, - locatorDefaults: LocatorOptions = {}, + private readonly locatorDefaults: LocatorOptions = {}, ) { this.root = Locator.root(driver, locatorDefaults); } @@ -46,6 +47,14 @@ export class Screen { return this.root.getByPlaceholder(placeholder, opts); } + getByWebView(): WebViewLocator { + return new WebViewLocator( + this.driver, + { kind: 'chain', parent: { kind: 'root' }, child: { kind: 'webview' } }, + this.locatorDefaults, + ); + } + // ─── Direct screen actions ────────────────────────────────── async screenshot(opts?: ScreenshotOptions): Promise { diff --git a/packages/mobilewright-core/src/webview-locator.ts b/packages/mobilewright-core/src/webview-locator.ts new file mode 100644 index 0000000..3184d67 --- /dev/null +++ b/packages/mobilewright-core/src/webview-locator.ts @@ -0,0 +1,68 @@ +import type { MobilewrightDriver } from '@mobilewright/protocol'; +import { queryAll, type LocatorStrategy } from './query-engine.js'; +import { Locator, type LocatorOptions } from './locator.js'; +import { Page } from './page.js'; + +export class WebViewLocator extends Locator { + private _page: Page | null = null; + + // first/last/nth stay within webview context so .page() remains available + override first(): WebViewLocator { + return this.nthWebView(0); + } + + override last(): WebViewLocator { + return this.nthWebView(-1); + } + + override nth(index: number): WebViewLocator { + return this.nthWebView(index); + } + + private nthWebView(index: number): WebViewLocator { + return new WebViewLocator( + this.driver, + { kind: 'nth', parent: this.strategy, index }, + this.options, + ); + } + + // Chaining into DOM locators returns a plain Locator, not WebViewLocator + protected override child(childStrategy: LocatorStrategy): Locator { + return new Locator( + this.driver, + { kind: 'chain', parent: this.strategy, child: childStrategy }, + this.options, + ); + } + + async page(): Promise { + if (this._page) return this._page; + + const bridge = this.driver.webViewBridge; + if (!bridge) { + throw new Error( + 'getByWebView().page(): this driver does not have a webViewBridge', + ); + } + + const roots = await this.driver.getViewHierarchy(); + const selected = queryAll(roots, this.strategy); + if (selected.length === 0) { + throw new Error('getByWebView().page(): no webview element found in the view hierarchy'); + } + + // Match native webview index to bridge webview list + const allNativeWebviews = queryAll(roots, { kind: 'webview' }); + const index = allNativeWebviews.indexOf(selected[0]); + const bridgeWebviews = await bridge.listWebViews(); + const target = bridgeWebviews[Math.max(0, index)]; + if (!target) { + throw new Error('getByWebView().page(): bridge returned no webviews'); + } + + const session = await bridge.attachWebView(target.id); + this._page = new Page(session); + return this._page; + } +} From 8ed5a83ae3c6840f14f56fc55e3b28174a561d81 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sun, 10 May 2026 19:28:17 +0200 Subject: [PATCH 04/42] feat(core): implement Page class and WebLocator stub Page exposes all v1 locator factories (getByRole, getByText, getByLabel, getByPlaceholder, getByTestId, getByAltText, getByTitle, locator) and page-level methods (url, title, goto, reload, evaluate, waitForURL, waitForLoadState, content, close). WebLocator defines WebLocatorStrategy and factory/collection methods; actions and queries come in step 5. --- packages/mobilewright-core/src/page.ts | 87 +++++++++++++++++++ packages/mobilewright-core/src/web-locator.ts | 69 +++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 packages/mobilewright-core/src/web-locator.ts diff --git a/packages/mobilewright-core/src/page.ts b/packages/mobilewright-core/src/page.ts index 1587da3..8003880 100644 --- a/packages/mobilewright-core/src/page.ts +++ b/packages/mobilewright-core/src/page.ts @@ -1,8 +1,95 @@ import type { WebViewSession } from '@mobilewright/protocol'; +import { retryUntil } from './poll.js'; +import { WebLocator } from './web-locator.js'; + +const DEFAULT_TIMEOUT = 5_000; export class Page { constructor(readonly session: WebViewSession) {} + // ─── Locator factories ─────────────────────────────────────── + + locator(selector: string): WebLocator { + return new WebLocator(this.session, { kind: 'css', selector }); + } + + getByRole(role: string, opts?: { name?: string | RegExp }): WebLocator { + return new WebLocator(this.session, { kind: 'role', role, name: opts?.name }); + } + + getByText(text: string | RegExp, opts?: { exact?: boolean }): WebLocator { + return new WebLocator(this.session, { kind: 'text', text, exact: opts?.exact }); + } + + getByLabel(label: string | RegExp, opts?: { exact?: boolean }): WebLocator { + return new WebLocator(this.session, { kind: 'label', label, exact: opts?.exact }); + } + + getByPlaceholder(text: string | RegExp, opts?: { exact?: boolean }): WebLocator { + return new WebLocator(this.session, { kind: 'placeholder', text, exact: opts?.exact }); + } + + getByTestId(testId: string): WebLocator { + return new WebLocator(this.session, { kind: 'testId', testId }); + } + + getByAltText(text: string | RegExp): WebLocator { + return new WebLocator(this.session, { kind: 'altText', text }); + } + + getByTitle(text: string | RegExp): WebLocator { + return new WebLocator(this.session, { kind: 'title', text }); + } + + // ─── Page-level methods ────────────────────────────────────── + + async url(): Promise { + return this.session.url(); + } + + async title(): Promise { + return this.session.title(); + } + + async goto(url: string): Promise { + await this.session.goto(url); + } + + async reload(): Promise { + await this.session.reload(); + } + + async evaluate(fn: string | (() => T)): Promise { + const expr = typeof fn === 'function' ? `(${fn.toString()})()` : fn; + return this.session.evaluate(expr); + } + + async waitForURL( + url: string | RegExp, + opts?: { timeout?: number }, + ): Promise { + await retryUntil( + () => this.session.url(), + (current) => url instanceof RegExp ? url.test(current) : current === url, + opts?.timeout ?? DEFAULT_TIMEOUT, + () => `waitForURL: timed out waiting for URL to match "${url}"`, + ); + } + + async waitForLoadState( + state: 'load' | 'domcontentloaded' = 'load', + ): Promise { + await this.session.waitForLoadState(state); + } + + async content(): Promise { + return this.session.evaluate('document.documentElement.outerHTML'); + } + + async screenshot(): Promise { + throw new Error('page.screenshot() is not yet supported — requires a screenshot capability on WebViewSession'); + } + async close(): Promise { await this.session.close(); } diff --git a/packages/mobilewright-core/src/web-locator.ts b/packages/mobilewright-core/src/web-locator.ts new file mode 100644 index 0000000..c4c09b7 --- /dev/null +++ b/packages/mobilewright-core/src/web-locator.ts @@ -0,0 +1,69 @@ +import type { WebViewSession } from '@mobilewright/protocol'; + +export type WebLocatorStrategy = + | { kind: 'css'; selector: string } + | { kind: 'role'; role: string; name?: string | RegExp } + | { kind: 'text'; text: string | RegExp; exact?: boolean } + | { kind: 'label'; label: string | RegExp; exact?: boolean } + | { kind: 'placeholder'; text: string | RegExp; exact?: boolean } + | { kind: 'testId'; testId: string } + | { kind: 'altText'; text: string | RegExp } + | { kind: 'title'; text: string | RegExp } + | { kind: 'nth'; parent: WebLocatorStrategy; index: number }; + +export class WebLocator { + constructor( + protected readonly session: WebViewSession, + protected readonly strategy: WebLocatorStrategy, + ) {} + + // ─── Chaining ──────────────────────────────────────────────── + + locator(selector: string): WebLocator { + return new WebLocator(this.session, { kind: 'css', selector }); + } + + getByRole(role: string, opts?: { name?: string | RegExp }): WebLocator { + return new WebLocator(this.session, { kind: 'role', role, name: opts?.name }); + } + + getByText(text: string | RegExp, opts?: { exact?: boolean }): WebLocator { + return new WebLocator(this.session, { kind: 'text', text, exact: opts?.exact }); + } + + getByLabel(label: string | RegExp, opts?: { exact?: boolean }): WebLocator { + return new WebLocator(this.session, { kind: 'label', label, exact: opts?.exact }); + } + + getByPlaceholder(text: string | RegExp, opts?: { exact?: boolean }): WebLocator { + return new WebLocator(this.session, { kind: 'placeholder', text, exact: opts?.exact }); + } + + getByTestId(testId: string): WebLocator { + return new WebLocator(this.session, { kind: 'testId', testId }); + } + + getByAltText(text: string | RegExp): WebLocator { + return new WebLocator(this.session, { kind: 'altText', text }); + } + + getByTitle(text: string | RegExp): WebLocator { + return new WebLocator(this.session, { kind: 'title', text }); + } + + // ─── Collection ────────────────────────────────────────────── + + first(): WebLocator { + return this.nth(0); + } + + last(): WebLocator { + return this.nth(-1); + } + + nth(index: number): WebLocator { + return new WebLocator(this.session, { kind: 'nth', parent: this.strategy, index }); + } + + // ─── Actions and queries (step 5) ──────────────────────────── +} From afe7eaadb73922585b85663ad1a785275e26e72d Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sun, 10 May 2026 19:56:54 +0200 Subject: [PATCH 05/42] feat(core): Playwright-compatible DOM selector engine and WebLocator implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces dom-selector-engine.ts — a browser-injectable JS string defining window.__mw with W3C-compliant ARIA role computation, accessible name algorithm, text matching (innermost-match semantics), and label detection. buildFindAll() now delegates to window.__mw.* instead of naive inline CSS/JS guesses. Page gains a static attach() factory that injects the engine once per session. WebLocator gains full query methods (isVisible, textContent, inputValue, getAttribute, count, waitFor, boundingBox) and action methods (click, fill, type, press, focus, hover, scrollIntoViewIfNeeded). --- .../src/dom-selector-engine.ts | 376 ++++++++++++++++++ packages/mobilewright-core/src/page.ts | 6 + packages/mobilewright-core/src/web-locator.ts | 237 ++++++++++- .../mobilewright-core/src/webview-locator.ts | 2 +- 4 files changed, 614 insertions(+), 7 deletions(-) create mode 100644 packages/mobilewright-core/src/dom-selector-engine.ts diff --git a/packages/mobilewright-core/src/dom-selector-engine.ts b/packages/mobilewright-core/src/dom-selector-engine.ts new file mode 100644 index 0000000..f8ebe31 --- /dev/null +++ b/packages/mobilewright-core/src/dom-selector-engine.ts @@ -0,0 +1,376 @@ +// Browser-injectable selector engine injected once per Page via session.evaluate(). +// Defines window.__mw with Playwright-compatible DOM matching helpers. +// Written from scratch against the W3C accName spec and HTML-AAM spec, +// matching Playwright's observable behavior for the common cases. + +export const DOM_SELECTOR_ENGINE = ` +(function () { + // ─── Text helpers ──────────────────────────────────────────── + + function normalizeWhiteSpace(s) { + return s.replace(/[\\u200b\\u00ad]/g, '').replace(/\\s+/g, ' ').trim(); + } + + function shouldSkipForTextMatching(el) { + const n = el.nodeName; + return n === 'SCRIPT' || n === 'NOSCRIPT' || n === 'STYLE' || n === 'TEMPLATE' || + (el.ownerDocument.head && el.ownerDocument.head.contains(el)); + } + + // Returns the full text content of an element, recursing into children but + // skipping script/style nodes. Matches Playwright's elementText().full. + function elementFullText(el) { + if (shouldSkipForTextMatching(el)) return ''; + if ((el.nodeName === 'INPUT') && (el.type === 'submit' || el.type === 'button')) { + return el.value; + } + let text = ''; + for (let child = el.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 3 /* TEXT_NODE */) { + text += child.nodeValue || ''; + } else if (child.nodeType === 1 /* ELEMENT_NODE */) { + text += elementFullText(child); + } + } + return text; + } + + function elementNormalizedText(el) { + return normalizeWhiteSpace(elementFullText(el)); + } + + // Mirrors Playwright's elementMatchesText(): returns whether the element's + // text matches AND whether a child also matches (to find the innermost match). + // Returns 'none' | 'self' | 'selfAndChildren'. + function elementMatchesText(el, matcher) { + if (shouldSkipForTextMatching(el)) return 'none'; + if (!matcher(elementNormalizedText(el))) return 'none'; + for (let child = el.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 1 && matcher(elementNormalizedText(child))) + return 'selfAndChildren'; + } + return 'self'; + } + + // Iterates all elements in document order (like querySelectorAll('*')), + // returning only the innermost matching elements — mirrors Playwright's + // internal:text engine behavior. + function findByText(root, textOrRegex, exact) { + const matcher = buildTextMatcher(textOrRegex, exact); + const result = []; + const all = root.querySelectorAll('*'); + for (const el of all) { + const match = elementMatchesText(el, matcher); + if (match === 'self') result.push(el); + // 'selfAndChildren' → skip the parent, child will be pushed when visited + } + return result; + } + + function buildTextMatcher(textOrRegex, exact) { + if (textOrRegex instanceof RegExp) return s => textOrRegex.test(s); + if (exact) return s => s === textOrRegex; + // Playwright default: case-sensitive substring on normalised text + return s => s.includes(textOrRegex); + } + + // ─── Attribute helpers ─────────────────────────────────────── + + // Used by getByPlaceholder, getByAltText, getByTitle. + // Playwright default (exact=false): case-insensitive substring. + function findByAttr(root, attrName, textOrRegex, exact) { + const elements = Array.from(root.querySelectorAll('[' + attrName + ']')); + return elements.filter(el => { + const val = el.getAttribute(attrName) || ''; + if (textOrRegex instanceof RegExp) return textOrRegex.test(val); + if (exact) return val === textOrRegex; + return val.toLowerCase().includes(textOrRegex.toLowerCase()); + }); + } + + // ─── ARIA role computation ──────────────────────────────────── + + const kAncestorPreventingLandmark = 'article,aside,main,nav,section'; + + const kInputTypeToRole = { + button: 'button', checkbox: 'checkbox', image: 'button', + number: 'spinbutton', radio: 'radio', range: 'slider', + reset: 'button', submit: 'button', + }; + + // https://w3c.github.io/html-aam/#html-element-role-mappings + const kImplicitRole = { + A: el => el.hasAttribute('href') ? 'link' : null, + AREA: el => el.hasAttribute('href') ? 'link' : null, + ARTICLE: () => 'article', + ASIDE: () => 'complementary', + BLOCKQUOTE: () => 'blockquote', + BUTTON: () => 'button', + CAPTION: () => 'caption', + CODE: () => 'code', + DATALIST: () => 'listbox', + DD: () => 'definition', + DEL: () => 'deletion', + DETAILS: () => 'group', + DFN: () => 'term', + DIALOG: () => 'dialog', + DT: () => 'term', + EM: () => 'emphasis', + FIELDSET: () => 'group', + FIGURE: () => 'figure', + FOOTER: el => el.closest(kAncestorPreventingLandmark) ? null : 'contentinfo', + FORM: el => (el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby')) ? 'form' : null, + H1: () => 'heading', H2: () => 'heading', H3: () => 'heading', + H4: () => 'heading', H5: () => 'heading', H6: () => 'heading', + HEADER: el => el.closest(kAncestorPreventingLandmark) ? null : 'banner', + HR: () => 'separator', + HTML: () => 'document', + IMG: el => (el.getAttribute('alt') === '' && !el.getAttribute('title') && !el.hasAttribute('tabindex')) ? 'presentation' : 'img', + INPUT: el => { + const t = (el.type || '').toLowerCase(); + if (t === 'search') return el.hasAttribute('list') ? 'combobox' : 'searchbox'; + if (['email','tel','text','url',''].includes(t)) return el.hasAttribute('list') ? 'combobox' : 'textbox'; + if (t === 'hidden') return null; + if (t === 'file') return 'button'; + return kInputTypeToRole[t] || 'textbox'; + }, + INS: () => 'insertion', + LI: () => 'listitem', + MAIN: () => 'main', + MARK: () => 'mark', + MATH: () => 'math', + MENU: () => 'list', + METER: () => 'meter', + NAV: () => 'navigation', + OL: () => 'list', + OPTGROUP: () => 'group', + OPTION: () => 'option', + OUTPUT: () => 'status', + P: () => 'paragraph', + PROGRESS: () => 'progressbar', + SEARCH: () => 'search', + SECTION: el => (el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby')) ? 'region' : null, + SELECT: el => (el.hasAttribute('multiple') || el.size > 1) ? 'listbox' : 'combobox', + STRONG: () => 'strong', + SUB: () => 'subscript', + SUP: () => 'superscript', + SVG: () => 'img', + TABLE: () => 'table', + TBODY: () => 'rowgroup', + TD: el => { const t = el.closest('table'); const r = t ? getExplicitRole(t) : null; return (r === 'grid' || r === 'treegrid') ? 'gridcell' : 'cell'; }, + TEXTAREA: () => 'textbox', + TFOOT: () => 'rowgroup', + TH: el => { const s = el.getAttribute('scope'); return (s === 'row' || s === 'rowgroup') ? 'rowheader' : 'columnheader'; }, + THEAD: () => 'rowgroup', + TIME: () => 'time', + TR: () => 'row', + UL: () => 'list', + }; + + const kValidRoles = new Set(['alert','alertdialog','application','article','banner','blockquote','button','caption','cell','checkbox','code','columnheader','combobox','complementary','contentinfo','definition','deletion','dialog','directory','document','emphasis','feed','figure','form','generic','grid','gridcell','group','heading','img','insertion','link','list','listbox','listitem','log','main','mark','marquee','math','meter','menu','menubar','menuitem','menuitemcheckbox','menuitemradio','navigation','none','note','option','paragraph','presentation','progressbar','radio','radiogroup','region','row','rowgroup','rowheader','scrollbar','search','searchbox','separator','slider','spinbutton','status','strong','subscript','superscript','switch','tab','table','tablist','tabpanel','term','textbox','time','timer','toolbar','tooltip','tree','treegrid','treeitem']); + + function getExplicitRole(el) { + const tokens = (el.getAttribute('role') || '').split(/\\s+/).map(r => r.trim()); + return tokens.find(r => kValidRoles.has(r)) || null; + } + + function getImplicitRole(el) { + const fn = kImplicitRole[el.tagName]; + return fn ? fn(el) : null; + } + + function isNativelyFocusable(el) { + const t = el.tagName; + if (['BUTTON','DETAILS','SELECT','TEXTAREA'].includes(t)) return true; + if (t === 'A' || t === 'AREA') return el.hasAttribute('href'); + if (t === 'INPUT') return (el.type || '').toLowerCase() !== 'hidden'; + return false; + } + + function getAriaRole(el) { + const explicit = getExplicitRole(el); + if (!explicit) return getImplicitRole(el); + // Presentation conflict resolution: explicit none/presentation is overridden + // when element is focusable or has global ARIA attributes. + if (explicit === 'none' || explicit === 'presentation') { + if (el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby') || + !isNaN(Number(el.getAttribute('tabindex'))) || isNativelyFocusable(el)) { + return getImplicitRole(el) || explicit; + } + } + return explicit; + } + + // ─── Accessible name (W3C accName, simplified) ─────────────── + + function getIdRefs(el, attrValue) { + if (!attrValue) return []; + const root = el.getRootNode() || document; + return attrValue.split(/\\s+/).filter(Boolean).flatMap(id => { + try { + const found = (root.querySelector ? root : document).querySelector('#' + CSS.escape(id)); + return found ? [found] : []; + } catch (e) { return []; } + }); + } + + // Priority: aria-labelledby > aria-label > native label/alt/placeholder/title > content + function getAccessibleName(el, visited) { + if (!visited) visited = new Set(); + if (visited.has(el)) return ''; + visited.add(el); + + // 1. aria-labelledby + const labelledByRefs = getIdRefs(el, el.getAttribute('aria-labelledby')); + if (labelledByRefs.length) + return normalizeWhiteSpace(labelledByRefs.map(r => getAccessibleName(r, visited)).join(' ')); + + // 2. aria-label + const ariaLabel = (el.getAttribute('aria-label') || '').trim(); + if (ariaLabel) return ariaLabel; + + const tag = el.tagName; + + // 3. input[type=button/submit/reset] + if (tag === 'INPUT') { + const type = (el.type || '').toLowerCase(); + if (['button','submit','reset'].includes(type)) { + const val = (el.value || '').trim(); + if (val) return val; + if (type === 'submit') return 'Submit'; + if (type === 'reset') return 'Reset'; + return el.getAttribute('title') || ''; + } + if (type === 'image') { + const alt = (el.getAttribute('alt') || '').trim(); + return alt || el.getAttribute('title') || 'Submit'; + } + } + + // 4. Associated