diff --git a/README.md b/README.md index 9de004b..5b51bf8 100644 --- a/README.md +++ b/README.md @@ -435,7 +435,7 @@ The daemon routes `POST /tool/:name` requests through the registry, applies Zod ### Accessibility References -The core uses Chrome DevTools Protocol accessibility APIs to build a deterministic reference map of interactive and meaningful accessibility nodes. Each element gets a short ref like `e1`, `e2`, etc., mapped to a Playwright-compatible selector. +The core uses Chrome DevTools Protocol accessibility APIs to build a deterministic reference map of interactive and meaningful accessibility nodes. Each element gets a short ref like `e1`, `e2`, etc., mapped to a Playwright-compatible selector. Each browser ref resolves to exactly one element, or the action fails (strict mode) instead of acting on another element. A ref uses `role=[name="" s]`, which matches the exact, case-sensitive accessible name. When several elements of a snapshot share a role and name, each of them gets a selector built from its DOM node and verified unique the way Playwright matches CSS (unique `data-testid`, else unique `id`, else a path anchored at the nearest ancestor with a unique `data-testid` or `id`; elements in open shadow roots get `css= >> css=`). When no such selector can be built, the ref keeps the plain role selector, so an action on it fails in strict mode; refs to elements in closed shadow roots always fail. Generic elements without a DOM selector use `text=""`, which also fails when ambiguous. Role and text refs from a scoped `accessibility_snapshot` are prefixed with the root selector. Agents call `describe_screen` to get the current reference map, then use refs for interaction: diff --git a/SKILL.md b/SKILL.md index 75bae0f..fb2078b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -232,7 +232,7 @@ The `testId` and `textContent` fields appear only on nodes with short or generic When 3+ consecutive identical nodes appear (same role, name, and path), they are collapsed into a summary like `… 3 more "maskicon" (refs e2–e4)` to reduce token waste. Individual refs still work for targeting. -Use the `ref` value (`e3`) for click/type/get-text/wait-for commands. +Use the `ref` value (`e3`) for click/type/get-text/wait-for commands. On the browser, each ref resolves to exactly one element, or the action fails (strict mode) instead of acting on another element: refs use the exact, case-sensitive name, and elements that share a role and name get a unique DOM selector, so two "Send" buttons get two refs that each click their own button. If a click fails with a strict mode violation, target the element by `--testid` or `--selector` instead. #### `mm list-testids` @@ -284,7 +284,7 @@ mm click --testid end-accessory --within "testid:account-list-item/0" mm click --testid onboarding-complete-done --timeout 60000 ``` -Use `--within` to scope the target inside a parent element. Values use the format `testid:`, `selector:`, or a bare a11y ref (`e5`). +Use `--within` to scope the target inside a parent element. Values use the format `testid:`, `selector:`, or a bare a11y ref (`e5`). With a ref target (`mm click e7 --within ...`), the ref already identifies one element, so `--within` only checks that it is inside the parent; when it is on the page but outside the parent, the command fails at once with `Ref e7 is not inside the within element`. If the page closes after clicking (e.g., confirmation popup), the response includes `pageClosedAfterClick: true` — this is normal, not an error. diff --git a/src/server/create-server.test.ts b/src/server/create-server.test.ts index d37bb76..877a17b 100644 --- a/src/server/create-server.test.ts +++ b/src/server/create-server.test.ts @@ -1249,7 +1249,9 @@ describe('createServer with active session', () => { // The first request is now running inside the queue. await vi.waitFor(() => expect(mockSM.cleanup).toHaveBeenCalled()); const second = post('/tool/get_context'); - await new Promise((resolve) => setTimeout(resolve, 60)); + // Hold the first request well past the threshold below: under host + // load the second request can take tens of ms to reach the queue. + await new Promise((resolve) => setTimeout(resolve, 250)); releaseFirst(); const [firstRes, secondRes] = await Promise.all([first, second]); diff --git a/src/tools/utils/cdp.test.ts b/src/tools/utils/cdp.test.ts index c916869..8ee8a18 100644 --- a/src/tools/utils/cdp.test.ts +++ b/src/tools/utils/cdp.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi } from 'vitest'; import { ISOLATED_WORLD_NAME, callInIsolatedWorld, + callOnNodeInIsolatedWorld, withCdpSession, } from './cdp.js'; @@ -14,10 +15,17 @@ type Send = ReturnType; * Builds a page whose CDP sessions answer with `respond`. * * @param respond - Answers `Runtime.callFunctionOn`. + * @param resolveNode - Answers `DOM.resolveNode` (default: an object id). * @returns The page, the sessions it opened and their send mocks. */ function createPage( respond: (params: Record, sessionIndex: number) => unknown, + resolveNode: ( + params: Record, + sessionIndex: number, + ) => unknown = (params) => ({ + object: { objectId: `obj-${String(params.backendNodeId)}` }, + }), ) { const sessions: { send: Send; detach: Send }[] = []; let contextCounter = 0; @@ -33,6 +41,10 @@ function createPage( return { executionContextId: contextCounter }; case 'Runtime.callFunctionOn': return respond(params, sessionIndex); + case 'DOM.resolveNode': + return resolveNode(params, sessionIndex); + case 'Runtime.releaseObjectGroup': + return {}; default: throw new Error(`Unexpected ${method}`); } @@ -228,3 +240,117 @@ describe('callInIsolatedWorld', () => { expect(await callInIsolatedWorld(page, '() => 1', [], 1_000)).toBe('quick'); }); }); + +describe('callOnNodeInIsolatedWorld', () => { + it('resolves the node in the isolated world and calls the function on it', async () => { + const { page, sessions } = createPage((params) => { + const fn = vm.runInNewContext( + `(${String(params.functionDeclaration)})`, + {}, + ); + return { result: { value: fn.call({ id: params.objectId }) } }; + }); + + const result = await callOnNodeInIsolatedWorld( + page, + 42, + 'function () { return __name(this.id, "x"); }', + ); + + expect(result).toBe('obj-42'); + const { calls } = sessions[0].send.mock; + const resolve = calls.find(([method]) => method === 'DOM.resolveNode'); + expect(resolve?.[1]).toStrictEqual({ + backendNodeId: 42, + executionContextId: 1, + objectGroup: expect.stringMatching(/^mm-node-\d+$/u), + }); + expect(calls).toContainEqual([ + 'Runtime.callFunctionOn', + expect.objectContaining({ objectId: 'obj-42', returnByValue: true }), + ]); + expect(calls).toContainEqual([ + 'Runtime.releaseObjectGroup', + { objectGroup: resolve?.[1].objectGroup }, + ]); + }); + + it('shares the session and world with callInIsolatedWorld', async () => { + const { page, newCDPSession } = createPage(() => ({ + result: { value: 1 }, + })); + + await callInIsolatedWorld(page, '() => 1'); + await callOnNodeInIsolatedWorld(page, 1, 'function () { return 1; }'); + + expect(newCDPSession).toHaveBeenCalledTimes(1); + }); + + it('rejects without retrying when the node cannot be resolved', async () => { + const { page, newCDPSession, sessions } = createPage( + () => ({ result: { value: 1 } }), + () => { + throw new Error('No node with given id found'); + }, + ); + + await expect( + callOnNodeInIsolatedWorld(page, 7, 'function () { return 1; }'), + ).rejects.toThrowError('No node with given id found'); + expect(newCDPSession).toHaveBeenCalledTimes(1); + expect(sessions[0].detach).not.toHaveBeenCalled(); + }); + + it('rejects when the node has no object', async () => { + const { page, sessions } = createPage( + () => ({ result: { value: 1 } }), + () => ({ object: {} }), + ); + + await expect( + callOnNodeInIsolatedWorld(page, 7, 'function () { return 1; }'), + ).rejects.toThrowError('No object for node 7'); + expect(sessions[0].send).toHaveBeenCalledWith( + 'Runtime.releaseObjectGroup', + expect.anything(), + ); + }); + + it('starts over once on a new world after a stale context', async () => { + const { page, newCDPSession, sessions } = createPage( + () => ({ result: { value: 'fresh' } }), + (params, index) => { + if (index === 0) { + throw new Error('Cannot find context with specified id'); + } + return { object: { objectId: `obj-${String(params.backendNodeId)}` } }; + }, + ); + + const result = await callOnNodeInIsolatedWorld( + page, + 3, + 'function () { return 1; }', + ); + + expect(result).toBe('fresh'); + expect(newCDPSession).toHaveBeenCalledTimes(2); + expect(sessions[0].detach).toHaveBeenCalled(); + expect(sessions[1].send).toHaveBeenCalledWith( + 'DOM.resolveNode', + expect.objectContaining({ executionContextId: 2 }), + ); + }); + + it('rejects without retrying when the function throws in the page', async () => { + const { page, newCDPSession } = createPage(() => ({ + result: { type: 'object' }, + exceptionDetails: { text: 'Uncaught' }, + })); + + await expect( + callOnNodeInIsolatedWorld(page, 1, 'function () { throw 1; }'), + ).rejects.toThrowError('Uncaught'); + expect(newCDPSession).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/tools/utils/cdp.ts b/src/tools/utils/cdp.ts index 655a461..e9d90d3 100644 --- a/src/tools/utils/cdp.ts +++ b/src/tools/utils/cdp.ts @@ -58,17 +58,25 @@ function resetIsolatedWorld(page: Page, world: IsolatedWorld): void { } /** - * Calls a function once in the page's isolated world. + * Error thrown when the node of a backend node id cannot be resolved (it + * left the DOM, or the page navigated). The session and world stay valid. + */ +class NodeNotFoundError extends Error {} + +/** + * Runs a step on the page's cached session and isolated world, creating + * them on first use. On a session or world failure (closed session, world + * gone after a navigation), drops them so the next call starts over. + * Errors from the page function (`InPageError`) and unresolvable nodes + * (`NodeNotFoundError`) keep them. * * @param page - The page. - * @param functionDeclaration - Source text of the function. - * @param args - JSON arguments. - * @returns The function's JSON result. + * @param step - The CDP work, given the session and world context id. + * @returns The step's result. */ -async function callOnce( +async function inIsolatedWorld( page: Page, - functionDeclaration: string, - args: unknown[], + step: (session: CDPSession, executionContextId: number) => Promise, ): Promise { let world = isolatedWorlds.get(page); if (!world) { @@ -86,23 +94,12 @@ async function callOnce( ); return executionContextId; })(); - const executionContextId = await current.contextId; - const response = await session.send('Runtime.callFunctionOn', { - functionDeclaration, - executionContextId, - arguments: args.map((value) => ({ value })), - returnByValue: true, - awaitPromise: true, - }); - if (response.exceptionDetails) { - throw new InPageError( - response.exceptionDetails.exception?.description ?? - response.exceptionDetails.text, - ); - } - return response.result.value as TResult; + return await step(session, await current.contextId); } catch (error) { - if (!(error instanceof InPageError)) { + if ( + !(error instanceof InPageError) && + !(error instanceof NodeNotFoundError) + ) { // The session closed or the world went away (navigation): start // over on the next call. resetIsolatedWorld(page, current); @@ -111,6 +108,65 @@ async function callOnce( } } +/** + * Runs `inIsolatedWorld` and, after a session or world failure, once more + * on a new session and world. + * + * @param page - The page. + * @param step - The CDP work, given the session and world context id. + * @returns The step's result. + */ +async function inIsolatedWorldWithRetry( + page: Page, + step: (session: CDPSession, executionContextId: number) => Promise, +): Promise { + try { + return await inIsolatedWorld(page, step); + } catch (error) { + if (error instanceof InPageError || error instanceof NodeNotFoundError) { + throw error; + } + return await inIsolatedWorld(page, step); + } +} + +type CallFunctionOnResponse = { + exceptionDetails?: { + text: string; + exception?: { description?: string }; + }; +}; + +/** + * Throws the page exception of a `Runtime.callFunctionOn` response. + * + * @param response - The response (`exceptionDetails` is set when the + * function threw). + */ +function throwOnException(response: CallFunctionOnResponse): void { + if (response.exceptionDetails) { + throw new InPageError( + response.exceptionDetails.exception?.description ?? + response.exceptionDetails.text, + ); + } +} + +/** + * Wraps function source so it runs in the page. Tools such as esbuild/tsx + * with keepNames add `__name(...)` calls to function source; define it so + * such source still runs. + * + * @param functionDeclaration - Source text of the function. + * @returns The wrapped source. + */ +function wrapDeclaration(functionDeclaration: string): string { + return `function () { + var __name = function (fn) { return fn; }; + return (${functionDeclaration}).apply(this, arguments); + }`; +} + /** * Calls a function in an isolated world of the page's main frame and * returns its JSON result (promises are awaited). Steady state is one @@ -137,22 +193,21 @@ export async function callInIsolatedWorld( args: unknown[] = [], timeoutMs?: number, ): Promise { - // Tools such as esbuild/tsx with keepNames add `__name(...)` calls to - // function source; define it so such source still runs in the page. - const declaration = `function () { - var __name = function (fn) { return fn; }; - return (${functionDeclaration}).apply(this, arguments); - }`; - const call = (async (): Promise => { - try { - return await callOnce(page, declaration, args); - } catch (error) { - if (error instanceof InPageError) { - throw error; - } - return await callOnce(page, declaration, args); - } - })(); + const declaration = wrapDeclaration(functionDeclaration); + const call = inIsolatedWorldWithRetry( + page, + async (session, executionContextId) => { + const response = await session.send('Runtime.callFunctionOn', { + functionDeclaration: declaration, + executionContextId, + arguments: args.map((value) => ({ value })), + returnByValue: true, + awaitPromise: true, + }); + throwOnException(response); + return response.result.value as TResult; + }, + ); if (timeoutMs === undefined) { return call; } @@ -171,3 +226,67 @@ export async function callInIsolatedWorld( clearTimeout(timer); } } + +let nodeCallCounter = 0; + +/** + * Calls a function with a DOM node as `this`, in the isolated world of the + * page's main frame (see `callInIsolatedWorld`), and returns its JSON + * result. Runs on the cached session: `DOM.resolveNode` with the world's + * context id, then `Runtime.callFunctionOn` on the node object, then + * releases the object. Backend node ids are per renderer, so ids read on + * another CDP session of the same page are valid here. + * + * Retries once on a new session and world after a session or world + * failure. Rejects without retrying when the node cannot be resolved (it + * left the DOM) or when the function throws in the page. + * + * @param page - The Playwright page. + * @param backendNodeId - The backend DOM node id of the node. + * @param functionDeclaration - Source text of the function (self-contained, + * JSON-serializable result). + * @returns The function's return value. + */ +export async function callOnNodeInIsolatedWorld( + page: Page, + backendNodeId: number, + functionDeclaration: string, +): Promise { + const declaration = wrapDeclaration(functionDeclaration); + return inIsolatedWorldWithRetry(page, async (session, executionContextId) => { + nodeCallCounter += 1; + const objectGroup = `mm-node-${nodeCallCounter}`; + let objectId: string | undefined; + try { + ({ + object: { objectId }, + } = await session.send('DOM.resolveNode', { + backendNodeId, + executionContextId, + objectGroup, + })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/\bnode\b/iu.test(message)) { + throw new NodeNotFoundError(message); + } + throw error; + } + try { + if (!objectId) { + throw new NodeNotFoundError(`No object for node ${backendNodeId}`); + } + const response = await session.send('Runtime.callFunctionOn', { + functionDeclaration: declaration, + objectId, + returnByValue: true, + }); + throwOnException(response); + return response.result.value as TResult; + } finally { + await session + .send('Runtime.releaseObjectGroup', { objectGroup }) + .catch(() => undefined); + } + }); +} diff --git a/src/tools/utils/discovery.test.ts b/src/tools/utils/discovery.test.ts index 59c7153..6430fc8 100644 --- a/src/tools/utils/discovery.test.ts +++ b/src/tools/utils/discovery.test.ts @@ -9,6 +9,8 @@ */ import type { Page, Locator } from '@playwright/test'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; import { describe, it, expect, vi } from 'vitest'; import type { RawA11yNode } from '../types'; @@ -18,6 +20,7 @@ import { collectTrimmedA11ySnapshot, parseAriaSnapshotYaml, resolveTarget, + UNREACHABLE_REF_SELECTOR, waitForAnimationsToSettle, waitForTarget, } from './discovery.js'; @@ -681,8 +684,8 @@ describe('collectTrimmedA11ySnapshot', () => { name: 'Cancel', path: [], }); - expect(result.refMap.get('e1')).toBe('role=button[name="Submit"]'); - expect(result.refMap.get('e2')).toBe('role=button[name="Cancel"]'); + expect(result.refMap.get('e1')).toBe('role=button[name="Submit" s]'); + expect(result.refMap.get('e2')).toBe('role=button[name="Cancel" s]'); }); it('detaches CDP session on success', async () => { @@ -764,7 +767,9 @@ describe('collectTrimmedA11ySnapshot', () => { const result = await collectTrimmedA11ySnapshot(page); - expect(result.refMap.get('e1')).toBe('role=button[name="Say \\"Hello\\""]'); + expect(result.refMap.get('e1')).toBe( + 'role=button[name="Say \\"Hello\\"" s]', + ); }); it('handles empty accessibility tree', async () => { @@ -938,10 +943,12 @@ describe('collectTrimmedA11ySnapshot', () => { role: 'button', name: 'Submit', }); - expect(result.refMap.has('e1')).toBe(true); - expect(result.refMap.has('e2')).toBe(true); - expect(result.refMap.has('e3')).toBe(true); - expect(result.refMap.has('e4')).toBe(true); + // Each collapsed member keeps its own ref. Without DOM selectors they + // share the role selector, so an action on one fails in strict mode. + expect([1, 2, 3, 4].map((i) => result.refMap.get(`e${i}`))).toStrictEqual( + [0, 1, 2, 3].map(() => 'role=button[name="maskicon" s]'), + ); + expect(result.refMap.get('e5')).toBe('role=button[name="Submit" s]'); }); it('does not collapse nodes with same role and name but different paths', async () => { @@ -992,10 +999,10 @@ describe('collectTrimmedA11ySnapshot', () => { const page = createMockPage({ a11ySnapshot: a11yTree, testIdBySelector: { - 'role=button[name="x"]': 'action-button', + 'role=button[name="x" s]': 'action-button', }, textBySelector: { - 'role=button[name="x"]': 'Click me', + 'role=button[name="x" s]': 'Click me', }, }); @@ -1011,7 +1018,7 @@ describe('collectTrimmedA11ySnapshot', () => { const page = createMockPage({ a11ySnapshot: a11yTree, textBySelector: { - 'role=button[name="maskicon"]': 'maskicon', + 'role=button[name="maskicon" s]': 'maskicon', }, }); @@ -1409,9 +1416,9 @@ describe('collectTrimmedA11ySnapshot', () => { const result = await collectTrimmedA11ySnapshot(page); expect(result.nodes).toHaveLength(3); - expect(result.refMap.get('e1')).toBe('role=button[name="Submit"]'); - expect(result.refMap.get('e2')).toBe('role=link[name="Home"]'); - expect(result.refMap.get('e3')).toBe('role=heading[name="Title"]'); + expect(result.refMap.get('e1')).toBe('role=button[name="Submit" s]'); + expect(result.refMap.get('e2')).toBe('role=link[name="Home" s]'); + expect(result.refMap.get('e3')).toBe('role=heading[name="Title" s]'); const resolveNodeCalls = mockCdpSession.send.mock.calls.filter( (call) => call[0] === 'DOM.resolveNode', @@ -1454,7 +1461,7 @@ describe('collectTrimmedA11ySnapshot', () => { const result = await collectTrimmedA11ySnapshot(page); expect(result.nodes).toHaveLength(2); - expect(result.refMap.get('e1')).toBe('role=button[name="OK"]'); + expect(result.refMap.get('e1')).toBe('role=button[name="OK" s]'); expect(result.refMap.get('e2')).toBe('[data-testid="menu-action"]'); const resolveNodeCalls = mockCdpSession.send.mock.calls.filter( @@ -1502,6 +1509,583 @@ describe('collectTrimmedA11ySnapshot', () => { }); }); +/** + * `waitForTarget` passes the time left until its deadline, which can be a + * few ms below the budget on a slow runner. + * + * @param budgetMs - The timeout given to `waitForTarget`. + * @returns An asymmetric matcher for a timeout in (budget - 100, budget]. + */ +function expectRemaining(budgetMs: number): number { + return expect.toSatisfy( + (value: number) => value <= budgetMs && value > budgetMs - 100, + ) as unknown as number; +} + +describe('exact ref selectors', () => { + /** + * Builds a flat CDP AX node list: one `main` root with the given children. + * + * @param children - The child nodes (role, name, optional backend id). + * @returns The AX nodes. + */ + function flatTree( + children: { role: string; name?: string; backendDOMNodeId?: number }[], + ): MockAXNode[] { + return [ + { + nodeId: 'root', + ignored: false, + role: { type: 'role', value: 'main' }, + childIds: children.map((_, index) => `c${index}`), + }, + ...children.map( + (child, index): MockAXNode => ({ + nodeId: `c${index}`, + ignored: false, + role: { type: 'role', value: child.role }, + ...(child.name === undefined + ? {} + : { name: { type: 'string', value: child.name } }), + parentId: 'root', + backendDOMNodeId: child.backendDOMNodeId ?? 1000 + index, + }), + ), + ]; + } + + it('keeps the plain role selector for duplicates without a DOM selector, so strict mode fails (AC1)', async () => { + const page = createMockPage({ + axNodes: flatTree([ + { role: 'button', name: 'Send' }, + { role: 'button', name: 'Send' }, + ]), + }); + + const result = await collectTrimmedA11ySnapshot(page); + + expect(result.nodes.map((node) => node.ref)).toStrictEqual(['e1', 'e2']); + // No nth index: AX order and Playwright order can differ. + expect(result.refMap.get('e1')).toBe('role=button[name="Send" s]'); + expect(result.refMap.get('e2')).toBe('role=button[name="Send" s]'); + }); + + it('resolves each duplicate ref to its own DOM selector (AC1)', async () => { + const page = createMockPage({ + axNodes: flatTree([ + { role: 'button', name: 'Send', backendDOMNodeId: 1 }, + { role: 'link', name: 'Send', backendDOMNodeId: 2 }, + { role: 'button', name: 'Send', backendDOMNodeId: 3 }, + ]), + domSelectorsByBackendId: { + 1: 'button:nth-of-type(1)', + 3: 'button:nth-of-type(2)', + }, + }); + const { refMap } = await collectTrimmedA11ySnapshot(page); + vi.mocked(page.locator).mockClear(); + + await resolveTarget(page, 'a11yRef', 'e1', refMap); + await resolveTarget(page, 'a11yRef', 'e3', refMap); + + expect(vi.mocked(page.locator).mock.calls).toStrictEqual([ + ['button:nth-of-type(1)'], + ['button:nth-of-type(2)'], + ]); + // Another role is another group: unique, no DOM lookup. + expect(refMap.get('e2')).toBe('role=link[name="Send" s]'); + + const locator = createMockLocator(); + const waitPage = { + locator: vi.fn().mockReturnValue(locator), + } as unknown as Page; + await waitForTarget(waitPage, 'a11yRef', 'e3', refMap, 5000); + expect(waitPage.locator).toHaveBeenCalledWith('button:nth-of-type(2)'); + expect(locator.waitFor).toHaveBeenCalledWith({ + state: 'visible', + timeout: expectRemaining(5000), + }); + }); + + // Playwright's role= engine already matches a quoted name exactly and + // case-sensitively; this documents that refs rely on it ("Send" and "Send + // max" are different selectors, so neither needs a DOM selector or nth). + it('keeps different names in different selectors, without nth (AC2)', async () => { + const page = createMockPage({ + axNodes: flatTree([ + { role: 'button', name: 'Send' }, + { role: 'button', name: 'Send max' }, + ]), + }); + + const result = await collectTrimmedA11ySnapshot(page); + + expect(result.refMap.get('e1')).toBe('role=button[name="Send" s]'); + expect(result.refMap.get('e2')).toBe('role=button[name="Send max" s]'); + }); + + it('gives each duplicate a DOM selector when one can be built', async () => { + const page = createMockPage({ + axNodes: flatTree([ + { role: 'button', name: 'Send', backendDOMNodeId: 1 }, + { role: 'button', name: 'Send max', backendDOMNodeId: 2 }, + { role: 'button', name: 'Send', backendDOMNodeId: 3 }, + ]), + domSelectorsByBackendId: { + 1: '[data-testid="send-a"]', + 2: '[data-testid="send-max"]', + 3: '#send-b', + }, + }); + const { mockCdpSession } = page as MockPageWithCdpSession; + + const result = await collectTrimmedA11ySnapshot(page); + + expect(Object.fromEntries(result.refMap)).toStrictEqual({ + e1: '[data-testid="send-a"]', + e2: 'role=button[name="Send max" s]', + e3: '#send-b', + }); + // Only duplicates pay for a DOM lookup. + const resolved = mockCdpSession.send.mock.calls + .filter(([method]) => method === 'DOM.resolveNode') + .map(([, params]) => (params as { backendNodeId: number }).backendNodeId); + expect(resolved.sort()).toStrictEqual([1, 3]); + }); + + it('keeps the plain role selector for duplicates whose DOM selector cannot be built', async () => { + const page = createMockPage({ + axNodes: [ + ...flatTree([ + { role: 'button', name: 'OK', backendDOMNodeId: 1 }, + { role: 'button', name: 'OK', backendDOMNodeId: 2 }, + { role: 'button', name: 'OK', backendDOMNodeId: 3 }, + ]), + { + nodeId: 'no-backend', + ignored: false, + role: { type: 'role', value: 'button' }, + name: { type: 'string', value: 'OK' }, + parentId: 'root', + }, + ].map((node) => + node.nodeId === 'root' + ? { ...node, childIds: [...(node.childIds ?? []), 'no-backend'] } + : node, + ), + // 1: unique selector; 2: no unique selector (null); 3: resolve fails. + domSelectorsByBackendId: { 1: '#ok-1' }, + resolveNodeErrorIds: new Set([3]), + }); + + const result = await collectTrimmedA11ySnapshot(page); + + // No unique selector, resolve failure (e.g. navigation mid-snapshot), + // no backend node: the role selector matches the whole group, so an + // action fails in strict mode instead of clicking another member. + expect(Object.fromEntries(result.refMap)).toStrictEqual({ + e1: '#ok-1', + e2: 'role=button[name="OK" s]', + e3: 'role=button[name="OK" s]', + e4: 'role=button[name="OK" s]', + }); + }); + + it('marks a duplicate inside a closed shadow root unreachable and skips its enrichment', async () => { + const page = createMockPage({ + axNodes: flatTree([ + { role: 'button', name: 'Send', backendDOMNodeId: 1 }, + { role: 'button', name: 'Send', backendDOMNodeId: 2 }, + ]), + domSelectorsByBackendId: { 1: UNREACHABLE_REF_SELECTOR, 2: 'button' }, + }); + + const result = await collectTrimmedA11ySnapshot(page); + + expect(Object.fromEntries(result.refMap)).toStrictEqual({ + e1: UNREACHABLE_REF_SELECTOR, + e2: 'button', + }); + expect(page.locator).not.toHaveBeenCalledWith(UNREACHABLE_REF_SELECTOR); + }); + + it('gives duplicates of a scoped snapshot DOM selectors without the root prefix', async () => { + const page = createMockPage({ + queryAxTreeNodes: [ + { + nodeId: 'scoped-root', + ignored: false, + role: { type: 'role', value: 'generic' }, + childIds: ['ok-1', 'ok-2'], + backendDOMNodeId: 200, + }, + { + nodeId: 'ok-1', + ignored: false, + role: { type: 'role', value: 'button' }, + name: { type: 'string', value: 'OK' }, + parentId: 'scoped-root', + backendDOMNodeId: 201, + }, + { + nodeId: 'ok-2', + ignored: false, + role: { type: 'role', value: 'button' }, + name: { type: 'string', value: 'OK' }, + parentId: 'scoped-root', + backendDOMNodeId: 202, + }, + ], + selectorNodes: { '.modal': { nodeId: 7, backendNodeId: 200 } }, + domSelectorsByBackendId: { 201: '#ok-1' }, + }); + + const result = await collectTrimmedA11ySnapshot(page, '.modal'); + + expect(Object.fromEntries(result.refMap)).toStrictEqual({ + e1: '#ok-1', + e2: 'css=.modal >> nth=0 >> role=button[name="OK" s]', + }); + }); + + it('runs DOM selector lookups concurrently, at most 16 at a time', async () => { + const count = 40; + const page = createMockPage({ + axNodes: flatTree( + Array.from({ length: count }, (_, index) => ({ + role: 'button', + name: 'Send', + backendDOMNodeId: index + 1, + })), + ), + domSelectorsByBackendId: Object.fromEntries( + Array.from({ length: count }, (_, index) => [ + index + 1, + `#send-${index + 1}`, + ]), + ), + }); + const { mockCdpSession } = page as MockPageWithCdpSession; + const send = mockCdpSession.send.getMockImplementation() as ( + method: string, + params?: Record, + ) => Promise; + let inFlight = 0; + let maxInFlight = 0; + mockCdpSession.send.mockImplementation( + async (method: string, params?: Record) => { + if (method !== 'DOM.resolveNode') { + return send(method, params); + } + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 1)); + inFlight -= 1; + return send(method, params); + }, + ); + + const result = await collectTrimmedA11ySnapshot(page); + + expect(maxInFlight).toBe(16); + expect(result.refMap.size).toBe(count); + expect(result.refMap.get('e1')).toBe('#send-1'); + expect(result.refMap.get(`e${count}`)).toBe(`#send-${count}`); + }); + + it('escapes backslashes and quotes and normalizes whitespace in names', async () => { + const page = createMockPage({ + axNodes: flatTree([ + { role: 'button', name: 'Say "hi"' }, + { role: 'button', name: 'C:\\path\\' }, + { role: 'button', name: ' Line 1\n\tLine 2 ' }, + { role: 'button', name: 'Line 1 Line 2' }, + { role: 'link', name: 'a >> b' }, + ]), + }); + + const result = await collectTrimmedA11ySnapshot(page); + + expect(result.refMap.get('e1')).toBe('role=button[name="Say \\"hi\\"" s]'); + expect(result.refMap.get('e2')).toBe( + 'role=button[name="C:\\\\path\\\\" s]', + ); + // Same normalized name → same elements in Playwright → one selector. + expect(result.refMap.get('e3')).toBe('role=button[name="Line 1 Line 2" s]'); + expect(result.refMap.get('e4')).toBe('role=button[name="Line 1 Line 2" s]'); + expect(result.refMap.get('e5')).toBe('role=link[name="a >> b" s]'); + }); + + it('matches only an empty accessible name for unnamed nodes', async () => { + const page = createMockPage({ + axNodes: flatTree([ + { role: 'button' }, + { role: 'button', name: '' }, + { role: 'button', name: 'Close' }, + ]), + }); + + const result = await collectTrimmedA11ySnapshot(page); + + expect(result.refMap.get('e1')).toBe('role=button[name="" s]'); + expect(result.refMap.get('e2')).toBe('role=button[name="" s]'); + expect(result.refMap.get('e3')).toBe('role=button[name="Close" s]'); + }); + + it('keeps identical text= fallback selectors strict, without nth', async () => { + const page = createMockPage({ + axNodes: flatTree([ + { role: 'generic', name: 'Account 1', backendDOMNodeId: 1 }, + { role: 'generic', name: 'Account 1', backendDOMNodeId: 2 }, + { role: 'generic', name: 'Say "hi" \\o/', backendDOMNodeId: 3 }, + { role: 'generic', name: 'Account 1', backendDOMNodeId: 4 }, + ]), + domSelectorsByBackendId: { 4: '[data-testid="account-1"]' }, + }); + + const result = await collectTrimmedA11ySnapshot(page); + + // text= also matches wrappers of the text: an nth index could point at + // another element, so an ambiguous text ref fails in strict mode. + expect(result.refMap.get('e1')).toBe('text="Account 1"'); + expect(result.refMap.get('e2')).toBe('text="Account 1"'); + expect(result.refMap.get('e3')).toBe('text="Say \\"hi\\" \\\\o/"'); + expect(result.refMap.get('e4')).toBe('[data-testid="account-1"]'); + }); + + it('prefixes role and text selectors of a scoped snapshot with the root', async () => { + const page = createMockPage({ + queryAxTreeNodes: [ + { + nodeId: 'scoped-root', + ignored: false, + role: { type: 'role', value: 'generic' }, + childIds: ['ok-1', 'ok-2', 'label', 'item'], + backendDOMNodeId: 200, + }, + ...flatTree([ + { role: 'button', name: 'OK' }, + { role: 'button', name: 'OK' }, + { role: 'generic', name: 'Fee', backendDOMNodeId: 1 }, + { role: 'generic', name: 'Row', backendDOMNodeId: 2 }, + ]) + .slice(1) + .map((node, index) => ({ + ...node, + nodeId: ['ok-1', 'ok-2', 'label', 'item'][index], + parentId: 'scoped-root', + })), + ], + selectorNodes: { ' .modal ': { nodeId: 7, backendNodeId: 200 } }, + domSelectorsByBackendId: { 2: '#row' }, + }); + + const result = await collectTrimmedA11ySnapshot(page, ' .modal '); + + expect(Object.fromEntries(result.refMap)).toStrictEqual({ + e1: 'css=.modal >> nth=0 >> role=button[name="OK" s]', + e2: 'css=.modal >> nth=0 >> role=button[name="OK" s]', + e3: 'css=.modal >> nth=0 >> text="Fee"', + e4: '#row', + }); + }); + + it('maps the scope root itself to the root selector', async () => { + const page = createMockPage({ + queryAxTreeNodes: [ + { + nodeId: 'dialog', + ignored: false, + role: { type: 'role', value: 'dialog' }, + name: { type: 'string', value: 'Confirm' }, + childIds: ['close'], + backendDOMNodeId: 70, + }, + { + nodeId: 'close', + ignored: false, + role: { type: 'role', value: 'button' }, + name: { type: 'string', value: 'Close' }, + parentId: 'dialog', + backendDOMNodeId: 71, + }, + ], + selectorNodes: { '[role="dialog"]': { nodeId: 7, backendNodeId: 70 } }, + }); + + const result = await collectTrimmedA11ySnapshot(page, '[role="dialog"]'); + + expect(result.refMap.get('e1')).toBe('css=[role="dialog"] >> nth=0'); + expect(result.refMap.get('e2')).toBe( + 'css=[role="dialog"] >> nth=0 >> role=button[name="Close" s]', + ); + }); + + it('keeps relatives of the root page-wide in the getPartialAXTree fallback', async () => { + const page = createMockPage({ + queryAxTreeError: new Error('queryAXTree unavailable'), + partialAxNodes: [ + { + nodeId: 'ancestor', + ignored: false, + role: { type: 'role', value: 'dialog' }, + name: { type: 'string', value: 'Wallet' }, + childIds: ['sibling', 'scoped-root'], + backendDOMNodeId: 60, + }, + { + nodeId: 'sibling', + ignored: false, + role: { type: 'role', value: 'button' }, + name: { type: 'string', value: 'OK' }, + parentId: 'ancestor', + backendDOMNodeId: 61, + }, + { + nodeId: 'scoped-root', + ignored: false, + role: { type: 'role', value: 'generic' }, + parentId: 'ancestor', + childIds: ['inner'], + backendDOMNodeId: 70, + }, + { + nodeId: 'inner', + ignored: false, + role: { type: 'role', value: 'button' }, + name: { type: 'string', value: 'OK' }, + parentId: 'scoped-root', + backendDOMNodeId: 71, + }, + ], + selectorNodes: { '.modal': { nodeId: 7, backendNodeId: 70 } }, + }); + + const result = await collectTrimmedA11ySnapshot(page, '.modal'); + + expect(Object.fromEntries(result.refMap)).toStrictEqual({ + e1: 'role=dialog[name="Wallet" s]', + e2: 'role=button[name="OK" s]', + e3: 'css=.modal >> nth=0 >> role=button[name="OK" s]', + }); + }); + + describe('Playwright selector parser round-trip', () => { + type AttributePart = { + name: string; + op: string; + value: unknown; + caseSensitive: boolean; + }; + type SelectorParser = { + parseSelector: (selector: string) => { + parts: { name: string; source: string }[]; + }; + parseAttributeSelector: ( + selector: string, + allowUnquotedStrings: boolean, + ) => { name: string; attributes: AttributePart[] }; + }; + + // playwright-core does not export its selector parser; load the + // internal file the injected script is built from (resolved through + // `playwright`). The path is not public API: skip these tests when a + // playwright-core version moves it. + const requireFromRoot = createRequire( + path.join(process.cwd(), 'package.json'), + ); + const requireFromPlaywright = createRequire( + requireFromRoot.resolve('playwright/package.json'), + ); + const parserPath = path.join( + path.dirname( + requireFromPlaywright.resolve('playwright-core/package.json'), + ), + 'lib/utils/isomorphic/selectorParser.js', + ); + let parser: SelectorParser | undefined; + try { + parser = requireFromPlaywright(parserPath) as SelectorParser; + } catch (error) { + if ((error as { code?: string }).code !== 'MODULE_NOT_FOUND') { + throw error; + } + } + const parserMissing = parser === undefined; + + it + .skipIf(parserMissing) + .each([ + 'Send', + 'Send max', + 'Say "hi"', + 'C:\\path\\', + '\\"', + 'a >> b', + 'Ünïcode ✓', + '', + ])( + 'parses the role selector for %j back to the exact name (internal playwright-core selectorParser)', + async (name) => { + const page = createMockPage({ + queryAxTreeNodes: flatTree([ + { role: 'button', name }, + { role: 'button', name }, + ]), + selectorNodes: { '.modal': { nodeId: 7, backendNodeId: 70 } }, + }); + + const { refMap } = await collectTrimmedA11ySnapshot(page, '.modal'); + const { parts } = (parser as SelectorParser).parseSelector( + refMap.get('e2') as string, + ); + + expect(parts.map((part) => part.name)).toStrictEqual([ + 'css', + 'nth', + 'role', + ]); + expect(parts[0].source).toBe('.modal'); + const role = (parser as SelectorParser).parseAttributeSelector( + parts[2].source, + true, + ); + expect(role.name).toBe('button'); + expect(role.attributes).toStrictEqual([ + expect.objectContaining({ + name: 'name', + op: '=', + value: name, + caseSensitive: true, + }), + ]); + }, + ); + + it.skipIf(parserMissing)( + 'keeps a text= selector with quotes, backslashes and >> in one part (internal playwright-core selectorParser)', + async () => { + const name = 'a >> "b" \\ c'; + const page = createMockPage({ + axNodes: flatTree([ + { role: 'generic', name }, + { role: 'generic', name }, + ]), + }); + + const { refMap } = await collectTrimmedA11ySnapshot(page); + const { parts } = (parser as SelectorParser).parseSelector( + refMap.get('e1') as string, + ); + + expect(parts.map((part) => [part.name, part.source])).toStrictEqual([ + ['text', '"a >> \\"b\\" \\\\ c"'], + ]); + }, + ); + }); +}); + describe('resolveTarget', () => { it('resolves a11yRef to selector from refMap', async () => { const refMap = new Map([['e1', 'role=button[name="Submit"]']]); @@ -1533,6 +2117,18 @@ describe('resolveTarget', () => { ).rejects.toThrowError('Available refs: e1, e2'); }); + it('throws for a ref inside a closed shadow root instead of matching another element', async () => { + const refMap = new Map([['e1', UNREACHABLE_REF_SELECTOR]]); + const page = createMockPage(); + + await expect( + resolveTarget(page, 'a11yRef', 'e1', refMap), + ).rejects.toThrowError( + 'Ref e1 is inside a closed shadow root: it cannot be targeted', + ); + expect(page.locator).not.toHaveBeenCalled(); + }); + it('resolves testId to data-testid selector', async () => { const page = createMockPage(); @@ -1563,7 +2159,7 @@ describe('waitForTarget', () => { expect(mockLocator.waitFor).toHaveBeenCalledWith({ state: 'visible', - timeout: 5000, + timeout: expectRemaining(5000), }); }); @@ -1631,13 +2227,195 @@ describe('waitForTarget', () => { expect(parentLocator.first).toHaveBeenCalled(); expect(firstParentLocator.waitFor).toHaveBeenCalledWith({ state: 'visible', - timeout: 5000, + timeout: expectRemaining(5000), }); expect(firstParentLocator.locator).toHaveBeenCalledWith( '[data-testid="end-accessory"]', ); expect(result).toBe(childLocator); }); + + /** + * Builds a page whose `e1` ref is the within parent and whose other refs + * resolve to `refLocator`. + * + * @param counts - Match counts of the page-wide ref locator and of the + * contained (`and`) locator. + * @param counts.ref - Page-wide matches of the ref. + * @param counts.contained - Matches of the ref inside the parent. + * @returns The page, its locators and the ref map. + */ + function createWithinRefPage(counts: { ref: number; contained: number }) { + const refMap = new Map([ + ['e1', '[data-testid="account-cell"]'], + ['e2', 'css=#list >> css=button:nth-of-type(2)'], + ]); + const contained = { + ...createMockLocator({ visible: true }), + count: vi.fn().mockResolvedValue(counts.contained), + }; + const descendants = { kind: 'descendants' }; + const firstParentLocator = { + waitFor: vi.fn().mockResolvedValue(undefined), + locator: vi.fn().mockReturnValue(descendants), + }; + const parentLocator = { + first: vi.fn().mockReturnValue(firstParentLocator), + }; + const firstRef = { waitFor: vi.fn().mockResolvedValue(undefined) }; + const refLocator = { + and: vi.fn().mockReturnValue(contained), + count: vi.fn().mockResolvedValue(counts.ref), + first: vi.fn().mockReturnValue(firstRef), + }; + const page = { + locator: vi.fn((selector: string) => + selector === refMap.get('e1') ? parentLocator : refLocator, + ), + } as unknown as Page; + return { + page, + refMap, + contained, + descendants, + firstParentLocator, + refLocator, + firstRef, + }; + } + + it('resolves an a11yRef page-wide and keeps it only inside the within parent', async () => { + const { + page, + refMap, + contained, + descendants, + firstParentLocator, + refLocator, + } = createWithinRefPage({ ref: 1, contained: 1 }); + + const result = await waitForTarget(page, 'a11yRef', 'e2', refMap, 5000, { + type: 'a11yRef', + value: 'e1', + }); + + // The ref selector is unique in the document, so it is not re-applied + // inside the parent. + expect(page.locator).toHaveBeenCalledWith( + 'css=#list >> css=button:nth-of-type(2)', + ); + expect(firstParentLocator.locator).toHaveBeenCalledWith('*'); + expect(refLocator.and).toHaveBeenCalledWith(descendants); + expect(contained.waitFor).toHaveBeenCalledWith({ + state: 'visible', + timeout: expectRemaining(5000), + }); + expect(result).toBe(contained); + }); + + it('fails at once when the ref is on the page but outside the within parent', async () => { + const { page, refMap, contained } = createWithinRefPage({ + ref: 1, + contained: 0, + }); + + await expect( + waitForTarget(page, 'a11yRef', 'e2', refMap, 5000, { + type: 'a11yRef', + value: 'e1', + }), + ).rejects.toThrowError('Ref e2 is not inside the within element'); + // Checked twice (the page can change between the two counts). + expect(contained.count).toHaveBeenCalledTimes(2); + expect(contained.waitFor).not.toHaveBeenCalled(); + }); + + it('waits instead of failing when the ref moves into the within parent between the checks', async () => { + const { page, refMap, contained } = createWithinRefPage({ + ref: 1, + contained: 0, + }); + contained.count.mockResolvedValueOnce(0).mockResolvedValueOnce(1); + + const result = await waitForTarget(page, 'a11yRef', 'e2', refMap, 5000, { + type: 'a11yRef', + value: 'e1', + }); + + expect(contained.count).toHaveBeenCalledTimes(2); + expect(contained.waitFor).toHaveBeenCalled(); + expect(result).toBe(contained); + }); + + it('waits for a ref that is not on the page yet instead of failing at once', async () => { + const { page, refMap, contained, refLocator, firstRef } = + createWithinRefPage({ ref: 0, contained: 0 }); + // Not on the page at the first check; one match once it is attached. + refLocator.count.mockResolvedValueOnce(0).mockResolvedValue(1); + + const result = await waitForTarget(page, 'a11yRef', 'e2', refMap, 5000, { + type: 'a11yRef', + value: 'e1', + }); + + // No containment verdict while it is not on the page: wait for it. + expect(contained.count).not.toHaveBeenCalled(); + expect(firstRef.waitFor).toHaveBeenCalledWith({ + state: 'attached', + timeout: expectRemaining(5000), + }); + expect(contained.waitFor).toHaveBeenCalled(); + expect(result).toBe(contained); + }); + + it('throws instead of narrowing an ambiguous ref to the match inside the within parent', async () => { + const { page, refMap, contained } = createWithinRefPage({ + ref: 9, + contained: 1, + }); + + await expect( + waitForTarget(page, 'a11yRef', 'e2', refMap, 5000, { + type: 'a11yRef', + value: 'e1', + }), + ).rejects.toThrowError( + 'Ref e2 is ambiguous (9 matches); take a new snapshot or target it by testId or selector', + ); + expect(contained.count).not.toHaveBeenCalled(); + expect(contained.waitFor).not.toHaveBeenCalled(); + }); + + it('throws when a ref that appears later is ambiguous', async () => { + const { page, refMap, contained, refLocator } = createWithinRefPage({ + ref: 0, + contained: 1, + }); + refLocator.count.mockResolvedValueOnce(0).mockResolvedValue(2); + + await expect( + waitForTarget(page, 'a11yRef', 'e2', refMap, 5000, { + type: 'a11yRef', + value: 'e1', + }), + ).rejects.toThrowError('Ref e2 is ambiguous (2 matches)'); + expect(contained.waitFor).not.toHaveBeenCalled(); + }); + + it('throws on timeout when no time is left after the within checks', async () => { + const { page, refMap } = createWithinRefPage({ ref: 0, contained: 0 }); + const now = vi.spyOn(Date, 'now'); + // deadline = 0 + 1000; parent check at 0; the ref check at 2000. + now.mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(2000); + + await expect( + waitForTarget(page, 'a11yRef', 'e2', refMap, 1000, { + type: 'a11yRef', + value: 'e1', + }), + ).rejects.toThrowError('Timeout 1000ms exceeded waiting for element'); + now.mockRestore(); + }); }); describe('parseAriaSnapshotYaml', () => { diff --git a/src/tools/utils/discovery.ts b/src/tools/utils/discovery.ts index 1b50a6e..08f5312 100644 --- a/src/tools/utils/discovery.ts +++ b/src/tools/utils/discovery.ts @@ -1,12 +1,17 @@ import type { Page, Locator, CDPSession } from '@playwright/test'; -import { callInIsolatedWorld, withCdpSession } from './cdp.js'; +import { + callInIsolatedWorld, + callOnNodeInIsolatedWorld, + withCdpSession, +} from './cdp.js'; import { TESTID_COLLECT_TIMEOUT_MS, TEXT_PREVIEW_MAX_LENGTH, } from './constants.js'; import { animationsSettled, + buildUniqueSelectorInPage, collectTestIdsInPage, waitUntilInPage, } from './in-page.js'; @@ -321,11 +326,41 @@ type CdpAXNode = { backendDOMNodeId?: number; }; -type RefTarget = { - selector: string; - backendDOMNodeId?: number; +/** + * A node of the snapshot that gets a ref, in AX tree order. + */ +type RefCandidate = { role: string; name: string; + backendDOMNodeId?: number; + /** + * False for nodes of a scoped snapshot that are outside the root subtree + * (relatives returned by the `getPartialAXTree` fallback). + */ + inScope: boolean; +}; + +/** + * RefMap value for an element inside a closed shadow root, which no + * Playwright selector can reach (see `buildUniqueSelectorInPage`). + * Resolving such a ref throws at once instead of matching another element. + */ +export const UNREACHABLE_REF_SELECTOR = 'mm-unreachable=closed-shadow-root'; + +/** + * Maximum number of DOM selector lookups (`buildSelectorFromBackendNode`) + * in flight at the same time during one snapshot. + */ +const DOM_SELECTOR_CONCURRENCY = 16; + +/** + * The element a scoped snapshot was taken from. + */ +type SnapshotScope = { + /** Playwright selector for the root element (`css= >> nth=0`). */ + selector: string; + /** Backend DOM node id of the root element. */ + backendNodeId: number; }; /** @@ -514,19 +549,20 @@ async function resolveRootBackendNodeId( * @param page The Playwright page to snapshot. * @param cdpSession The active CDP session. * @param rootSelector Optional CSS selector that scopes the snapshot. - * @returns The normalized raw accessibility tree. + * @returns The normalized raw accessibility tree, and the backend node id of + * the root element when the snapshot is scoped. */ async function collectRawAxTree( page: Page, cdpSession: CDPSession, rootSelector?: string, -): Promise { +): Promise<{ roots: RawA11yNode[]; rootBackendNodeId?: number }> { await cdpSession.send('Accessibility.enable'); try { if (!rootSelector) { const result = await cdpSession.send('Accessibility.getFullAXTree'); - return buildAxTree(result.nodes); + return { roots: buildAxTree(result.nodes) }; } const backendNodeId = await resolveRootBackendNodeId( @@ -535,23 +571,32 @@ async function collectRawAxTree( rootSelector, ); if (backendNodeId === null) { - return []; + return { roots: [] }; } try { const result = await cdpSession.send('Accessibility.queryAXTree', { backendNodeId, }); - return buildAxTree(result.nodes); + return { + roots: buildAxTree(result.nodes), + rootBackendNodeId: backendNodeId, + }; } catch (error) { debugWarn( `queryAXTree failed for selector="${rootSelector}", falling back to getPartialAXTree:`, error, ); + // getPartialAXTree also returns relatives of the root (ancestors, + // siblings). Their role selectors stay page-wide, so ambiguous matches + // fail in strict mode instead of being narrowed to the root subtree. const result = await cdpSession.send('Accessibility.getPartialAXTree', { backendNodeId, }); - return buildAxTree(result.nodes); + return { + roots: buildAxTree(result.nodes), + rootBackendNodeId: backendNodeId, + }; } } finally { await cdpSession.send('Accessibility.disable').catch(() => undefined); @@ -592,250 +637,339 @@ function shouldIncludeNode(node: RawA11yNode): boolean { /** * Builds the most stable Playwright selector available for a DOM-backed AX node. * - * @param cdpSession The active CDP session. + * Runs `buildUniqueSelectorInPage` on the node in an isolated world of the + * page (`callOnNodeInIsolatedWorld`): the Extension's main world scuttles + * globals with LavaMoat, so page code must not run there. A unique + * `data-testid`, else a unique `id`, else the shortest unique `nth-of-type` + * path, checked the way Playwright's CSS engine matches (open shadow roots + * included). Elements inside open shadow roots get a chained selector + * (`css= >> css=`), and elements inside a closed shadow root + * get `UNREACHABLE_REF_SELECTOR`. There is no selector (`undefined`) when + * no candidate is unique, and for detached elements. + * + * @param page The Playwright page. * @param backendDOMNodeId The backend DOM node id for the target element. * @returns A Playwright-compatible selector when one can be synthesized. */ async function buildSelectorFromBackendNode( - cdpSession: CDPSession, + page: Page, backendDOMNodeId: number, ): Promise { - const resolvedNode = await cdpSession.send('DOM.resolveNode', { - backendNodeId: backendDOMNodeId, - objectGroup: `mm-discovery-${backendDOMNodeId}`, - }); - const { objectId } = resolvedNode.object; - - if (!objectId) { - return undefined; - } - - try { - const selectorResult = await cdpSession.send('Runtime.callFunctionOn', { - objectId, - returnByValue: true, - functionDeclaration: `function() { - const escapeCss = (value) => { - if (globalThis.CSS && typeof globalThis.CSS.escape === 'function') { - return globalThis.CSS.escape(String(value)); - } - - return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\\\$&'); - }; - - if (!(this instanceof Element)) { - return null; - } - - const testId = this.getAttribute('data-testid'); - if (testId) { - const candidate = '[data-testid="' + escapeCss(testId) + '"]'; - if (document.querySelectorAll(candidate).length === 1) { - return candidate; - } - } - - if (this.id) { - const candidate = '#' + escapeCss(this.id); - if (document.querySelectorAll(candidate).length === 1) { - return candidate; - } - } - - const parts = []; - let current = this; - - while (current && current instanceof Element) { - let selector = current.localName; - if (!selector) { - return null; - } - - if (current.parentElement) { - const siblings = Array.from(current.parentElement.children).filter( - (sibling) => sibling.localName === current.localName, - ); - - if (siblings.length > 1) { - selector += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')'; - } - } - - parts.unshift(selector); - const joined = parts.join(' > '); - - try { - if (document.querySelectorAll(joined).length === 1) { - return joined; - } - } catch { - return null; - } - - current = current.parentElement; - } + const selector = await callOnNodeInIsolatedWorld( + page, + backendDOMNodeId, + buildUniqueSelectorInPage.toString(), + ); + return typeof selector === 'string' ? selector : undefined; +} - return parts.join(' > '); - }`, - }); +/** + * Runs an async function over items with at most `limit` calls in flight. + * + * @param items The items. + * @param limit The maximum number of concurrent calls. + * @param fn The function to run for each item. + * @returns The results, in the order of `items`. + */ +async function mapWithConcurrency( + items: TItem[], + limit: number, + fn: (item: TItem) => Promise, +): Promise { + const results: TResult[] = new Array(items.length); + let next = 0; + const worker = async (): Promise => { + while (next < items.length) { + const index = next; + next += 1; + results[index] = await fn(items[index]); + } + }; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, worker), + ); + return results; +} - return typeof selectorResult.result.value === 'string' - ? selectorResult.result.value - : undefined; - } finally { - await cdpSession - .send('Runtime.releaseObjectGroup', { - objectGroup: `mm-discovery-${backendDOMNodeId}`, - }) - .catch(() => undefined); - } +/** + * Builds DOM selectors (see `buildSelectorFromBackendNode`) for backend DOM + * node ids, at most `DOM_SELECTOR_CONCURRENCY` at a time. A failed lookup + * is logged and gives no selector. + * + * @param page The Playwright page. + * @param backendNodeIds The backend DOM node ids. + * @returns The selector of each id that has one. + */ +async function buildDomSelectors( + page: Page, + backendNodeIds: number[], +): Promise> { + const selectors = await mapWithConcurrency( + backendNodeIds, + DOM_SELECTOR_CONCURRENCY, + async (backendNodeId) => { + try { + return await buildSelectorFromBackendNode(page, backendNodeId); + } catch (error) { + debugWarn( + `Failed to resolve DOM selector for backendDOMNodeId=${backendNodeId}:`, + error, + ); + return undefined; + } + }, + ); + const result = new Map(); + backendNodeIds.forEach((backendNodeId, index) => { + const selector = selectors[index]; + if (selector) { + result.set(backendNodeId, selector); + } + }); + return result; } /** - * Builds the selector target that backs a trimmed accessibility ref. + * Builds the selector of every ref of a snapshot, so that each ref matches + * exactly one element or, when that cannot be guaranteed, makes the action + * fail in Playwright's strict mode instead of hitting another element. * - * Standard included roles (button, link, heading, etc.) use a cheap ARIA - * selector built from the role and name — no CDP round-trips required. - * Only generic or other non-standard nodes pay the cost of DOM selector - * resolution, since they cannot produce a meaningful ARIA selector. + * - Included roles (button, link, heading, ...) use + * `role=[name="" s]` (exact, case-sensitive name), which needs + * no CDP round-trip. It matches every element with that role and name, so + * when several refs share it, each of them gets a DOM selector from + * `buildSelectorFromBackendNode` (verified unique the way Playwright's + * CSS engine matches). A member without one (no backend node, resolve + * failure, no unique path) keeps the plain role selector, so an action on + * it fails in Playwright's strict mode. A member inside a closed shadow + * root gets `UNREACHABLE_REF_SELECTOR`: Playwright cannot see it, so its + * role selector could match exactly one other member. There is + * deliberately no `>> nth=` fallback: Chromium's AX tree and + * Playwright's role engine do not always agree on the members of a group + * and their order (a modal `` or `inert` removes the blocked + * elements from the AX tree while Playwright still counts them; Playwright + * visits shadow roots after the light DOM while the AX tree uses flat-tree + * order, slotted content included; aria-owns moves owned elements in the + * AX tree only), so an index could act on another element. + * - Generic nodes use a DOM selector, else `text=""`. `text=` also + * matches wrappers of the text and elements that are not refs of this + * snapshot, so an action on an ambiguous text ref fails in strict mode. * - * @param cdpSession The active CDP session. - * @param node The normalized accessibility node. - * @param role The normalized role for the node. - * @param name The accessible name for the node. - * @returns The selector target used to populate the ref map. + * DOM lookups run concurrently, at most `DOM_SELECTOR_CONCURRENCY` at a + * time. + * + * Scoped snapshots: the AX tree only holds the subtree of the root, so role + * and text selectors get the root as a prefix + * (`css= >> nth=0 >> role=...`). DOM selectors are unique in the + * document and get no prefix. Playwright's + * role engine does not match the scope element itself, so the root node + * gets the root selector. With the `getPartialAXTree` fallback, the tree + * also holds relatives of the root (ancestors, siblings). Their role + * selectors stay page-wide, without DOM lookup, so an action on such a ref + * fails in strict mode when it matches more than one element. + * + * @param page The Playwright page. + * @param candidates The nodes that can get a ref, in AX tree order. + * @param scope The root of a scoped snapshot. + * @returns The selector of each candidate (same order), or null when the + * candidate has no selector and gets no ref. */ -async function buildRefTarget( - cdpSession: CDPSession, - node: RawA11yNode, - role: string, - name: string, -): Promise { - if (INCLUDED_ROLES_SET.has(role)) { - return { - selector: buildA11ySelector(role as IncludedRole, name), - backendDOMNodeId: node.backendDOMNodeId, - role, - name, - }; - } - - let domSelector: string | undefined; +async function buildRefSelectors( + page: Page, + candidates: RefCandidate[], + scope?: SnapshotScope, +): Promise<(string | null)[]> { + const scoped = (selector: string, inScope: boolean): string => + scope && inScope ? `${scope.selector} >> ${selector}` : selector; + const isScopeRoot = (candidate: RefCandidate): boolean => + scope !== undefined && candidate.backendDOMNodeId === scope.backendNodeId; + + // Role selectors, and how many in-scope refs share each one. + const roleSelectors = candidates.map((candidate) => + INCLUDED_ROLES_SET.has(candidate.role) && !isScopeRoot(candidate) + ? scoped( + buildA11ySelector(candidate.role as IncludedRole, candidate.name), + candidate.inScope, + ) + : undefined, + ); + const groupSizes = new Map(); + candidates.forEach((candidate, index) => { + const selector = roleSelectors[index]; + if (selector !== undefined && candidate.inScope) { + groupSizes.set(selector, (groupSizes.get(selector) ?? 0) + 1); + } + }); + const isDuplicate = (index: number): boolean => { + const selector = roleSelectors[index]; + return ( + selector !== undefined && + candidates[index].inScope && + (groupSizes.get(selector) ?? 0) > 1 + ); + }; - if (node.backendDOMNodeId !== null && node.backendDOMNodeId !== undefined) { - try { - domSelector = await buildSelectorFromBackendNode( - cdpSession, - node.backendDOMNodeId, - ); - } catch (error) { - debugWarn( - `Failed to resolve DOM selector for backendDOMNodeId=${node.backendDOMNodeId} (role=${role}, name=${name}):`, - error, - ); + // DOM lookups: generic nodes, and refs that share a role selector. + const lookupIds = new Set(); + candidates.forEach(({ role, backendDOMNodeId }, index) => { + if ( + backendDOMNodeId !== undefined && + backendDOMNodeId !== null && + (!INCLUDED_ROLES_SET.has(role) || isDuplicate(index)) + ) { + lookupIds.add(backendDOMNodeId); } - } + }); + const domSelectors = await buildDomSelectors(page, [...lookupIds]); - const selector = - domSelector ?? (name ? `text=${JSON.stringify(name)}` : undefined); + return candidates.map((candidate, index) => { + const roleSelector = roleSelectors[index]; + if (roleSelector !== undefined && !isDuplicate(index)) { + return roleSelector; + } - if (!domSelector && selector) { + const domSelector = + candidate.backendDOMNodeId === undefined + ? undefined + : domSelectors.get(candidate.backendDOMNodeId); + if (domSelector) { + return domSelector; + } + if (roleSelector !== undefined) { + // Duplicate without a DOM selector: strict mode makes an action on + // it fail instead of acting on another member of the group. + return roleSelector; + } + if (scope && isScopeRoot(candidate)) { + return scope.selector; + } + if (!candidate.name) { + return null; + } debugWarn( - `Using text= fallback selector for generic node (role=${role}, name=${name}). ` + + `Using text= fallback selector for generic node (role=${candidate.role}, name=${candidate.name}). ` + `DOM resolution failed; targeting by visible text is less stable.`, undefined, ); - } - - if (!selector) { - return null; - } - - return { - selector, - backendDOMNodeId: node.backendDOMNodeId, - role, - name, - }; + return scoped(buildTextSelector(candidate.name), candidate.inScope); + }); } /** * Trims a normalized AX tree into the public accessibility snapshot shape. * - * @param cdpSession The active CDP session. + * @param page The Playwright page. * @param parsedRoots The normalized raw accessibility tree roots. + * @param scope The root of a scoped snapshot. * @returns The trimmed nodes and their backing ref map. */ async function trimAxTreeToA11yNodes( - cdpSession: CDPSession, + page: Page, parsedRoots: RawA11yNode[], + scope?: SnapshotScope, ): Promise<{ nodes: A11yNodeTrimmed[]; refMap: Map; }> { - const trimmedNodes: A11yNodeTrimmed[] = []; - const refMap = new Map(); - let refCounter = 1; + const candidates: RefCandidate[] = []; + const candidateNodes: Omit[] = []; + + // queryAXTree returns only the root subtree, which may not hold the root + // node itself (when it is ignored). Every node is then in scope. When the + // root node is present, only it and its descendants are. + const rootInTree = + scope !== undefined && + containsBackendNode(parsedRoots, scope.backendNodeId); /** - * Walks the normalized tree and emits trimmed nodes with deterministic refs. + * Walks the normalized tree and collects the nodes that can get a ref. * * @param node The current node being visited. * @param ancestorPath The semantic ancestor path collected so far. - * @returns Resolves once the current subtree has been processed. + * @param insideRoot Whether an ancestor is the scope root. */ - async function traverseNode( + function traverseNode( node: RawA11yNode, ancestorPath: string[], - ): Promise { + insideRoot: boolean, + ): void { const role = node.role?.toLowerCase() ?? ''; const name = node.name ?? ''; const isPathNode = role === 'dialog' || role === 'heading'; const childPath = isPathNode ? [...ancestorPath, `${role}:${name}`] : ancestorPath; + const inScope = + !rootInTree || + insideRoot || + node.backendDOMNodeId === scope?.backendNodeId; if (shouldIncludeNode(node)) { - const refTarget = await buildRefTarget(cdpSession, node, role, name); - - if (refTarget) { - const ref = `e${refCounter}`; - refCounter += 1; - - const trimmedNode: A11yNodeTrimmed = { - ref, - role, - name, - path: isPathNode ? childPath : ancestorPath, - }; - - if (node.disabled !== undefined) { - trimmedNode.disabled = node.disabled; - } - if (node.checked !== undefined) { - trimmedNode.checked = node.checked === true; - } - if (node.expanded !== undefined) { - trimmedNode.expanded = node.expanded; - } - - trimmedNodes.push(trimmedNode); - refMap.set(ref, refTarget.selector); + const trimmedNode: Omit = { + role, + name, + path: isPathNode ? childPath : ancestorPath, + }; + + if (node.disabled !== undefined) { + trimmedNode.disabled = node.disabled; + } + if (node.checked !== undefined) { + trimmedNode.checked = node.checked === true; + } + if (node.expanded !== undefined) { + trimmedNode.expanded = node.expanded; } + + candidateNodes.push(trimmedNode); + candidates.push({ + role, + name, + backendDOMNodeId: node.backendDOMNodeId, + inScope, + }); } - if (node.children) { - for (const child of node.children) { - await traverseNode(child, childPath); - } + for (const child of node.children ?? []) { + traverseNode(child, childPath, inScope); } } for (const root of parsedRoots) { - await traverseNode(root, []); + traverseNode(root, [], false); } - return { nodes: trimmedNodes, refMap }; + const selectors = await buildRefSelectors(page, candidates, scope); + const nodes: A11yNodeTrimmed[] = []; + const refMap = new Map(); + selectors.forEach((selector, index) => { + if (selector === null) { + return; + } + const ref = `e${nodes.length + 1}`; + nodes.push({ ref, ...candidateNodes[index] }); + refMap.set(ref, selector); + }); + + return { nodes, refMap }; +} + +/** + * Checks whether a tree holds the node of a backend DOM node id. + * + * @param nodes The tree roots. + * @param backendNodeId The backend DOM node id. + * @returns True when a node of the tree has that id. + */ +function containsBackendNode( + nodes: RawA11yNode[], + backendNodeId: number, +): boolean { + return nodes.some( + (node) => + node.backendDOMNodeId === backendNodeId || + containsBackendNode(node.children ?? [], backendNodeId), + ); } /** @@ -853,15 +987,28 @@ export async function collectTrimmedA11ySnapshot( refMap: Map; }> { return withCdpSession(page, async (cdpSession) => { - const parsedRoots = await collectRawAxTree(page, cdpSession, rootSelector); + const { roots: parsedRoots, rootBackendNodeId } = await collectRawAxTree( + page, + cdpSession, + rootSelector, + ); if (parsedRoots.length === 0) { return { nodes: [], refMap: new Map() }; } + const scope = + rootSelector !== undefined && rootBackendNodeId !== undefined + ? { + selector: buildScopeSelector(rootSelector), + backendNodeId: rootBackendNodeId, + } + : undefined; + const { nodes, refMap } = await trimAxTreeToA11yNodes( - cdpSession, + page, parsedRoots, + scope, ); await enrichNodesWithDOMContext(page, nodes, refMap); @@ -913,7 +1060,7 @@ async function enrichNodesWithDOMContext( const results = await Promise.allSettled( enrichBatch.map(async (node): Promise => { const selector = refMap.get(node.ref); - if (!selector) { + if (!selector || selector === UNREACHABLE_REF_SELECTOR) { return { ref: node.ref, testId: null, textContent: null }; } try { @@ -1019,16 +1166,82 @@ function collapseIdenticalRuns(nodes: A11yNodeTrimmed[]): A11yNodeTrimmed[] { return collapsed; } +/** + * Normalizes whitespace the way Playwright does before it compares names and + * text (`normalizeWhiteSpace`): drops zero-width spaces and soft hyphens, + * trims, and collapses whitespace runs (newlines included) to one space. + * Two names with the same normalized form match the same elements, so they + * must produce the same selector for duplicates to be detected. + * + * @param value The name or text. + * @returns The normalized string. + */ +function normalizeSelectorWhitespace(value: string): string { + return value + .replace(/[\u200b\u00ad]/gu, '') + .trim() + .replace(/\s+/gu, ' '); +} + +/** + * Quotes a string for a Playwright selector. + * + * Playwright reads quoted strings in attribute selectors + * (`parseAttributeSelector`) and in `text=` selectors (`cssUnquote`) the + * same way: a backslash escapes the next character, so only `\` and `"` + * need escaping. `>>` inside the quotes does not split the selector. The + * value is whitespace-normalized first (see `normalizeSelectorWhitespace`). + * + * @param value The string to quote. + * @returns The quoted string. + */ +function quoteSelectorString(value: string): string { + const escaped = normalizeSelectorWhitespace(value) + .replace(/\\/gu, '\\\\') + .replace(/"/gu, '\\"'); + return `"${escaped}"`; +} + /** * Build an accessibility selector string from role and name. * + * Playwright's `role=` engine matches a quoted name exactly and + * case-sensitively (the full accessible name after whitespace + * normalization, no substring), so "Send" does not match "Send max". Only + * `getByRole` / `internal:role` without `exact` match substrings or ignore + * case. The `s` flag repeats the case-sensitive default to make it + * explicit. An empty name matches only elements with an empty accessible + * name. The selector still matches every element with that role and name: + * `buildRefSelectors` gives duplicates a unique selector. + * * @param role The ARIA role for the selector * @param name The accessible name to match - * @returns Formatted selector string (e.g., "role=button[name=\"Click me\"]") + * @returns Formatted selector string (e.g., `role=button[name="Click me" s]`) */ function buildA11ySelector(role: IncludedRole, name: string): string { - const escapedName = name.replace(/"/gu, '\\"'); - return `role=${role}[name="${escapedName}"]`; + return `role=${role}[name=${quoteSelectorString(name)} s]`; +} + +/** + * Build a text selector that matches elements whose own text is exactly + * `text` (case-sensitive, after whitespace normalization). + * + * @param text The text to match. + * @returns Formatted selector string (e.g., `text="Rename account"`). + */ +function buildTextSelector(text: string): string { + return `text=${quoteSelectorString(text)}`; +} + +/** + * Build the Playwright selector for the root of a scoped snapshot: the + * first element that matches the CSS selector, as `DOM.querySelector` does. + * + * @param rootSelector The CSS selector of the snapshot root. + * @returns Formatted selector string (e.g., `css=.modal >> nth=0`). + */ +function buildScopeSelector(rootSelector: string): string { + return `css=${rootSelector.trim()} >> nth=0`; } /** @@ -1068,6 +1281,11 @@ function resolveTargetScoped( `Available refs: ${Array.from(refMap.keys()).join(', ')}`, ); } + if (selector === UNREACHABLE_REF_SELECTOR) { + throw new Error( + `Ref ${targetValue} is inside a closed shadow root: it cannot be targeted`, + ); + } return scope.locator(selector); } case 'testId': @@ -1099,6 +1317,11 @@ export async function resolveTarget( return resolveTargetScoped(page, targetType, targetValue, refMap); } +/** + * Delay between the two checks that a `within` ref is outside the parent. + */ +const WITHIN_RECHECK_DELAY_MS = 100; + /** * Wait for a target element to become visible, optionally scoped within a parent. * @@ -1141,7 +1364,63 @@ export async function waitForTarget( // (e.g. 63 "end-accessory" buttons across 63 account cells). scope = parentLocator.first(); } - const locator = resolveTargetScoped(scope, targetType, targetValue, refMap); + let locator: Locator; + if (within && targetType === 'a11yRef') { + // A ref selector resolves to one element page-wide (DOM selectors are + // unique in the document; scope prefixes count from the document, not + // from the parent). Resolve it page-wide and keep it only if it is + // inside the parent: `within` only checks containment. + const refLocator = resolveTargetScoped( + page, + targetType, + targetValue, + refMap, + ); + const contained = refLocator.and(scope.locator('*')); + // `within` must never pick among the matches of an ambiguous ref (a + // duplicate without a unique selector): with several page-wide matches, + // `.and(parent)` could narrow them to one wrong element. + const assertUnambiguous = async (): Promise => { + const count = await refLocator.count(); + if (count > 1) { + throw new Error( + `Ref ${targetValue} is ambiguous (${count} matches); take a new snapshot or target it by testId or selector`, + ); + } + return count; + }; + // Fail fast when the ref is on the page but outside the parent, instead + // of waiting for the whole timeout. The two counts are separate round + // trips and the page can change in between (an element moving into the + // parent), so only fail when a second check, after a short delay, sees + // the same. + const isOutside = async (): Promise => + (await assertUnambiguous()) === 1 && (await contained.count()) === 0; + if (await isOutside()) { + const delay = Math.min( + WITHIN_RECHECK_DELAY_MS, + Math.max(0, deadline - Date.now()), + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + if (await isOutside()) { + throw new Error(`Ref ${targetValue} is not inside the within element`); + } + } + const attachRemaining = deadline - Date.now(); + if (attachRemaining <= 0) { + throw new Error(`Timeout ${timeoutMs}ms exceeded waiting for element`); + } + // Not on the page yet: wait until it is (`first()`: no strict-mode + // error while it is ambiguous), then check again that it is one + // element. Containment is enforced by waiting on `contained` below. + await refLocator + .first() + .waitFor({ state: 'attached', timeout: attachRemaining }); + await assertUnambiguous(); + locator = contained; + } else { + locator = resolveTargetScoped(scope, targetType, targetValue, refMap); + } const childRemaining = deadline - Date.now(); if (childRemaining <= 0) { throw new Error(`Timeout ${timeoutMs}ms exceeded waiting for element`); diff --git a/src/tools/utils/in-page.test.ts b/src/tools/utils/in-page.test.ts index 85c3441..985b2f2 100644 --- a/src/tools/utils/in-page.test.ts +++ b/src/tools/utils/in-page.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect } from 'vitest'; import * as inPage from './in-page.js'; import { animationsSettled, + buildUniqueSelectorInPage, collectTestIdsInPage, waitUntilInPage, } from './in-page.js'; @@ -731,6 +732,7 @@ describe('in-page function source', () => { it('exports the in-page functions', () => { expect(inPageFunctions.map(([name]) => name).sort()).toStrictEqual([ 'animationsSettled', + 'buildUniqueSelectorInPage', 'collectTestIdsInPage', 'waitUntilInPage', ]); @@ -750,3 +752,505 @@ describe('in-page function source', () => { }, ); }); + +type DomParent = DomDocument | DomShadowRoot | DomElement; + +type DomElement = { + nodeType: 1; + localName: string; + id: string; + testId?: string; + parentNode: DomParent | null; + parentElement: DomElement | null; + children: DomElement[]; + shadowRoot: DomShadowRoot | null; + getAttribute: (name: string) => string | null; + matches: (compound: string) => boolean; + getRootNode: () => DomParent; + querySelectorAll: (compound: string) => DomElement[]; +}; + +type DomShadowRoot = { + nodeType: 11; + mode: 'open' | 'closed'; + host: DomElement; + parentNode: null; + children: DomElement[]; + querySelectorAll: (compound: string) => DomElement[]; +}; + +type DomDocument = { + nodeType: 9; + parentNode: null; + children: DomElement[]; + querySelectorAll: (compound: string) => DomElement[]; +}; + +type DomSpec = { + tag: string; + id?: string; + testId?: string; + children?: DomSpec[]; + shadow?: { mode: 'open' | 'closed'; children: DomSpec[] }; +}; + +// An identifier: escapes (`\31 `, `\:`), or any char but the delimiters. +const IDENT = String.raw`(?:\\[0-9a-fA-F]{1,6} ?|\\[^0-9a-fA-F]|[^\\#.:\[\]"\s>])+`; +const COMPOUND_RE = new RegExp( + String.raw`^(?[a-z][a-z0-9-]*)?(?:#(?${IDENT}))?(?:\[data-testid="(?${IDENT})"\])?(?::nth-(?of-type|child)\((?\d+)\))?$`, + 'u', +); + +/** + * Undoes CSS identifier escapes (the inverse of `CSS.escape`). + * + * @param value - The escaped identifier. + * @returns The identifier. + */ +function cssUnescape(value: string): string { + return value.replace( + /\\(?:(?[0-9a-fA-F]{1,6}) ?|(?[^0-9a-fA-F]))/gu, + (...args) => { + const { code, char } = args[args.length - 1] as { + code?: string; + char?: string; + }; + return code === undefined + ? (char as string) + : String.fromCodePoint(parseInt(code, 16)); + }, + ); +} + +/** + * Light descendants of a node, in DOM order (shadow roots not entered). + * + * @param node - The root. + * @param node.children - Its child elements. + * @returns The descendants. + */ +function lightDescendants(node: { children: DomElement[] }): DomElement[] { + return node.children.flatMap((child) => [child, ...lightDescendants(child)]); +} + +/** + * querySelectorAll over light descendants for one compound selector + * (`*`, tag, `#id`, `[data-testid="x"]`, `:nth-of-type(n)`). + * + * @param node - The root. + * @param node.children - Its child elements. + * @param compound - The compound selector. + * @returns The matches. + */ +function queryCompound( + node: { children: DomElement[] }, + compound: string, +): DomElement[] { + return lightDescendants(node).filter( + (item) => compound === '*' || item.matches(compound), + ); +} + +/** + * Builds a fake document (with an `html` > `body` root) for + * buildUniqueSelectorInPage, with element lookup by `name` (id, testId or + * the `key` of the spec path). + * + * @param body - The children of `body`. + * @returns The document and every element by id/testId. + */ +function buildDom(body: DomSpec[]): { + document: DomDocument; + byKey: Map; +} { + const byKey = new Map(); + const make = (spec: DomSpec, parent: DomParent): DomElement => { + const item: DomElement = { + nodeType: 1, + localName: spec.tag, + id: spec.id ?? '', + testId: spec.testId, + parentNode: parent, + parentElement: parent.nodeType === 1 ? parent : null, + children: [], + shadowRoot: null, + getAttribute: (name) => + name === 'data-testid' ? (item.testId ?? null) : null, + matches: (compound) => { + const match = COMPOUND_RE.exec(compound); + if (!match?.groups) { + throw new Error(`Unsupported selector ${compound}`); + } + const { tag, id, testId, nthKind, nth } = match.groups; + if (tag && tag !== item.localName) { + return false; + } + if (id && cssUnescape(id) !== item.id) { + return false; + } + if (testId && cssUnescape(testId) !== item.testId) { + return false; + } + if (nth) { + const siblings = (item.parentNode as DomParent).children.filter( + (sibling) => + nthKind === 'child' || sibling.localName === item.localName, + ); + return siblings.indexOf(item) + 1 === Number(nth); + } + return true; + }, + getRootNode: () => { + let node: DomParent = item; + while (node.nodeType === 1 && node.parentNode) { + node = node.parentNode; + } + return node; + }, + querySelectorAll: (compound) => queryCompound(item, compound), + }; + item.children = (spec.children ?? []).map((child) => make(child, item)); + if (spec.shadow) { + const shadowRoot: DomShadowRoot = { + nodeType: 11, + mode: spec.shadow.mode, + host: item, + parentNode: null, + children: [], + querySelectorAll: (compound) => queryCompound(shadowRoot, compound), + }; + shadowRoot.children = spec.shadow.children.map((child) => + make(child, shadowRoot), + ); + // Closed roots are not exposed through `element.shadowRoot`. + item.shadowRoot = spec.shadow.mode === 'open' ? shadowRoot : null; + } + for (const key of [spec.id, spec.testId]) { + if (key) { + byKey.set(key, item); + } + } + return item; + }; + const document: DomDocument = { + nodeType: 9, + parentNode: null, + children: [], + querySelectorAll: (compound) => queryCompound(document, compound), + }; + document.children = [ + make( + { tag: 'html', children: [{ tag: 'body', children: body }] }, + document, + ), + ]; + return { document, byKey }; +} + +/** + * Runs buildUniqueSelectorInPage, compiled from its source, on an element. + * + * @param document - The fake document. + * @param target - The element (`this`). + * @param globals - Extra globals. + * @returns The selector. + */ +function uniqueSelector( + document: DomDocument, + target: unknown, + globals: Record = {}, +): string | null { + const fn = compileInPage<(this: unknown) => string | null>( + buildUniqueSelectorInPage, + { document, ...globals }, + ); + return fn.call(target); +} + +describe('buildUniqueSelectorInPage', () => { + it('prefers a unique data-testid, then a unique id', () => { + const { document, byKey } = buildDom([ + { tag: 'button', testId: 'send', id: 'send-id' }, + { tag: 'button', testId: 'dup', id: 'only' }, + { tag: 'button', testId: 'dup' }, + ]); + + expect(uniqueSelector(document, byKey.get('send'))).toBe( + '[data-testid="send"]', + ); + expect(uniqueSelector(document, byKey.get('only'))).toBe('#only'); + }); + + it('builds the shortest unique nth-of-type path', () => { + const { document } = buildDom([ + { tag: 'div', children: [{ tag: 'button' }, { tag: 'button' }] }, + { tag: 'div', children: [{ tag: 'span' }] }, + { tag: 'button' }, + ]); + const body = document.children[0].children[0]; + const [firstButton, secondButton] = body.children[0].children; + const span = body.children[1].children[0]; + + expect(uniqueSelector(document, secondButton)).toBe( + 'button:nth-of-type(2)', + ); + // `button:nth-of-type(1)` also matches the body's own button. + expect(uniqueSelector(document, firstButton)).toBe( + 'div:nth-of-type(1) > button:nth-of-type(1)', + ); + expect(uniqueSelector(document, span)).toBe('span'); + }); + + it('does not treat a document selector as unique when an open shadow root also matches it', () => { + const { document } = buildDom([ + { tag: 'button', testId: 'send' }, + { + tag: 'x-card', + shadow: { mode: 'open', children: [{ tag: 'button', testId: 'send' }] }, + }, + ]); + const lightButton = document.children[0].children[0].children[0]; + + // `[data-testid="send"]` and `button` also match inside the shadow root. + expect(uniqueSelector(document, lightButton)).toBe('body > button'); + }); + + it('chains the host selector and the path inside an open shadow root', () => { + const { document } = buildDom([ + { tag: 'button' }, + { + tag: 'x-card', + testId: 'card', + shadow: { + mode: 'open', + children: [{ tag: 'span', children: [{ tag: 'button' }] }], + }, + }, + { + tag: 'x-card', + shadow: { + mode: 'open', + children: [{ tag: 'span', children: [{ tag: 'button' }] }], + }, + }, + ]); + const body = document.children[0].children[0]; + const inCard = (index: number): DomElement => + (body.children[index].shadowRoot as DomShadowRoot).children[0] + .children[0]; + + expect(uniqueSelector(document, inCard(1))).toBe( + 'css=[data-testid="card"] >> css=button', + ); + expect(uniqueSelector(document, inCard(2))).toBe( + 'css=x-card:nth-of-type(2) >> css=button', + ); + }); + + it('recurses through nested open shadow roots', () => { + const { document, byKey } = buildDom([ + { + tag: 'div', + id: 'outer', + shadow: { + mode: 'open', + children: [ + { + tag: 'div', + shadow: { mode: 'open', children: [{ tag: 'button' }] }, + }, + { + tag: 'div', + shadow: { + mode: 'open', + children: [{ tag: 'button', testId: 'deep' }], + }, + }, + ], + }, + }, + ]); + + expect(uniqueSelector(document, byKey.get('deep'))).toBe( + 'css=#outer >> css=div:nth-of-type(2) >> css=[data-testid="deep"]', + ); + }); + + it('anchors to the host with :scope when the path inside the shadow root is not unique', () => { + const { document } = buildDom([ + { + tag: 'div', + id: 'host', + shadow: { + mode: 'open', + children: [ + { tag: 'button' }, + { + tag: 'div', + shadow: { mode: 'open', children: [{ tag: 'button' }] }, + }, + ], + }, + }, + ]); + const host = document.children[0].children[0].children[0]; + const topButton = (host.shadowRoot as DomShadowRoot).children[0]; + + // `button` alone also matches the button of the nested shadow root. + expect(uniqueSelector(document, topButton)).toBe( + 'css=#host >> css=:scope > button', + ); + }); + + it('returns the unreachable marker inside a closed shadow root, at any depth', () => { + const { document, byKey } = buildDom([ + { + tag: 'div', + id: 'host', + shadow: { + mode: 'closed', + children: [ + { tag: 'button', testId: 'x' }, + { + tag: 'div', + shadow: { + mode: 'open', + children: [{ tag: 'button', testId: 'y' }], + }, + }, + ], + }, + }, + ]); + + expect(uniqueSelector(document, byKey.get('x'))).toBe( + 'mm-unreachable=closed-shadow-root', + ); + expect(uniqueSelector(document, byKey.get('y'))).toBe( + 'mm-unreachable=closed-shadow-root', + ); + }); + + it('anchors the path at the nearest ancestor with a unique id or data-testid', () => { + const { document } = buildDom([ + { + tag: 'div', + children: [{ tag: 'span', children: [{ tag: 'button' }] }], + }, + { + tag: 'div', + id: 'h', + children: [{ tag: 'span', children: [{ tag: 'button' }] }], + }, + { tag: 'section', testId: 'card', children: [{ tag: 'button' }] }, + { tag: 'section', children: [{ tag: 'button' }] }, + ]); + const body = document.children[0].children[0]; + const inH = body.children[1].children[0].children[0]; + const inCard = body.children[2].children[0]; + + // `span > button` matches both spans' buttons; the id anchor is found + // before the nth-of-type walk reaches `div:nth-of-type(2)`. + expect(uniqueSelector(document, inH)).toBe('#h > span > button'); + expect(uniqueSelector(document, inCard)).toBe( + '[data-testid="card"] > button', + ); + }); + + it('tells a slotted light child and a shadow child of one host apart with nth-child', () => { + //