From f3cc0a7bc7bf612217f59b8bf26cb1c271f98f8b Mon Sep 17 00:00:00 2001 From: cryptotavares Date: Sat, 24 Oct 2026 11:09:51 +0100 Subject: [PATCH 1/2] perf: settle only on visible finite animations; testIds in one call Tickets P1-10 + P1-12. - Animation settle: wait only for running, finite animations whose target element is in the viewport (spinners, shimmer and other infinite or off-screen animations no longer hold the observation). Cap 1000 ms (was 3000 ms). Hidden pages poll with setTimeout (rAF does not fire). - collectTestIds: one in-page call instead of getAttribute + isVisible + textContent per element. Same output shape and order; visibility follows Playwright's isVisible (non-empty box, visibility visible, display: contents via children; opacity 0 counts as visible). Times out after 5 s and falls back to the old per-element locator loop on any failure. - Both in-page functions run in a CDP isolated world of the main frame (Page.createIsolatedWorld + Runtime.callFunctionOn), which shares the DOM but not page globals, so LavaMoat scuttling cannot break them. One session and world per page, recreated once after navigation. Extension bench (p50): list-testids toolMs 53 -> 1; home click observationMs 562 -> 515; first Send click observation 8003 ms (timed out, no testIds) -> 43 ms. --- src/server/create-server.test.ts | 12 +- src/server/create-server.ts | 23 +- src/tools/utils/cdp.test.ts | 230 +++++++++ src/tools/utils/cdp.ts | 152 ++++++ src/tools/utils/constants.ts | 20 +- src/tools/utils/discovery.test.ts | 361 +++++++++----- src/tools/utils/discovery.ts | 98 +++- src/tools/utils/in-page.test.ts | 753 ++++++++++++++++++++++++++++++ src/tools/utils/in-page.ts | 247 ++++++++++ 9 files changed, 1749 insertions(+), 147 deletions(-) create mode 100644 src/tools/utils/cdp.test.ts create mode 100644 src/tools/utils/in-page.test.ts create mode 100644 src/tools/utils/in-page.ts diff --git a/src/server/create-server.test.ts b/src/server/create-server.test.ts index 18e721a..d37bb76 100644 --- a/src/server/create-server.test.ts +++ b/src/server/create-server.test.ts @@ -17,6 +17,7 @@ import pkg from '../../package.json'; import type { PortMap, WorkflowContext } from '../capabilities/context.js'; import { toolRegistry } from '../tools/registry.js'; import type { StepRecordObservation } from '../tools/types/observation.js'; +import { waitForAnimationsToSettle } from '../tools/utils/discovery.js'; import type { DaemonState, ServerConfig, @@ -37,6 +38,7 @@ vi.mock('../tools/utils/discovery.js', () => ({ nodes: [], refMap: new Map(), }), + waitForAnimationsToSettle: vi.fn().mockResolvedValue(true), waitForTarget: vi.fn().mockResolvedValue({ click: vi.fn().mockResolvedValue(undefined), fill: vi.fn().mockResolvedValue(undefined), @@ -58,7 +60,6 @@ function createMockSessionManager() { cleanup: vi.fn(async () => true), getPage: vi.fn(() => ({ waitForLoadState: vi.fn(async () => undefined), - waitForFunction: vi.fn(async () => undefined), isClosed: vi.fn(() => false), viewportSize: vi.fn(() => ({ width: 800, height: 600 })), locator: vi.fn(() => ({ @@ -1055,9 +1056,9 @@ describe('createServer with active session', () => { }); it('cdp with observe full waits for animations and returns an observation', async () => { + vi.mocked(waitForAnimationsToSettle).mockClear(); const page = { waitForLoadState: vi.fn(async () => undefined), - waitForFunction: vi.fn(async () => undefined), }; mockSM.getPage.mockReturnValue(page as never); mockSM.getPlatformDriver.mockReturnValue({ @@ -1072,13 +1073,13 @@ describe('createServer with active session', () => { expect(body.ok).toBe(true); expect(body.observations).toBeDefined(); - expect(page.waitForFunction).toHaveBeenCalled(); + expect(waitForAnimationsToSettle).toHaveBeenCalledWith(page, 1_000, 250); }); it('get_state with observe full does not wait for animations', async () => { + vi.mocked(waitForAnimationsToSettle).mockClear(); const page = { waitForLoadState: vi.fn(async () => undefined), - waitForFunction: vi.fn(async () => undefined), url: () => '', }; mockSM.getPage.mockReturnValue(page as never); @@ -1086,7 +1087,7 @@ describe('createServer with active session', () => { const { body } = await post('/tool/get_state', { observe: 'full' }); expect(body.observations).toBeDefined(); - expect(page.waitForFunction).not.toHaveBeenCalled(); + expect(waitForAnimationsToSettle).not.toHaveBeenCalled(); }); it('does not pass observe to the tool handler', async () => { @@ -2336,7 +2337,6 @@ describe('createServer abort, ref generations and observations', () => { vi.mocked(discovery.collectTestIds).mockClear(); mockSM.getPage.mockReturnValue({ waitForLoadState: vi.fn(async () => sleep(120)), - waitForFunction: vi.fn(async () => undefined), } as never); await startServer({ requestTimeoutMs: 50, mobile: false }); addTapTool(); diff --git a/src/server/create-server.ts b/src/server/create-server.ts index 5ec69f1..5a49a58 100644 --- a/src/server/create-server.ts +++ b/src/server/create-server.ts @@ -34,6 +34,7 @@ import type { StepRecordObservation } from '../tools/types/observation.js'; import type { RunStepsInput } from '../tools/types/tool-inputs.js'; import type { RunStepsResult } from '../tools/types/tool-outputs.js'; import { + ANIMATION_SETTLE_GRACE_MS, ANIMATION_SETTLE_TIMEOUT_MS, GRACEFUL_SHUTDOWN_TIMEOUT_MS, MAX_IDLE_CHECK_INTERVAL_MS, @@ -47,6 +48,7 @@ import { import { collectTestIds, collectTrimmedA11ySnapshot, + waitForAnimationsToSettle, } from '../tools/utils/discovery.js'; import type { DaemonState, @@ -461,20 +463,13 @@ export function createServer(config: ServerConfig): ServerInstance { if (category !== 'readonly' && category !== 'discovery') { await page.waitForLoadState('domcontentloaded').catch(() => undefined); signal.throwIfAborted(); - await page - .waitForFunction( - async () => - new Promise((resolve) => { - requestAnimationFrame(() => { - const allSettled = document - .getAnimations() - .every((a: Animation) => a.playState !== 'running'); - resolve(allSettled); - }); - }), - { timeout: ANIMATION_SETTLE_TIMEOUT_MS }, - ) - .catch(() => undefined); + // Waits only for running, finite, in-viewport animations (infinite + // spinners are ignored), checked once per frame, capped at 1 s. + await waitForAnimationsToSettle( + page, + ANIMATION_SETTLE_TIMEOUT_MS, + ANIMATION_SETTLE_GRACE_MS, + ); signal.throwIfAborted(); } const testIds = await collectTestIds(page, OBSERVATION_TESTID_LIMIT); diff --git a/src/tools/utils/cdp.test.ts b/src/tools/utils/cdp.test.ts new file mode 100644 index 0000000..c916869 --- /dev/null +++ b/src/tools/utils/cdp.test.ts @@ -0,0 +1,230 @@ +import type { Page } from '@playwright/test'; +import * as vm from 'node:vm'; +import { describe, it, expect, vi } from 'vitest'; + +import { + ISOLATED_WORLD_NAME, + callInIsolatedWorld, + withCdpSession, +} from './cdp.js'; + +type Send = ReturnType; + +/** + * Builds a page whose CDP sessions answer with `respond`. + * + * @param respond - Answers `Runtime.callFunctionOn`. + * @returns The page, the sessions it opened and their send mocks. + */ +function createPage( + respond: (params: Record, sessionIndex: number) => unknown, +) { + const sessions: { send: Send; detach: Send }[] = []; + let contextCounter = 0; + const newCDPSession = vi.fn(async () => { + const sessionIndex = sessions.length; + const session = { + send: vi.fn(async (method: string, params: Record) => { + switch (method) { + case 'Page.getFrameTree': + return { frameTree: { frame: { id: 'main' } } }; + case 'Page.createIsolatedWorld': + contextCounter += 1; + return { executionContextId: contextCounter }; + case 'Runtime.callFunctionOn': + return respond(params, sessionIndex); + default: + throw new Error(`Unexpected ${method}`); + } + }), + detach: vi.fn(async () => undefined), + }; + sessions.push(session); + return session; + }); + const page = { + context: () => ({ newCDPSession }), + } as unknown as Page; + return { page, sessions, newCDPSession }; +} + +/** + * Runs a functionDeclaration sent by callInIsolatedWorld in a new context. + * + * @param params - The Runtime.callFunctionOn params. + * @returns The CDP response. + */ +function evaluateDeclaration(params: Record): unknown { + const fn = vm.runInNewContext(`(${String(params.functionDeclaration)})`, {}); + const args = (params.arguments as { value: unknown }[]).map( + (arg) => arg.value, + ); + return { result: { value: fn(...args) } }; +} + +describe('withCdpSession', () => { + it('detaches the session after the callback', async () => { + const { page, sessions } = createPage(() => undefined); + + const result = await withCdpSession(page, async () => 'done'); + + expect(result).toBe('done'); + expect(sessions[0].detach).toHaveBeenCalled(); + }); +}); + +describe('callInIsolatedWorld', () => { + it('runs the function source with its arguments in an isolated world', async () => { + const { page, sessions } = createPage(evaluateDeclaration); + + const result = await callInIsolatedWorld( + page, + '(a, b) => a * b', + [6, 7], + ); + + expect(result).toBe(42); + expect(sessions[0].send).toHaveBeenCalledWith('Page.createIsolatedWorld', { + frameId: 'main', + worldName: ISOLATED_WORLD_NAME, + }); + expect(sessions[0].send).toHaveBeenCalledWith( + 'Runtime.callFunctionOn', + expect.objectContaining({ + executionContextId: 1, + arguments: [{ value: 6 }, { value: 7 }], + returnByValue: true, + awaitPromise: true, + }), + ); + }); + + it('defines __name so keepNames-transformed source runs', async () => { + const { page } = createPage(evaluateDeclaration); + + const result = await callInIsolatedWorld( + page, + 'function () { const f = __name(() => "ok", "f"); return f(); }', + ); + + expect(result).toBe('ok'); + }); + + it('reuses the session and world between calls', async () => { + const { page, sessions, newCDPSession } = createPage(() => ({ + result: { value: 1 }, + })); + + await callInIsolatedWorld(page, '() => 1'); + await callInIsolatedWorld(page, '() => 1'); + + expect(newCDPSession).toHaveBeenCalledTimes(1); + const worldCalls = sessions[0].send.mock.calls.filter( + ([method]) => method === 'Page.createIsolatedWorld', + ); + expect(worldCalls).toHaveLength(1); + expect(sessions[0].detach).not.toHaveBeenCalled(); + }); + + it('starts over once after the world or session is gone', async () => { + const { page, sessions, newCDPSession } = createPage((_params, index) => { + if (index === 0) { + throw new Error('Cannot find context with specified id'); + } + return { result: { value: 'fresh' } }; + }); + + const result = await callInIsolatedWorld(page, '() => 1'); + + expect(result).toBe('fresh'); + expect(newCDPSession).toHaveBeenCalledTimes(2); + expect(sessions[0].detach).toHaveBeenCalled(); + }); + + it('rejects when the retry fails too', async () => { + const { page, newCDPSession } = createPage(() => { + throw new Error('Target closed'); + }); + + await expect(callInIsolatedWorld(page, '() => 1')).rejects.toThrowError( + 'Target closed', + ); + expect(newCDPSession).toHaveBeenCalledTimes(2); + }); + + it('rejects without retrying when the function throws in the page', async () => { + const { page, sessions, newCDPSession } = createPage(() => ({ + result: { type: 'object' }, + exceptionDetails: { + text: 'Uncaught', + exception: { description: 'TypeError: boom' }, + }, + })); + + await expect(callInIsolatedWorld(page, '() => 1')).rejects.toThrowError( + 'TypeError: boom', + ); + expect(newCDPSession).toHaveBeenCalledTimes(1); + expect(sessions[0].detach).not.toHaveBeenCalled(); + }); + + it('uses the exception text when there is no description', async () => { + const { page } = createPage(() => ({ + result: { type: 'object' }, + exceptionDetails: { text: 'Uncaught' }, + })); + + await expect(callInIsolatedWorld(page, '() => 1')).rejects.toThrowError( + 'Uncaught', + ); + }); + + it('keeps a newer world when an older call fails late', async () => { + const rejecters: ((error: Error) => void)[] = []; + const { page, newCDPSession } = createPage(async (_params, index) => { + if (index === 0) { + return new Promise((_resolve, reject) => { + rejecters.push(reject); + }); + } + return { result: { value: index } }; + }); + + // Two calls in flight on world 0. + const first = callInIsolatedWorld(page, '() => 1'); + const second = callInIsolatedWorld(page, '() => 1'); + await vi.waitFor(() => expect(rejecters).toHaveLength(2)); + // The first fails, drops world 0 and retries on world 1. + rejecters[0](new Error('Target closed')); + expect(await first).toBe(1); + // The second fails later: world 1 is kept and reused by its retry. + rejecters[1](new Error('Target closed')); + expect(await second).toBe(1); + expect(await callInIsolatedWorld(page, '() => 1')).toBe(1); + expect(newCDPSession).toHaveBeenCalledTimes(2); + }); + + it('rejects after timeoutMs while the page call is pending', async () => { + vi.useFakeTimers(); + try { + const { page } = createPage(async () => new Promise(() => undefined)); + + const pending = callInIsolatedWorld(page, '() => 1', [], 100); + const outcome = pending.then( + () => 'resolved', + (error: Error) => error.message, + ); + await vi.advanceTimersByTimeAsync(100); + + expect(await outcome).toBe('In-page call timed out after 100ms'); + } finally { + vi.useRealTimers(); + } + }); + + it('resolves before timeoutMs when the page answers', async () => { + const { page } = createPage(() => ({ result: { value: 'quick' } })); + + expect(await callInIsolatedWorld(page, '() => 1', [], 1_000)).toBe('quick'); + }); +}); diff --git a/src/tools/utils/cdp.ts b/src/tools/utils/cdp.ts index ebd1dfd..655a461 100644 --- a/src/tools/utils/cdp.ts +++ b/src/tools/utils/cdp.ts @@ -1,5 +1,8 @@ import type { Page, CDPSession } from '@playwright/test'; +/** Name of the isolated world in which in-page functions run. */ +export const ISOLATED_WORLD_NAME = '__mm_client_mcp_core__'; + /** * Opens a short-lived CDP session for a single operation. * @@ -19,3 +22,152 @@ export async function withCdpSession( await cdpSession.detach().catch(() => undefined); } } + +type IsolatedWorld = { + session: Promise; + contextId?: Promise; +}; + +/** + * One CDP session and isolated world per page, kept between calls. Each + * `Page.createIsolatedWorld` on a new session adds a JS context to the + * frame that lives until the next navigation, so creating one per call + * would pile up contexts on a long-lived extension page. + */ +const isolatedWorlds = new WeakMap(); + +/** + * Error thrown when the in-page function itself threw. + */ +class InPageError extends Error {} + +/** + * Drops the cached session and world of a page. + * + * @param page - The page. + * @param world - The entry to drop (kept when it was already replaced). + */ +function resetIsolatedWorld(page: Page, world: IsolatedWorld): void { + if (isolatedWorlds.get(page) !== world) { + return; + } + isolatedWorlds.delete(page); + world.session + .then(async (session) => session.detach()) + .catch(() => undefined); +} + +/** + * Calls a function once in the page's isolated world. + * + * @param page - The page. + * @param functionDeclaration - Source text of the function. + * @param args - JSON arguments. + * @returns The function's JSON result. + */ +async function callOnce( + page: Page, + functionDeclaration: string, + args: unknown[], +): Promise { + let world = isolatedWorlds.get(page); + if (!world) { + world = { session: page.context().newCDPSession(page) }; + isolatedWorlds.set(page, world); + } + const current = world; + try { + const session = await current.session; + current.contextId ??= (async (): Promise => { + const { frameTree } = await session.send('Page.getFrameTree'); + const { executionContextId } = await session.send( + 'Page.createIsolatedWorld', + { frameId: frameTree.frame.id, worldName: ISOLATED_WORLD_NAME }, + ); + 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; + } catch (error) { + if (!(error instanceof InPageError)) { + // The session closed or the world went away (navigation): start + // over on the next call. + resetIsolatedWorld(page, current); + } + throw error; + } +} + +/** + * 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 + * `Runtime.callFunctionOn`; the session and world are created on first use + * and again (one retry) after a navigation or a closed session. + * + * The isolated world shares the DOM with the page but has its own globals, + * so it works on pages that lock down their own globals (LavaMoat scuttling + * on MetaMask Extension pages breaks `page.evaluate` in the main world). + * + * `functionDeclaration` is source text, for example `fn.toString()`: the + * function must be self-contained (no imports, no closures over module + * scope) and its arguments and result must be JSON-serializable. + * + * @param page - The Playwright page. + * @param functionDeclaration - Source text of the function to call. + * @param args - Arguments passed to the function. + * @param timeoutMs - When set, stop waiting after this time and reject. + * @returns The function's return value. + */ +export async function callInIsolatedWorld( + page: Page, + functionDeclaration: string, + 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); + } + })(); + if (timeoutMs === undefined) { + return call; + } + // A call still pending after the timeout settles later; ignore it. + call.catch(() => undefined); + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`In-page call timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }); + try { + return await Promise.race([call, timeout]); + } finally { + clearTimeout(timer); + } +} diff --git a/src/tools/utils/constants.ts b/src/tools/utils/constants.ts index 686e11e..6b8d333 100644 --- a/src/tools/utils/constants.ts +++ b/src/tools/utils/constants.ts @@ -16,8 +16,24 @@ export const QUEUE_SETTLE_TIMEOUT_MS = 5_000; /** Upper bound for any queued task timeout (largest schema timeout + budget). */ export const MAX_QUEUE_TIMEOUT_MS = 600_000; -/** Timeout for waiting for CSS/Web animations to settle after a mutation */ -export const ANIMATION_SETTLE_TIMEOUT_MS = 3_000; +/** + * Cap on the wait for animations to settle after a mutating browser call. + * Only running, finite animations of elements in the viewport are waited + * for; infinite ones (spinners, the fox, skeleton shimmer) are ignored. + */ +export const ANIMATION_SETTLE_TIMEOUT_MS = 1_000; + +/** + * Extra time the daemon gives the in-page animation wait before it stops + * waiting itself (covers a page whose timers are throttled or frozen). + */ +export const ANIMATION_SETTLE_GRACE_MS = 250; + +/** + * Cap on the in-page testId collection call. After it, collection falls + * back to per-element locator calls. + */ +export const TESTID_COLLECT_TIMEOUT_MS = 5_000; /** Maximum interval between idle-shutdown checks */ export const MAX_IDLE_CHECK_INTERVAL_MS = 60_000; diff --git a/src/tools/utils/discovery.test.ts b/src/tools/utils/discovery.test.ts index 6187c75..59c7153 100644 --- a/src/tools/utils/discovery.test.ts +++ b/src/tools/utils/discovery.test.ts @@ -12,13 +12,17 @@ import type { Page, Locator } from '@playwright/test'; import { describe, it, expect, vi } from 'vitest'; import type { RawA11yNode } from '../types'; +import { TESTID_COLLECT_TIMEOUT_MS } from './constants.js'; import { collectTestIds, collectTrimmedA11ySnapshot, parseAriaSnapshotYaml, resolveTarget, + waitForAnimationsToSettle, waitForTarget, } from './discovery.js'; +import { collectTestIdsInPage } from './in-page.js'; +import type { InPageTestIdItem } from './in-page.js'; type MockAXProperty = { name: string; @@ -143,7 +147,9 @@ function createDomNode( function createMockPage( options: { - testIds?: { testId: string; visible: boolean; text?: string }[]; + testIds?: InPageTestIdItem[]; + isolatedWorldError?: Error; + isolatedWorldException?: string; a11ySnapshot?: string | null; axNodes?: MockAXNode[]; partialAxNodes?: MockAXNode[]; @@ -163,10 +169,17 @@ function createMockPage( resolveNodeErrorIds?: Set; testIdBySelector?: Record; textBySelector?: Record; + locatorTestIds?: { + testId: string | null; + visible: boolean; + text?: string; + }[]; } = {}, ): Page { const { testIds = [], + isolatedWorldError, + isolatedWorldException, a11ySnapshot = null, axNodes, partialAxNodes, @@ -179,6 +192,7 @@ function createMockPage( resolveNodeErrorIds = new Set(), testIdBySelector = {}, textBySelector = {}, + locatorTestIds = [], } = options; const resolvedAxNodes = @@ -264,7 +278,26 @@ function createMockPage( } return { object: { objectId: `obj-${backendNodeId}` } }; } + case 'Page.getFrameTree': + return { frameTree: { frame: { id: 'main-frame' } } }; + case 'Page.createIsolatedWorld': + return { executionContextId: 7 }; case 'Runtime.callFunctionOn': { + if (params?.executionContextId !== undefined) { + if (isolatedWorldError) { + throw isolatedWorldError; + } + if (isolatedWorldException) { + return { + result: { type: 'undefined' }, + exceptionDetails: { + text: 'Uncaught', + exception: { description: isolatedWorldException }, + }, + }; + } + return { result: { value: testIds } }; + } const objectId = params?.objectId; const backendNodeId = Number( String(objectId).replace('obj-', ''), @@ -283,12 +316,6 @@ function createMockPage( detach: vi.fn().mockResolvedValue(undefined), }; - const mockLocators = testIds.map((item) => ({ - getAttribute: vi.fn().mockResolvedValue(item.testId), - isVisible: vi.fn().mockResolvedValue(item.visible), - textContent: vi.fn().mockResolvedValue(item.text ?? ''), - })); - return { mockCdpSession, waitForLoadState: vi.fn().mockResolvedValue(undefined), @@ -298,7 +325,13 @@ function createMockPage( locator: vi.fn((selector: string) => { if (selector === '[data-testid]') { return { - all: vi.fn().mockResolvedValue(mockLocators), + all: vi.fn().mockResolvedValue( + locatorTestIds.map((item) => ({ + getAttribute: vi.fn().mockResolvedValue(item.testId), + isVisible: vi.fn().mockResolvedValue(item.visible), + textContent: vi.fn().mockResolvedValue(item.text ?? null), + })), + ), }; } if (selector.startsWith('[data-testid="')) { @@ -364,174 +397,262 @@ function createMockLocator( } describe('collectTestIds', () => { - it('collects visible test IDs from page', async () => { + it('collects test IDs with one isolated-world call', async () => { const page = createMockPage({ testIds: [ { testId: 'button-1', visible: true, text: 'Click me' }, - { testId: 'input-1', visible: true, text: '' }, + { testId: 'input-1', visible: false, text: '' }, ], - }); + }) as MockPageWithCdpSession; const result = await collectTestIds(page); - expect(result).toHaveLength(2); - expect(result[0]).toStrictEqual({ - testId: 'button-1', - tag: 'element', - text: 'Click me', - visible: true, - }); - expect(result[1]).toStrictEqual({ - testId: 'input-1', - tag: 'element', - text: '', - visible: true, + expect(result).toStrictEqual([ + { testId: 'button-1', tag: 'element', text: 'Click me', visible: true }, + { testId: 'input-1', tag: 'element', text: '', visible: false }, + ]); + const calls = page.mockCdpSession.send.mock.calls.filter( + ([method]) => method === 'Runtime.callFunctionOn', + ); + expect(calls).toHaveLength(1); + expect(calls[0][1]).toMatchObject({ + executionContextId: 7, + arguments: [{ value: 150 }, { value: 100 }], + returnByValue: true, + awaitPromise: true, }); + expect(calls[0][1].functionDeclaration).toContain( + collectTestIdsInPage.toString(), + ); + expect(page.mockCdpSession.send).toHaveBeenCalledWith( + 'Page.createIsolatedWorld', + { frameId: 'main-frame', worldName: '__mm_client_mcp_core__' }, + ); }); - it('respects limit parameter', async () => { - const page = createMockPage({ - testIds: [ - { testId: 'item-1', visible: true }, - { testId: 'item-2', visible: true }, - { testId: 'item-3', visible: true }, - ], - }); + it('passes the limit to the page', async () => { + const page = createMockPage() as MockPageWithCdpSession; - const result = await collectTestIds(page, 2); + await collectTestIds(page, 2); - expect(result).toHaveLength(2); + expect(page.mockCdpSession.send).toHaveBeenCalledWith( + 'Runtime.callFunctionOn', + expect.objectContaining({ arguments: [{ value: 2 }, { value: 100 }] }), + ); }); - it('handles invisible elements', async () => { + it('maps a null text to undefined', async () => { const page = createMockPage({ - testIds: [ - { testId: 'visible', visible: true }, - { testId: 'hidden', visible: false }, - ], + testIds: [{ testId: 'no-text', visible: true, text: null }], }); const result = await collectTestIds(page); - expect(result).toHaveLength(2); - expect(result[0].visible).toBe(true); - expect(result[1].visible).toBe(false); + expect(result).toStrictEqual([ + { testId: 'no-text', tag: 'element', text: undefined, visible: true }, + ]); }); - it('handles elements without test IDs', async () => { - const mockLocators = [ - { - getAttribute: vi.fn().mockResolvedValue(null), - isVisible: vi.fn().mockResolvedValue(true), - textContent: vi.fn().mockResolvedValue(''), - }, - ]; + it('falls back to locators when the CDP call fails', async () => { + const page = createMockPage({ + isolatedWorldError: new Error('Target closed'), + locatorTestIds: [ + { testId: 'a', visible: true, text: ' Hello ' }, + { testId: null, visible: true }, + { testId: 'b', visible: false }, + ], + }); - const page = { - waitForLoadState: vi.fn().mockResolvedValue(undefined), - locator: vi.fn().mockReturnValue({ - all: vi.fn().mockResolvedValue(mockLocators), - }), - } as unknown as Page; + expect(await collectTestIds(page)).toStrictEqual([ + { testId: 'a', tag: 'element', text: 'Hello', visible: true }, + { testId: 'b', tag: 'element', text: undefined, visible: false }, + ]); + expect(page.locator).toHaveBeenCalledWith('[data-testid]'); + }); - const result = await collectTestIds(page); + it('falls back to locators when the page function throws', async () => { + const page = createMockPage({ + isolatedWorldException: 'TypeError: boom', + locatorTestIds: [{ testId: 'a', visible: true, text: 'A' }], + }); + + expect(await collectTestIds(page)).toStrictEqual([ + { testId: 'a', tag: 'element', text: 'A', visible: true }, + ]); + }); - expect(result).toHaveLength(0); + it('falls back to locators when the in-page call times out', async () => { + vi.useFakeTimers(); + try { + const page = createMockPage({ + locatorTestIds: [{ testId: 'slow', visible: true, text: 'S' }], + }) as MockPageWithCdpSession; + page.mockCdpSession.send.mockImplementation(async (method: string) => { + if (method === 'Page.getFrameTree') { + return { frameTree: { frame: { id: 'f' } } }; + } + if (method === 'Page.createIsolatedWorld') { + return { executionContextId: 3 }; + } + return new Promise(() => undefined); + }); + + const pending = collectTestIds(page); + let result: unknown; + pending.then((value) => (result = value)).catch(() => undefined); + await vi.advanceTimersByTimeAsync(TESTID_COLLECT_TIMEOUT_MS - 1); + expect(result).toBeUndefined(); + expect(page.locator).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + expect(await pending).toStrictEqual([ + { testId: 'slow', tag: 'element', text: 'S', visible: true }, + ]); + } finally { + vi.useRealTimers(); + } }); - it('handles errors during collection', async () => { - const mockLocators = [ + it('respects the limit and per-element errors in the locator fallback', async () => { + const page = createMockPage({ + isolatedWorldError: new Error('Target closed'), + }); + const locators = [ { - getAttribute: vi.fn().mockRejectedValue(new Error('Element detached')), - isVisible: vi.fn().mockResolvedValue(true), - textContent: vi.fn().mockResolvedValue(''), + getAttribute: vi.fn().mockRejectedValue(new Error('detached')), + isVisible: vi.fn(), + textContent: vi.fn(), }, { - getAttribute: vi.fn().mockResolvedValue('valid-id'), + getAttribute: vi.fn().mockResolvedValue('a'), + isVisible: vi.fn().mockRejectedValue(new Error('detached')), + textContent: vi.fn().mockRejectedValue(new Error('detached')), + }, + { + getAttribute: vi.fn().mockResolvedValue('b'), isVisible: vi.fn().mockResolvedValue(true), - textContent: vi.fn().mockResolvedValue('Valid'), + textContent: vi.fn().mockResolvedValue('x'.repeat(300)), + }, + { + getAttribute: vi.fn().mockResolvedValue('c'), + isVisible: vi.fn(), + textContent: vi.fn(), }, ]; + vi.mocked(page.locator).mockReturnValue({ + all: vi.fn().mockResolvedValue(locators), + } as unknown as Locator); - const page = { - waitForLoadState: vi.fn().mockResolvedValue(undefined), - locator: vi.fn().mockReturnValue({ - all: vi.fn().mockResolvedValue(mockLocators), - }), - } as unknown as Page; + expect(await collectTestIds(page, 2)).toStrictEqual([ + { testId: 'a', tag: 'element', text: undefined, visible: false }, + { testId: 'b', tag: 'element', text: 'x'.repeat(100), visible: true }, + ]); + expect(locators[3].getAttribute).not.toHaveBeenCalled(); + }); - const result = await collectTestIds(page); + it('returns an empty array when the locator fallback fails too', async () => { + const page = createMockPage({ + isolatedWorldError: new Error('Target closed'), + }); + vi.mocked(page.locator).mockReturnValue({ + all: vi.fn().mockRejectedValue(new Error('Target closed')), + } as unknown as Locator); - expect(result).toHaveLength(1); - expect(result[0].testId).toBe('valid-id'); + expect(await collectTestIds(page)).toStrictEqual([]); }); - it('truncates long text content', async () => { - const longText = 'a'.repeat(300); + it('returns an empty array when the page returns nothing', async () => { const page = createMockPage({ - testIds: [{ testId: 'long-text', visible: true, text: longText }], + testIds: undefined as unknown as InPageTestIdItem[], + }) as MockPageWithCdpSession; + page.mockCdpSession.send.mockImplementation(async (method: string) => { + if (method === 'Page.getFrameTree') { + return { frameTree: { frame: { id: 'f' } } }; + } + if (method === 'Page.createIsolatedWorld') { + return { executionContextId: 1 }; + } + return { result: {} }; }); - const result = await collectTestIds(page); - - expect(result[0].text?.length).toBeLessThanOrEqual(200); + expect(await collectTestIds(page)).toStrictEqual([]); }); - it('handles isVisible rejection gracefully', async () => { - const mockLocators = [ - { - getAttribute: vi.fn().mockResolvedValue('btn-1'), - isVisible: vi.fn().mockRejectedValue(new Error('detached')), - textContent: vi.fn().mockResolvedValue('OK'), - }, - ]; - - const page = { - waitForLoadState: vi.fn().mockResolvedValue(undefined), - locator: vi.fn().mockReturnValue({ - all: vi.fn().mockResolvedValue(mockLocators), - }), - } as unknown as Page; + it('handles page load state failure', async () => { + const page = createMockPage({ + testIds: [{ testId: 'test-1', visible: true, text: '' }], + }); + vi.spyOn(page, 'waitForLoadState').mockRejectedValue( + new Error('Load failed'), + ); const result = await collectTestIds(page); expect(result).toHaveLength(1); - expect(result[0].visible).toBe(false); }); +}); - it('handles textContent rejection gracefully', async () => { - const mockLocators = [ - { - getAttribute: vi.fn().mockResolvedValue('btn-1'), - isVisible: vi.fn().mockResolvedValue(true), - textContent: vi.fn().mockRejectedValue(new Error('detached')), - }, - ]; - - const page = { - waitForLoadState: vi.fn().mockResolvedValue(undefined), - locator: vi.fn().mockReturnValue({ - all: vi.fn().mockResolvedValue(mockLocators), - }), - } as unknown as Page; +describe('waitForAnimationsToSettle', () => { + it('runs the settle loop in the isolated world with the timeout', async () => { + const page = createMockPage() as MockPageWithCdpSession; + page.mockCdpSession.send.mockImplementation(async (method: string) => { + if (method === 'Page.getFrameTree') { + return { frameTree: { frame: { id: 'f' } } }; + } + if (method === 'Page.createIsolatedWorld') { + return { executionContextId: 3 }; + } + return { result: { value: true } }; + }); - const result = await collectTestIds(page); + expect(await waitForAnimationsToSettle(page, 1_000, 250)).toBe(true); - expect(result).toHaveLength(1); - expect(result[0].text).toBeUndefined(); + const call = page.mockCdpSession.send.mock.calls.find( + ([method]) => method === 'Runtime.callFunctionOn', + ); + expect(call?.[1]).toMatchObject({ + executionContextId: 3, + arguments: [{ value: 1_000 }], + awaitPromise: true, + }); + expect(call?.[1].functionDeclaration).toContain('getAnimations'); + expect(call?.[1].functionDeclaration).toContain('requestAnimationFrame'); }); - it('handles page load state failure', async () => { + it('returns false when the CDP call fails', async () => { const page = createMockPage({ - testIds: [{ testId: 'test-1', visible: true }], + isolatedWorldError: new Error('Target closed'), }); - vi.spyOn(page, 'waitForLoadState').mockRejectedValue( - new Error('Load failed'), - ); - const result = await collectTestIds(page); + expect(await waitForAnimationsToSettle(page, 1_000, 250)).toBe(false); + }); - expect(result).toHaveLength(1); + it('stops waiting after the timeout plus grace', async () => { + vi.useFakeTimers(); + try { + const page = createMockPage() as MockPageWithCdpSession; + page.mockCdpSession.send.mockImplementation(async (method: string) => { + if (method === 'Page.getFrameTree') { + return { frameTree: { frame: { id: 'f' } } }; + } + if (method === 'Page.createIsolatedWorld') { + return { executionContextId: 3 }; + } + return new Promise(() => undefined); + }); + + const pending = waitForAnimationsToSettle(page, 1_000, 250); + await vi.advanceTimersByTimeAsync(1_249); + let settled: boolean | undefined; + pending.then((value) => (settled = value)).catch(() => undefined); + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + + expect(await pending).toBe(false); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/src/tools/utils/discovery.ts b/src/tools/utils/discovery.ts index c94c60b..1b50a6e 100644 --- a/src/tools/utils/discovery.ts +++ b/src/tools/utils/discovery.ts @@ -1,7 +1,16 @@ import type { Page, Locator, CDPSession } from '@playwright/test'; -import { withCdpSession } from './cdp.js'; -import { TEXT_PREVIEW_MAX_LENGTH } from './constants.js'; +import { callInIsolatedWorld, withCdpSession } from './cdp.js'; +import { + TESTID_COLLECT_TIMEOUT_MS, + TEXT_PREVIEW_MAX_LENGTH, +} from './constants.js'; +import { + animationsSettled, + collectTestIdsInPage, + waitUntilInPage, +} from './in-page.js'; +import type { InPageTestIdItem } from './in-page.js'; import { debugWarn } from '../../utils'; import type { TestIdItem, @@ -161,7 +170,12 @@ function parseAriaNodeDescriptor(descriptor: string): RawA11yNode | null { } /** - * Collect all visible test IDs from the current page. + * Collect the `[data-testid]` elements of the current page, with a text + * preview and visibility, in one in-page call (see `collectTestIdsInPage` + * for order, text and visibility semantics). + * + * When the in-page call fails or takes longer than + * `TESTID_COLLECT_TIMEOUT_MS`, falls back to per-element locator calls. * * @param page The Playwright page to scan for test IDs * @param limit Maximum number of test IDs to return (default: 150) @@ -173,7 +187,50 @@ export async function collectTestIds( ): Promise { await page.waitForLoadState('domcontentloaded').catch(() => undefined); - const locators = await page.locator('[data-testid]').all(); + let items: InPageTestIdItem[]; + try { + items = await callInIsolatedWorld( + page, + collectTestIdsInPage.toString(), + [limit, TEXT_PREVIEW_MAX_LENGTH], + TESTID_COLLECT_TIMEOUT_MS, + ); + } catch (error) { + debugWarn( + 'discovery.collectTestIds: in-page call failed, using locators', + error, + ); + return collectTestIdsViaLocators(page, limit); + } + + return (items ?? []).map((item) => ({ + testId: item.testId, + tag: 'element', + text: item.text ?? undefined, + visible: item.visible, + })); +} + +/** + * Collects test IDs with per-element Playwright locator calls + * (`getAttribute`, `isVisible`, `textContent`). Slower than the in-page + * call; used only when that call fails. + * + * @param page The Playwright page to scan for test IDs + * @param limit Maximum number of test IDs to return + * @returns Array of test ID items with visibility and text content + */ +async function collectTestIdsViaLocators( + page: Page, + limit: number, +): Promise { + const locators = await page + .locator('[data-testid]') + .all() + .catch((error: unknown) => { + debugWarn('discovery.collectTestIdsViaLocators', error); + return []; + }); const results: TestIdItem[] = []; for (const locator of locators) { @@ -203,7 +260,7 @@ export async function collectTestIds( visible: isVisible, }); } catch (error) { - debugWarn('discovery.collectTestIds', error); + debugWarn('discovery.collectTestIdsViaLocators', error); continue; } } @@ -211,6 +268,37 @@ export async function collectTestIds( return results; } +/** + * Waits until the page's running, finite, in-viewport animations end (see + * `animationsSettled`), checking once per animation frame, for at most + * `timeoutMs`. Never throws: a timeout or an unreadable page ends the wait. + * + * @param page - The Playwright page. + * @param timeoutMs - Maximum time to wait in the page. + * @param graceMs - Extra time before the daemon stops waiting for the page. + * @returns True when the animations settled, false otherwise. + */ +export async function waitForAnimationsToSettle( + page: Page, + timeoutMs: number, + graceMs: number, +): Promise { + const functionDeclaration = `function (timeoutMs) { + return (${waitUntilInPage.toString()})(${animationsSettled.toString()}, timeoutMs); + }`; + try { + return await callInIsolatedWorld( + page, + functionDeclaration, + [timeoutMs], + timeoutMs + graceMs, + ); + } catch (error) { + debugWarn('discovery.waitForAnimationsToSettle', error); + return false; + } +} + type CdpAXProperty = { name: string; value?: { diff --git a/src/tools/utils/in-page.test.ts b/src/tools/utils/in-page.test.ts new file mode 100644 index 0000000..3ef0ee0 --- /dev/null +++ b/src/tools/utils/in-page.test.ts @@ -0,0 +1,753 @@ +/** + * Tests for the in-page functions. Each function is compiled from its own + * source text (`fn.toString()`, as sent to the page) in a fresh `vm` + * context whose only globals are the DOM fakes, so a function that uses a + * module import or a closure fails here as it would in the page. + */ +import * as vm from 'node:vm'; +import { describe, it, expect } from 'vitest'; + +import * as inPage from './in-page.js'; +import { + animationsSettled, + collectTestIdsInPage, + waitUntilInPage, +} from './in-page.js'; + +type Rect = { x: number; y: number; width: number; height: number }; + +type FakeStyle = { display?: string; visibility?: string }; + +type FakeNode = { + nodeType: number; + nextSibling: FakeNode | null; + rect?: Rect; +}; + +type FakeElement = FakeNode & { + testId?: string | null; + text: string | null; + style: FakeStyle; + checkVisible: boolean; + children: FakeNode[]; + firstChild: FakeNode | null; + shadowRoot: FakeRoot | null; + nodeName: string; + getAttribute: (name: string) => string | null; + getBoundingClientRect: () => DOMRect; + checkVisibility?: () => boolean; + closest: (selector: string) => FakeElement | null; + open?: boolean; + parent: FakeElement | null; + readonly textContent: string | null; +}; + +type FakeRoot = { + elements: FakeElement[]; + querySelectorAll: (selector: string) => FakeElement[]; +}; + +/** + * Builds a DOMRect-like object. + * + * @param rect - Position and size. + * @returns The rect. + */ +function toDomRect(rect: Rect): DOMRect { + return { + ...rect, + left: rect.x, + top: rect.y, + right: rect.x + rect.width, + bottom: rect.y + rect.height, + } as DOMRect; +} + +/** + * Builds a fake element. + * + * @param options - Element properties. + * @param options.testId - The data-testid value (null: no attribute). + * @param options.text - The textContent. + * @param options.rect - The bounding box (default 10x10 at the origin). + * @param options.style - Computed style values. + * @param options.checkVisible - What checkVisibility() returns. + * @param options.children - Child nodes (elements or text nodes). + * @param options.shadowRoot - An attached open shadow root. + * @param options.nodeName - The node name. + * @returns The element. + */ +function element( + options: { + testId?: string | null; + text?: string | null; + rect?: Rect; + style?: FakeStyle; + checkVisible?: boolean; + children?: FakeNode[]; + shadowRoot?: FakeRoot; + nodeName?: string; + } = {}, +): FakeElement { + const children = options.children ?? []; + children.forEach((child, index) => { + child.nextSibling = children[index + 1] ?? null; + }); + const fake: FakeElement = { + nodeType: 1, + nextSibling: null, + testId: options.testId, + text: options.text === undefined ? '' : options.text, + rect: options.rect ?? { x: 0, y: 0, width: 10, height: 10 }, + style: options.style ?? {}, + checkVisible: options.checkVisible ?? true, + children, + firstChild: children[0] ?? null, + shadowRoot: options.shadowRoot ?? null, + nodeName: options.nodeName ?? 'DIV', + parent: null, + get textContent() { + return fake.text; + }, + getAttribute: (name) => + name === 'data-testid' ? (fake.testId ?? null) : null, + getBoundingClientRect: () => toDomRect(fake.rect as Rect), + checkVisibility: () => fake.checkVisible, + closest: (selector) => { + for ( + let current: FakeElement | null = fake; + current; + current = current.parent + ) { + if ( + selector === 'details,summary' && + ['DETAILS', 'SUMMARY'].includes(current.nodeName) + ) { + return current; + } + } + return null; + }, + }; + for (const child of children) { + if (child.nodeType === 1) { + (child as FakeElement).parent = fake; + } + } + return fake; +} + +/** + * Builds a fake text node. + * + * @param rect - The box of the text. + * @returns The text node. + */ +function textNode(rect: Rect): FakeNode { + return { nodeType: 3, nextSibling: null, rect }; +} + +/** + * Builds a fake document or shadow root over a flat list of elements (in + * DOM order), answering `[data-testid]` (elements with the attribute) and + * `*` (all elements). + * + * @param elements - All elements of the root, in DOM order. + * @returns The root. + */ +function root(elements: FakeElement[]): FakeRoot { + return { + elements, + querySelectorAll: (selector) => { + if (selector === '[data-testid]') { + return elements.filter( + (item) => item.testId !== undefined && item.testId !== null, + ); + } + if (selector === '*') { + return elements; + } + throw new Error(`Unexpected selector ${selector}`); + }, + }; +} + +/** + * Compiles an in-page function from its source text in a new context. + * + * @param fn - The function to compile. + * @param globals - The context's globals. + * @returns The compiled function. + */ +function compileInPage( + fn: (...args: never[]) => unknown, + globals: Record, +): TFn { + return vm.runInNewContext(`(${fn.toString()})`, globals) as TFn; +} + +/** + * Runs collectTestIdsInPage against a fake document. + * + * @param documentRoot - The fake document. + * @param limit - Item limit. + * @param maxTextLength - Text preview length. + * @returns The collected items. + */ +function collect( + documentRoot: FakeRoot, + limit = 150, + maxTextLength = 100, +): ReturnType { + const fakeDocument = { + ...documentRoot, + createRange: () => { + let selected: FakeNode | undefined; + return { + selectNodeContents: (node: FakeNode) => { + selected = node; + }, + getBoundingClientRect: () => toDomRect(selected?.rect as Rect), + }; + }, + }; + const fakeWindow = { + getComputedStyle: (target: FakeElement) => ({ + display: target.style.display ?? 'block', + visibility: target.style.visibility ?? 'visible', + }), + }; + const fn = compileInPage(collectTestIdsInPage, { + document: fakeDocument, + window: fakeWindow, + }); + // Round-trip through JSON like returnByValue does. + return JSON.parse(JSON.stringify(fn(limit, maxTextLength))); +} + +describe('collectTestIdsInPage', () => { + it('returns testId, trimmed text and visibility in DOM order', () => { + const items = collect( + root([ + element({ testId: 'a', text: ' Hello\n ' }), + element({ testId: 'b', text: '' }), + element({ testId: 'c', text: null }), + ]), + ); + + expect(items).toStrictEqual([ + { testId: 'a', text: 'Hello', visible: true }, + { testId: 'b', text: '', visible: true }, + { testId: 'c', text: null, visible: true }, + ]); + }); + + it('cuts the text after trimming', () => { + const items = collect( + root([element({ testId: 'a', text: ` ${'x'.repeat(20)}` })]), + 150, + 5, + ); + + expect(items[0].text).toBe('xxxxx'); + }); + + it('skips empty testIds without counting them towards the limit', () => { + const items = collect( + root([ + element({ testId: '' }), + element({ testId: 'a' }), + element({ testId: '' }), + element({ testId: 'b' }), + element({ testId: 'c' }), + ]), + 2, + ); + + expect(items.map((item) => item.testId)).toStrictEqual(['a', 'b']); + }); + + it('returns nothing for a limit of 0', () => { + expect(collect(root([element({ testId: 'a' })]), 0)).toStrictEqual([]); + }); + + it('reports empty boxes as not visible', () => { + const items = collect( + root([ + element({ + testId: 'zero-width', + rect: { x: 0, y: 0, width: 0, height: 5 }, + }), + element({ + testId: 'zero-height', + rect: { x: 0, y: 0, width: 5, height: 0 }, + }), + ]), + ); + + expect(items.map((item) => item.visible)).toStrictEqual([false, false]); + }); + + it('reports visibility hidden and collapse as not visible', () => { + const items = collect( + root([ + element({ testId: 'hidden', style: { visibility: 'hidden' } }), + element({ testId: 'collapse', style: { visibility: 'collapse' } }), + ]), + ); + + expect(items.map((item) => item.visible)).toStrictEqual([false, false]); + }); + + it('reports elements that fail checkVisibility() as not visible', () => { + const items = collect( + root([element({ testId: 'display-none', checkVisible: false })]), + ); + + expect(items[0].visible).toBe(false); + }); + + it('reports off-screen elements with a box as visible', () => { + const items = collect( + root([ + element({ + testId: 'off-screen', + rect: { x: -5000, y: 9000, width: 10, height: 10 }, + }), + ]), + ); + + expect(items[0].visible).toBe(true); + }); + + it('uses the closed
fallback when checkVisibility() is missing', () => { + const inside = element({ testId: 'inside' }); + const summary = element({ testId: 'summary', nodeName: 'SUMMARY' }); + const details = element({ + testId: 'details', + nodeName: 'DETAILS', + children: [summary, inside], + }); + for (const item of [inside, summary, details]) { + delete item.checkVisibility; + } + + const items = collect(root([details, summary, inside])); + + expect(items).toStrictEqual([ + { testId: 'details', text: '', visible: true }, + { testId: 'summary', text: '', visible: true }, + { testId: 'inside', text: '', visible: false }, + ]); + + details.open = true; + expect(collect(root([inside]))[0].visible).toBe(true); + }); + + describe('display: contents', () => { + it('is visible when a child element is visible', () => { + const child = element({ testId: 'child' }); + const contents = element({ + testId: 'contents', + style: { display: 'contents' }, + rect: { x: 0, y: 0, width: 0, height: 0 }, + children: [element({ checkVisible: false }), child], + }); + + const items = collect(root([contents, child])); + + expect(items[0]).toStrictEqual({ + testId: 'contents', + text: '', + visible: true, + }); + }); + + it('is visible when a child text node has a box', () => { + const contents = element({ + testId: 'contents', + style: { display: 'contents' }, + children: [ + textNode({ x: 0, y: 0, width: 0, height: 0 }), + textNode({ x: 0, y: 0, width: 8, height: 12 }), + ], + }); + + expect(collect(root([contents]))[0].visible).toBe(true); + }); + + it('is not visible without a visible child', () => { + const contents = element({ + testId: 'contents', + style: { display: 'contents' }, + children: [ + { nodeType: 8, nextSibling: null }, + textNode({ x: 0, y: 0, width: 0, height: 0 }), + element({ style: { visibility: 'hidden' } }), + element({ + style: { display: 'contents' }, + children: [element({ rect: { x: 0, y: 0, width: 0, height: 0 } })], + }), + ], + }); + + expect(collect(root([contents]))[0].visible).toBe(false); + }); + }); + + it('reports an element whose style cannot be read as not visible', () => { + const broken = element({ testId: 'broken' }); + broken.getBoundingClientRect = () => { + throw new Error('detached'); + }; + + expect(collect(root([broken]))).toStrictEqual([ + { testId: 'broken', text: '', visible: false }, + ]); + }); + + it('adds open shadow root matches after the document matches', () => { + const inner = element({ testId: 'inner' }); + const nestedHost = element({ shadowRoot: root([inner]) }); + const shadowItem = element({ testId: 'shadow' }); + const host = element({ + testId: 'host', + shadowRoot: root([shadowItem, nestedHost]), + }); + const after = element({ testId: 'after' }); + + const items = collect(root([host, after])); + + expect(items.map((item) => item.testId)).toStrictEqual([ + 'host', + 'after', + 'shadow', + 'inner', + ]); + }); + + it('applies the limit across shadow roots', () => { + const host = element({ + testId: 'host', + shadowRoot: root([element({ testId: 's1' }), element({ testId: 's2' })]), + }); + const second = element({ + shadowRoot: root([element({ testId: 's3' })]), + }); + + expect( + collect(root([host, second]), 2).map((item) => item.testId), + ).toStrictEqual(['host', 's1']); + expect( + collect(root([host, second]), 3).map((item) => item.testId), + ).toStrictEqual(['host', 's1', 's2']); + expect( + collect(root([host, second]), 1).map((item) => item.testId), + ).toStrictEqual(['host']); + }); +}); + +type FakeAnimation = { + playState: string; + effect: { + getComputedTiming: () => { iterations: number; endTime: number }; + target?: unknown; + } | null; +}; + +/** + * Builds a fake animation. + * + * @param options - Animation properties. + * @param options.playState - The play state. + * @param options.iterations - Computed iterations. + * @param options.endTime - Computed end time. + * @param options.target - The effect target (default: a 10x10 element in view). + * @param options.effect - False for an animation without an effect. + * @returns The animation. + */ +function animation( + options: { + playState?: string; + iterations?: number; + endTime?: number; + target?: unknown; + effect?: boolean; + } = {}, +): FakeAnimation { + if (options.effect === false) { + return { playState: options.playState ?? 'running', effect: null }; + } + return { + playState: options.playState ?? 'running', + effect: { + getComputedTiming: () => ({ + iterations: options.iterations ?? 1, + endTime: options.endTime ?? 300, + }), + target: + 'target' in options ? options.target : targetElement({ x: 0, y: 0 }), + }, + }; +} + +/** + * Builds a fake animation target element. + * + * @param rect - Position and size (size defaults to 10x10). + * @param rect.x - Left. + * @param rect.y - Top. + * @param rect.width - Width. + * @param rect.height - Height. + * @returns The target. + */ +function targetElement(rect: { + x: number; + y: number; + width?: number; + height?: number; +}): { getBoundingClientRect: () => DOMRect } { + return { + getBoundingClientRect: () => toDomRect({ width: 10, height: 10, ...rect }), + }; +} + +/** + * Runs animationsSettled against fake animations in a 800x600 viewport. + * + * @param animations - The page's animations. + * @returns The predicate result. + */ +function settled(animations: FakeAnimation[]): boolean { + const fn = compileInPage(animationsSettled, { + window: { innerWidth: 800, innerHeight: 600 }, + document: { getAnimations: () => animations }, + }); + return fn(); +} + +describe('animationsSettled', () => { + it('is settled without animations', () => { + expect(settled([])).toBe(true); + }); + + it('is not settled with a running finite animation in the viewport', () => { + expect(settled([animation()])).toBe(false); + }); + + it('ignores animations that are not running', () => { + expect( + settled([ + animation({ playState: 'finished' }), + animation({ playState: 'paused' }), + animation({ playState: 'idle' }), + ]), + ).toBe(true); + }); + + it('ignores infinite animations', () => { + expect( + settled([ + animation({ iterations: Infinity, endTime: Infinity }), + animation({ iterations: 1, endTime: Infinity }), + ]), + ).toBe(true); + }); + + it('ignores animations without an effect or an element target', () => { + expect( + settled([ + animation({ effect: false }), + animation({ target: null }), + animation({ target: { pseudo: true } }), + ]), + ).toBe(true); + }); + + it('ignores targets outside the viewport', () => { + expect( + settled([ + animation({ target: targetElement({ x: -10, y: 0 }) }), + animation({ target: targetElement({ x: 0, y: -10 }) }), + animation({ target: targetElement({ x: 800, y: 0 }) }), + animation({ target: targetElement({ x: 0, y: 600 }) }), + ]), + ).toBe(true); + }); + + it('ignores targets with an empty box', () => { + expect( + settled([ + animation({ target: targetElement({ x: 5, y: 5, width: 0 }) }), + animation({ target: targetElement({ x: 5, y: 5, height: 0 }) }), + ]), + ).toBe(true); + }); + + it('counts targets that only partly intersect the viewport', () => { + expect( + settled([animation({ target: targetElement({ x: -5, y: 595 }) })]), + ).toBe(false); + }); + + it('is not settled when one animation still counts', () => { + expect(settled([animation({ iterations: Infinity }), animation()])).toBe( + false, + ); + }); +}); + +describe('waitUntilInPage', () => { + /** + * Runs waitUntilInPage with a manual animation-frame queue and timers. + * + * @param predicate - The polled predicate. + * @param frames - Number of frames to run. + * @param timeoutAfterFrames - Fire the timeout after this many frames. + * @returns The result and the number of predicate calls. + */ + async function run( + predicate: () => boolean, + frames: number, + timeoutAfterFrames?: number, + ): Promise<{ result: boolean; calls: number; cleared: boolean }> { + const queue: (() => void)[] = []; + let timeoutCallback: (() => void) | undefined; + let cleared = false; + let calls = 0; + const fn = compileInPage(waitUntilInPage, { + Promise, + document: { hidden: false }, + requestAnimationFrame: (callback: () => void) => queue.push(callback), + setTimeout: (callback: () => void) => { + timeoutCallback = callback; + return 1; + }, + clearTimeout: () => { + cleared = true; + }, + }); + const pending = fn(() => { + calls += 1; + return predicate(); + }, 1_000); + for (let frame = 0; frame < frames; frame++) { + if (frame === timeoutAfterFrames) { + timeoutCallback?.(); + } + queue.shift()?.(); + } + return { result: await pending, calls, cleared }; + } + + it('first checks in the next frame and resolves true once it passes', async () => { + let remaining = 2; + const outcome = await run(() => { + remaining -= 1; + return remaining < 0; + }, 5); + + expect(outcome).toStrictEqual({ result: true, calls: 3, cleared: true }); + }); + + it('resolves false on timeout and stops polling', async () => { + const outcome = await run(() => false, 5, 2); + + expect(outcome).toStrictEqual({ result: false, calls: 2, cleared: false }); + }); + + it('treats a throwing predicate as settled', async () => { + const outcome = await run(() => { + throw new Error('boom'); + }, 1); + + expect(outcome.result).toBe(true); + }); + + it('checks with a 16 ms setTimeout when the document is hidden', async () => { + const timers: { callback: () => void; delay: number }[] = []; + let rafCalls = 0; + let remaining = 1; + const fn = compileInPage(waitUntilInPage, { + Promise, + document: { hidden: true }, + requestAnimationFrame: () => { + rafCalls += 1; + }, + setTimeout: (callback: () => void, delay: number) => { + timers.push({ callback, delay }); + return timers.length; + }, + clearTimeout: () => undefined, + }); + + const pending = fn(() => { + remaining -= 1; + return remaining < 0; + }, 1_000); + // timers[0] is the overall timeout; the first check follows. + expect(timers.map(({ delay }) => delay)).toStrictEqual([1_000, 16]); + timers[1].callback(); + expect(timers.map(({ delay }) => delay)).toStrictEqual([1_000, 16, 16]); + timers[2].callback(); + + expect(await pending).toBe(true); + expect(rafCalls).toBe(0); + expect(timers).toHaveLength(3); + }); + + it('composes with animationsSettled as source text', async () => { + const queue: (() => void)[] = []; + const animations = [animation()]; + const composed = vm.runInNewContext( + `(function (timeoutMs) { + return (${waitUntilInPage.toString()})(${animationsSettled.toString()}, timeoutMs); + })`, + { + Promise, + window: { innerWidth: 800, innerHeight: 600 }, + document: { hidden: false, getAnimations: () => animations }, + requestAnimationFrame: (callback: () => void) => queue.push(callback), + setTimeout: () => 1, + clearTimeout: () => undefined, + }, + ) as (timeoutMs: number) => Promise; + + const pending = composed(1_000); + queue.shift()?.(); + animations[0].playState = 'finished'; + queue.shift()?.(); + + expect(await pending).toBe(true); + expect(queue).toHaveLength(0); + }); +}); + +describe('in-page function source', () => { + const inPageFunctions = Object.entries(inPage).filter( + (entry): entry is [string, (...args: never[]) => unknown] => + typeof entry[1] === 'function', + ); + + it('exports the in-page functions', () => { + expect(inPageFunctions.map(([name]) => name).sort()).toStrictEqual([ + 'animationsSettled', + 'collectTestIdsInPage', + 'waitUntilInPage', + ]); + }); + + // Instrumentation or bundler helpers (coverage counters, `__vite_ssr_*`, + // `__publicField`...) are undefined in the page. Only `__name` is defined + // by callInIsolatedWorld. + it.each(inPageFunctions)( + '%s references no injected helper other than __name', + (_name, fn) => { + const helpers = (fn.toString().match(/\b__\w+/gu) ?? []).filter( + (identifier) => identifier !== '__name', + ); + + expect(helpers).toStrictEqual([]); + }, + ); +}); diff --git a/src/tools/utils/in-page.ts b/src/tools/utils/in-page.ts new file mode 100644 index 0000000..6307176 --- /dev/null +++ b/src/tools/utils/in-page.ts @@ -0,0 +1,247 @@ +/* istanbul ignore file -- @preserve */ +/** + * Functions that run inside the browser page (see `callInIsolatedWorld` in + * `cdp.ts`). They are sent to the page as source text (`fn.toString()`), so + * each one must be self-contained: no imports, no references to module + * scope, and only page globals (`window`, `document`). Helpers are defined + * inside the function body. Values come in as arguments and go back as + * JSON-serializable return values. + * + * The file is not instrumented for coverage: coverage counters would be + * part of `fn.toString()` and break the functions in the page. The tests + * run them from their source text in a separate `vm` context. + */ + +/** + * A `[data-testid]` element as read in the page. `text` is `null` when the + * element has no `textContent`. + */ +export type InPageTestIdItem = { + testId: string; + text: string | null; + visible: boolean; +}; + +/** + * Reports whether the page's animations have settled. + * + * Only animations that can end soon and that the user can see are counted: + * an animation keeps the page unsettled when it is running, it is finite + * (its computed `iterations` and `endTime` are not `Infinity`), and its + * effect targets an element whose bounding box has a non-zero size and + * intersects the viewport. Animations without an effect or without an + * element target are ignored. Infinite animations (spinners, the fox, + * skeleton shimmer) never settle, so they are ignored too. A finite + * animation that is still in a long `delay` phase reports `running`, so it + * counts as running until it ends or the wait reaches its cap. + * + * Runs in the page: must stay self-contained. + * + * @returns True when no running, finite, in-viewport animation remains. + */ +export function animationsSettled(): boolean { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + for (const animation of document.getAnimations()) { + if (animation.playState !== 'running') { + continue; + } + const { effect } = animation; + if (!effect) { + continue; + } + const timing = effect.getComputedTiming(); + if (timing.iterations === Infinity || timing.endTime === Infinity) { + continue; + } + const target = (effect as Partial).target ?? null; + if (!target || typeof target.getBoundingClientRect !== 'function') { + continue; + } + const rect = target.getBoundingClientRect(); + const hasSize = rect.width > 0 && rect.height > 0; + const inViewport = + rect.right > 0 && + rect.bottom > 0 && + rect.left < viewportWidth && + rect.top < viewportHeight; + if (hasSize && inViewport) { + return false; + } + } + return true; +} + +/** + * Polls `predicate` once per animation frame (first check in the next + * frame) until it returns true or `timeoutMs` passes. A predicate that + * throws ends the wait, like a settled page. When the document is hidden + * (Chromium does not run animation frames for hidden pages), each check is + * scheduled with a 16 ms `setTimeout` instead. + * + * Runs in the page: must stay self-contained. The predicate is passed in by + * composing source text (see `waitForAnimationsToSettle`). + * + * @param predicate - The check to poll. + * @param timeoutMs - Maximum time to wait. + * @returns True when the predicate passed (or threw), false on timeout. + */ +export async function waitUntilInPage( + predicate: () => boolean, + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (value: boolean): void => { + if (!done) { + done = true; + resolve(value); + } + }; + const timer = setTimeout(() => finish(false), timeoutMs); + const schedule = (callback: () => void): void => { + if (document.hidden) { + setTimeout(callback, 16); + } else { + requestAnimationFrame(callback); + } + }; + const check = (): void => { + if (done) { + return; + } + let passed: boolean; + try { + passed = predicate(); + } catch { + passed = true; + } + if (passed) { + clearTimeout(timer); + finish(true); + return; + } + schedule(check); + }; + schedule(check); + }); +} + +/** + * Collects `[data-testid]` elements in one pass, with the same results as + * the former per-element Playwright calls (`page.locator('[data-testid]')` + * then `getAttribute`, `isVisible` and `textContent` on each). + * + * Order: like Playwright's CSS engine, the document's matches in DOM order + * first, then the matches inside each open shadow root (hosts in DOM order, + * nested roots depth-first). Elements with an empty or missing testId are + * skipped and do not count towards `limit`. + * + * Text: `textContent`, trimmed, cut to `maxTextLength` characters; `null` + * when the element has no `textContent`. + * + * Visibility follows Playwright's `isVisible()` (injected `computeBox`): + * - `display: contents`: visible when a child element is visible or a child + * text node has a non-empty box. + * - otherwise hidden when `element.checkVisibility()` (no options, as + * Playwright calls it in Chromium) is false, e.g. under `display: none` or + * `content-visibility: hidden`; hidden when the computed `visibility` is + * not `visible` (`hidden`, `collapse`); else visible when the bounding box + * has a non-zero width and height. + * - `opacity: 0` does not hide an element; neither does being outside the + * viewport or covered by another element. + * An element whose visibility cannot be computed is reported not visible. + * + * Runs in the page: must stay self-contained. + * + * @param limit - Maximum number of items to return. + * @param maxTextLength - Maximum length of each text preview. + * @returns The items, in the order described above. + */ +export function collectTestIdsInPage( + limit: number, + maxTextLength: number, +): InPageTestIdItem[] { + const results: InPageTestIdItem[] = []; + + const hasBox = (rect: DOMRect): boolean => rect.width > 0 && rect.height > 0; + + const isVisible = (element: Element): boolean => { + const style = window.getComputedStyle(element); + if (style.display === 'contents') { + for (let child = element.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 1 && isVisible(child as Element)) { + return true; + } + if (child.nodeType === 3) { + const range = document.createRange(); + // Playwright's isVisibleTextNode uses selectNodeContents. + range.selectNodeContents(child); + if (hasBox(range.getBoundingClientRect())) { + return true; + } + } + } + return false; + } + if (typeof element.checkVisibility === 'function') { + if (!element.checkVisibility()) { + return false; + } + } else { + // Playwright's fallback when checkVisibility() is missing. + const details = element.closest('details,summary'); + if ( + details !== element && + details?.nodeName === 'DETAILS' && + !(details as HTMLDetailsElement).open + ) { + return false; + } + } + if (style.visibility !== 'visible') { + return false; + } + return hasBox(element.getBoundingClientRect()); + }; + + // Returns true once `limit` items are collected. + const visit = (root: ParentNode): boolean => { + for (const element of Array.from(root.querySelectorAll('[data-testid]'))) { + if (results.length >= limit) { + return true; + } + const testId = element.getAttribute('data-testid'); + if (!testId) { + continue; + } + let visible: boolean; + try { + visible = isVisible(element); + } catch { + visible = false; + } + const text = element.textContent; + results.push({ + testId, + text: text === null ? null : text.trim().substring(0, maxTextLength), + visible, + }); + } + // Second scan, for shadow hosts: Playwright's CSS engine returns a + // root's own matches first, then the matches of each open shadow root + // (hosts in DOM order, nested roots depth-first). Keep that order. + for (const host of Array.from(root.querySelectorAll('*'))) { + if (results.length >= limit) { + return true; + } + if (host.shadowRoot && visit(host.shadowRoot)) { + return true; + } + } + return results.length >= limit; + }; + + visit(document); + return results; +} From 2fa8fc34dd2f46eda538b601fbb6db3b0adf9b33 Mon Sep 17 00:00:00 2001 From: cryptotavares Date: Mon, 26 Oct 2026 18:11:54 +0000 Subject: [PATCH 2/2] test: infer in-page function entry types --- src/tools/utils/in-page.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tools/utils/in-page.test.ts b/src/tools/utils/in-page.test.ts index 3ef0ee0..85c3441 100644 --- a/src/tools/utils/in-page.test.ts +++ b/src/tools/utils/in-page.test.ts @@ -725,8 +725,7 @@ describe('waitUntilInPage', () => { describe('in-page function source', () => { const inPageFunctions = Object.entries(inPage).filter( - (entry): entry is [string, (...args: never[]) => unknown] => - typeof entry[1] === 'function', + (entry) => typeof entry[1] === 'function', ); it('exports the in-page functions', () => {