diff --git a/src/tools/browser/registerBrowserTools.test.ts b/src/tools/browser/registerBrowserTools.test.ts index 7e6e142..34a57c6 100644 --- a/src/tools/browser/registerBrowserTools.test.ts +++ b/src/tools/browser/registerBrowserTools.test.ts @@ -14,6 +14,11 @@ class FakeServer { const createSession = (): SessionRecord => { const locator = { first: vi.fn(() => locator), + count: vi.fn(async () => 1), + isVisible: vi.fn(async () => true), + isEnabled: vi.fn(async () => true), + isChecked: vi.fn(async () => false), + inputValue: vi.fn(async () => 'test-value'), click: vi.fn(async () => undefined), fill: vi.fn(async () => undefined), hover: vi.fn(async () => undefined), @@ -30,7 +35,11 @@ const createSession = (): SessionRecord => { locator: vi.fn(() => locator), waitForSelector: vi.fn(async () => undefined), waitForTimeout: vi.fn(async () => undefined), - evaluate: vi.fn(async (_callback: unknown, script: string) => `page:${script}`), + evaluate: vi.fn(async (_callback: unknown, arg: unknown) => { + // extractPageSnapshot passes { maxDepth, maxChildren, selector }; browser_evaluate passes a string + if (typeof arg === 'string') return `page:${arg}`; + return { tree: [], hiddenTopLevelCount: 0, title: 'Example', url: 'https://example.com' }; + }), keyboard: { press: vi.fn(async () => undefined), type: vi.fn(async () => undefined), @@ -172,4 +181,98 @@ describe('registerBrowserTools', () => { }); expect(session.page.mouse.up).toHaveBeenCalled(); }); + + it('browser_snapshot returns a JSON snapshot with params', async () => { + const fakeServer = new FakeServer(); + const session = createSession(); + const registry = { getSessionOrThrow: vi.fn(() => session) }; + + registerBrowserTools(fakeServer as unknown as McpServer, registry as never); + + const snapshot = fakeServer.tools.get('browser_snapshot'); + const result = (await snapshot?.({ + sessionId: 1, + maxDepth: 3, + maxChildren: 10, + })) as { content: Array<{ text?: string }> }; + + const parsed = JSON.parse(result.content[0]?.text ?? '{}'); + expect(parsed.title).toBe('Example'); + expect(parsed.url).toBe('https://example.com'); + expect(parsed.params).toMatchObject({ maxDepth: 3, maxChildren: 10, selector: null }); + }); + + it('browser_dom_query returns element state when element exists', async () => { + const fakeServer = new FakeServer(); + const session = createSession(); + const registry = { getSessionOrThrow: vi.fn(() => session) }; + + registerBrowserTools(fakeServer as unknown as McpServer, registry as never); + + const domQuery = fakeServer.tools.get('browser_dom_query'); + const result = (await domQuery?.({ + sessionId: 1, + selector: '#submit', + })) as { content: Array<{ text?: string }> }; + + const parsed = JSON.parse(result.content[0]?.text ?? '{}'); + expect(parsed.selector).toBe('#submit'); + expect(parsed.count).toBe(1); + expect(parsed.visible).toBe(true); + expect(parsed.enabled).toBe(true); + }); + + it('browser_dom_query returns only count when element is absent', async () => { + const fakeServer = new FakeServer(); + const session = createSession(); + // override count to return 0 + (session.page.locator as ReturnType).mockReturnValue({ + count: vi.fn(async () => 0), + first: vi.fn(), + }); + const registry = { getSessionOrThrow: vi.fn(() => session) }; + + registerBrowserTools(fakeServer as unknown as McpServer, registry as never); + + const domQuery = fakeServer.tools.get('browser_dom_query'); + const result = (await domQuery?.({ + sessionId: 1, + selector: '.missing', + })) as { content: Array<{ text?: string }> }; + + const parsed = JSON.parse(result.content[0]?.text ?? '{}'); + expect(parsed.selector).toBe('.missing'); + expect(parsed.count).toBe(0); + expect(parsed.visible).toBeUndefined(); + }); + + it('browser_dom_query sets checked and value to null for non-checkbox non-input elements', async () => { + const fakeServer = new FakeServer(); + const session = createSession(); + // isChecked and inputValue throw for non-checkbox / non-input elements + const firstLocator = { + isVisible: vi.fn(async () => true), + isEnabled: vi.fn(async () => true), + isChecked: vi.fn(async () => { throw new Error('not a checkbox'); }), + inputValue: vi.fn(async () => { throw new Error('not an input'); }), + }; + (session.page.locator as ReturnType).mockReturnValue({ + count: vi.fn(async () => 1), + first: vi.fn(() => firstLocator), + }); + const registry = { getSessionOrThrow: vi.fn(() => session) }; + + registerBrowserTools(fakeServer as unknown as McpServer, registry as never); + + const domQuery = fakeServer.tools.get('browser_dom_query'); + const result = (await domQuery?.({ + sessionId: 1, + selector: 'span', + })) as { content: Array<{ text?: string }> }; + + const parsed = JSON.parse(result.content[0]?.text ?? '{}'); + expect(parsed.count).toBe(1); + expect(parsed.checked).toBeNull(); + expect(parsed.value).toBeNull(); + }); }); diff --git a/src/tools/browser/registerBrowserTools.ts b/src/tools/browser/registerBrowserTools.ts index e432863..2474c76 100644 --- a/src/tools/browser/registerBrowserTools.ts +++ b/src/tools/browser/registerBrowserTools.ts @@ -1,6 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SessionRegistry } from '../../sessions/SessionRegistry.js'; import { + domQuerySchema, dragSchema, evaluateSchema, fillFormSchema, @@ -15,6 +16,7 @@ import { selectOptionSchema, selectorSchema, sessionIdSchema, + snapshotSchema, uploadFileSchema, waitForSelectorSchema, waitForTimeoutSchema, @@ -127,10 +129,10 @@ export const registerBrowserTools = (server: McpServer, registry: SessionRegistr { title: 'Browser Snapshot', description: 'Return a structured page snapshot.', - inputSchema: sessionIdSchema, + inputSchema: snapshotSchema, }, - withSession(registry, async ({ page }) => { - const snapshot = await extractPageSnapshot(page); + withSession(registry, async ({ page }, { maxDepth, maxChildren, selector }) => { + const snapshot = await extractPageSnapshot(page, { maxDepth, maxChildren, selector }); return jsonResult(snapshot); }), @@ -210,15 +212,51 @@ export const registerBrowserTools = (server: McpServer, registry: SessionRegistr { title: 'Browser Get Page Structure', description: 'Return a readable page structure summary.', - inputSchema: sessionIdSchema, + inputSchema: snapshotSchema, }, - withSession(registry, async ({ page }) => { - const snapshot = await extractPageSnapshot(page); + withSession(registry, async ({ page }, { maxDepth, maxChildren, selector }) => { + const snapshot = await extractPageSnapshot(page, { maxDepth, maxChildren, selector }); return textResult(formatPageStructure(snapshot)); }), ); + server.registerTool( + 'browser_dom_query', + { + title: 'Browser DOM Query', + description: 'Query element presence, count, and state without waiting.', + inputSchema: domQuerySchema, + }, + withSession(registry, async ({ page }, { selector }) => { + const locator = page.locator(selector); + const count = await locator.count(); + + if (count === 0) { + return jsonResult({ selector, count }); + } + + const first = locator.first(); + const [visible, enabled] = await Promise.all([first.isVisible(), first.isEnabled()]); + + let checked: boolean | null = null; + try { + checked = await first.isChecked(); + } catch { + // not a checkbox or radio + } + + let value: string | null = null; + try { + value = await first.inputValue(); + } catch { + // not an input element + } + + return jsonResult({ selector, count, visible, enabled, checked, value }); + }), + ); + server.registerTool( 'browser_evaluate', { diff --git a/src/types/toolArgs.ts b/src/types/toolArgs.ts index 36a3ec8..231a0e1 100644 --- a/src/types/toolArgs.ts +++ b/src/types/toolArgs.ts @@ -37,7 +37,17 @@ export const fillFormSchema = sessionIdSchema.extend({ }); export const screenshotSchema = sessionIdSchema.extend({ - fullPage: z.boolean().optional(), + fullPage: z.boolean().optional().describe('Capture full scrollable page, not just the viewport.'), +}); + +export const snapshotSchema = sessionIdSchema.extend({ + maxDepth: z.number().int().positive().optional().describe('Max tree depth (default 4).'), + maxChildren: z.number().int().positive().optional().describe('Max children per node (default 20).'), + selector: z.string().min(1).optional().describe('Scope to an element; omit for document body.'), +}); + +export const domQuerySchema = sessionIdSchema.extend({ + selector: z.string().min(1), }); export const dragSchema = sessionIdSchema.extend({ @@ -48,7 +58,7 @@ export const dragSchema = sessionIdSchema.extend({ export const selectOptionSchema = sessionIdSchema.extend({ selector: z.string().min(1), - values: z.array(z.string().min(1)).min(1), + values: z.array(z.string().min(1)).min(1).describe('Option values to select; pass multiple for multi-select.'), timeout: z.number().int().positive().optional(), }); @@ -58,16 +68,16 @@ export const generateLocatorSchema = sessionIdSchema.extend({ export const evaluateSchema = sessionIdSchema.extend({ script: z.string().min(1), - selector: z.string().min(1).optional(), + selector: z.string().min(1).optional().describe('Matched element passed as first arg to script.'), }); export const keyboardPressSchema = sessionIdSchema.extend({ - key: z.string().min(1), + key: z.string().min(1).describe('Playwright key name, e.g. "Enter", "ArrowDown", "Control+A".'), }); export const keyboardTypeSchema = sessionIdSchema.extend({ text: z.string(), - delay: z.number().int().nonnegative().optional(), + delay: z.number().int().nonnegative().optional().describe('Ms between keystrokes. Omit for instant.'), }); export const mousePointSchema = sessionIdSchema.extend({ diff --git a/src/utils/dom.test.ts b/src/utils/dom.test.ts index 0d1bedb..293aa23 100644 --- a/src/utils/dom.test.ts +++ b/src/utils/dom.test.ts @@ -1,7 +1,34 @@ import type { Page } from 'playwright-core'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { extractPageSnapshot, formatPageStructure, generateLocatorCandidates } from './dom.js'; +// Minimal fake DOM element — only the properties buildNode actually touches. +const fakeEl = ( + tag: string, + attrs: Record = {}, + children: object[] = [], + text = '', +) => ({ + tagName: tag.toUpperCase(), + innerText: text, + textContent: text, + attributes: Object.entries(attrs).map(([name, value]) => ({ name, value })), + children, +}); + +// Stubs page.evaluate to call the passed function directly in Node.js and +// sets up global.document and global.window so the callback's document/window +// references resolve (vitest runs in node environment where window is undefined). +const stubPage = (doc: object, url = 'https://test.com') => { + vi.stubGlobal('document', doc); + vi.stubGlobal('window', { location: { href: url } }); + return { + evaluate: vi.fn(async (fn: (args: unknown) => unknown, args: unknown) => fn(args)), + title: vi.fn(async () => 'Test'), + url: vi.fn(() => url), + } as unknown as Page; +}; + describe('dom utils', () => { it('formats a readable page structure', () => { const output = formatPageStructure({ @@ -11,11 +38,11 @@ describe('dom utils', () => { { tag: 'button', text: 'Submit', - role: 'button', - name: 'submit', + attributes: { role: 'button', name: 'submit' }, children: [], }, ], + params: { maxDepth: 4, maxChildren: 20, selector: null }, }); expect(output).toContain('Title: Example'); @@ -25,26 +52,298 @@ describe('dom utils', () => { it('delegates snapshot extraction to page.evaluate', async () => { const page = { - evaluate: vi.fn(async () => ({ - title: 'Snapshot', - url: 'https://example.com', - tree: [], - })), + evaluate: vi.fn(async () => ({ tree: [], hiddenTopLevelCount: 0, title: 'Snapshot', url: 'https://example.com' })), } as unknown as Page; const result = await extractPageSnapshot(page); expect(result.title).toBe('Snapshot'); + expect(result.url).toBe('https://example.com'); + expect(result.params).toEqual({ maxDepth: 4, maxChildren: 20, selector: null }); expect(page.evaluate).toHaveBeenCalledTimes(1); }); + it('url is captured inside the same evaluate call as title and tree', async () => { + const page = { + evaluate: vi.fn(async () => ({ tree: [], hiddenTopLevelCount: 0, title: 'T', url: 'https://spa-page.com' })), + } as unknown as Page; + + const result = await extractPageSnapshot(page); + + expect(result.url).toBe('https://spa-page.com'); + // page.url() must NOT be called — url comes from inside evaluate atomically. + expect('url' in page).toBe(false); + }); + + it('passes options to page.evaluate and records them in params', async () => { + const page = { + evaluate: vi.fn(async () => ({ tree: [], hiddenTopLevelCount: 0, title: 'Options Test', url: 'https://example.com' })), + } as unknown as Page; + + const result = await extractPageSnapshot(page, { maxDepth: 2, maxChildren: 5, selector: '#main' }); + + expect(result.params).toEqual({ maxDepth: 2, maxChildren: 5, selector: '#main' }); + expect(page.evaluate).toHaveBeenCalledWith(expect.any(Function), { + maxDepth: 2, + maxChildren: 5, + selector: '#main', + }); + }); + + it('renders hiddenAttrCount in formatted output', () => { + const output = formatPageStructure({ + title: 'T', + url: 'https://example.com', + tree: [ + { + tag: 'div', + text: '', + attributes: { id: 'main' }, + hiddenAttrCount: 5, + children: [], + }, + ], + params: { maxDepth: 4, maxChildren: 20, selector: null }, + }); + + expect(output).toContain('[…5 more attrs]'); + }); + + it('renders hiddenTopLevelCount in formatted output', () => { + const output = formatPageStructure({ + title: 'T', + url: 'https://example.com', + tree: [{ tag: 'div', text: '', attributes: {}, children: [] }], + hiddenTopLevelCount: 8, + params: { maxDepth: 4, maxChildren: 20, selector: null }, + }); + + expect(output).toContain('[…8 more top-level elements]'); + }); + + it('renders hiddenByCount in formatted output with maxChildren hint', () => { + const output = formatPageStructure({ + title: 'T', + url: 'https://example.com', + tree: [ + { + tag: 'ul', + text: '', + attributes: {}, + hiddenByCount: 12, + children: [{ tag: 'li', text: 'first', attributes: {}, children: [] }], + }, + ], + params: { maxDepth: 4, maxChildren: 20, selector: null }, + }); + + expect(output).toContain('li'); + expect(output).toContain('[…12 more children — increase maxChildren to expand]'); + }); + + it('renders hiddenByDepth in formatted output with maxDepth hint', () => { + const output = formatPageStructure({ + title: 'T', + url: 'https://example.com', + tree: [ + { + tag: 'div', + text: '', + attributes: {}, + hiddenByDepth: 3, + children: [], + }, + ], + params: { maxDepth: 4, maxChildren: 20, selector: null }, + }); + + expect(output).toContain('[…3 more children — increase maxDepth to expand]'); + }); + + it('renders hiddenByNodeCap in formatted output', () => { + const output = formatPageStructure({ + title: 'T', + url: 'https://example.com', + tree: [ + { + tag: 'div', + text: '', + attributes: {}, + hiddenByNodeCap: 7, + children: [], + }, + ], + params: { maxDepth: 4, maxChildren: 20, selector: null }, + }); + + expect(output).toContain('[…7 more children — node cap reached]'); + }); + + describe('buildNode', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('filters out SKIP_TAGS at every level', async () => { + const body = fakeEl('body', {}, [ + fakeEl('div'), + fakeEl('script'), + fakeEl('style'), + fakeEl('noscript'), + fakeEl('p'), + ]); + const page = stubPage({ body, documentElement: body }); + + const result = await extractPageSnapshot(page); + + const tags = result.tree.map((n) => n.tag); + expect(tags).toEqual(['div', 'p']); + }); + + it('caps top-level nodes at maxChildren and reports hiddenTopLevelCount', async () => { + const body = fakeEl('body', {}, Array.from({ length: 25 }, () => fakeEl('div'))); + const page = stubPage({ body, documentElement: body }); + + const result = await extractPageSnapshot(page, { maxChildren: 5 }); + + expect(result.tree).toHaveLength(5); + expect(result.hiddenTopLevelCount).toBe(20); + }); + + it('stops recursing at maxDepth and sets hiddenByDepth', async () => { + const inner = fakeEl('span', {}, [], 'deep'); + const middle = fakeEl('p', {}, [inner]); + const outer = fakeEl('div', {}, [middle]); + const body = fakeEl('body', {}, [outer]); + const page = stubPage({ body, documentElement: body }); + + // depth 0 = outer(div), depth 1 = middle(p) which hits the limit + const result = await extractPageSnapshot(page, { maxDepth: 1 }); + + const p = result.tree[0].children[0]; + expect(p.tag).toBe('p'); + expect(p.children).toHaveLength(0); + expect(p.hiddenByDepth).toBe(1); + expect(p.hiddenByCount).toBeUndefined(); + }); + + it('caps visible children per node at maxChildren and sets hiddenByCount', async () => { + const body = fakeEl('body', {}, [ + fakeEl('ul', {}, Array.from({ length: 10 }, () => fakeEl('li'))), + ]); + const page = stubPage({ body, documentElement: body }); + + const result = await extractPageSnapshot(page, { maxChildren: 3 }); + + const ul = result.tree[0]; + expect(ul.children).toHaveLength(3); + expect(ul.hiddenByCount).toBe(7); + expect(ul.hiddenByDepth).toBeUndefined(); + }); + + it('enforces the 500-node global cap and sets hiddenByNodeCap', async () => { + // Build a tree: 1 ul with 600 li children — far exceeds the 500-node cap. + // nodesBuilt starts at 0; the ul itself is node 1, so budget left for children + // is 499. The first 499 li nodes are shown; the remaining 101 are capped. + const body = fakeEl('body', {}, [ + fakeEl('ul', {}, Array.from({ length: 600 }, () => fakeEl('li'))), + ]); + const page = stubPage({ body, documentElement: body }); + + const result = await extractPageSnapshot(page, { maxChildren: 600 }); + + const ul = result.tree[0]; + expect(ul.children.length).toBeLessThanOrEqual(499); + expect(ul.hiddenByNodeCap).toBeGreaterThan(0); + expect(ul.hiddenByNodeCap! + ul.children.length).toBe(600); + }); + + it('sets hiddenAttrCount when attributes exceed the 8-attr cap', async () => { + const attrs = Object.fromEntries( + Array.from({ length: 12 }, (_, i) => [`data-x${i}`, `v${i}`]), + ); + const body = fakeEl('body', {}, [fakeEl('div', attrs)]); + const page = stubPage({ body, documentElement: body }); + + const result = await extractPageSnapshot(page); + + const node = result.tree[0]; + expect(Object.keys(node.attributes)).toHaveLength(8); + expect(node.hiddenAttrCount).toBe(4); + }); + + it('orders priority attrs (role, aria-label, name, id) before the rest', async () => { + const attrs = { 'data-a': 'a', 'data-b': 'b', role: 'button', name: 'submit' }; + const body = fakeEl('body', {}, [fakeEl('button', attrs)]); + const page = stubPage({ body, documentElement: body }); + + const result = await extractPageSnapshot(page); + + const keys = Object.keys(result.tree[0].attributes); + expect(keys.indexOf('role')).toBeLessThan(keys.indexOf('data-a')); + expect(keys.indexOf('name')).toBeLessThan(keys.indexOf('data-b')); + }); + + it('truncates text to 120 characters', async () => { + const body = fakeEl('body', {}, [fakeEl('p', {}, [], 'a'.repeat(200))]); + const page = stubPage({ body, documentElement: body }); + + const result = await extractPageSnapshot(page); + + expect(result.tree[0].text).toHaveLength(120); + }); + + it('normalises internal whitespace in text', async () => { + const body = fakeEl('body', {}, [fakeEl('p', {}, [], ' hello world ')]); + const page = stubPage({ body, documentElement: body }); + + const result = await extractPageSnapshot(page); + + expect(result.tree[0].text).toBe('hello world'); + }); + + it('falls back to textContent when innerText is undefined', async () => { + const el = { ...fakeEl('p', {}, [], ''), innerText: undefined, textContent: 'fallback' }; + const body = fakeEl('body', {}, [el]); + const page = stubPage({ body, documentElement: body }); + + const result = await extractPageSnapshot(page); + + expect(result.tree[0].text).toBe('fallback'); + }); + + it('scopes tree to the element matched by selector', async () => { + const target = fakeEl('section', { id: 'content' }, [fakeEl('p', {}, [], 'hello')]); + const page = stubPage({ + body: fakeEl('body', {}, [fakeEl('div'), target]), + documentElement: fakeEl('body'), + querySelector: vi.fn((sel: string) => (sel === '#content' ? target : null)), + }); + + const result = await extractPageSnapshot(page, { selector: '#content' }); + + expect(result.tree).toHaveLength(1); + expect(result.tree[0].tag).toBe('section'); + expect(result.tree[0].children[0].tag).toBe('p'); + }); + + it('returns empty tree when selector matches nothing', async () => { + const page = stubPage({ + body: fakeEl('body', {}, [fakeEl('div')]), + documentElement: fakeEl('body'), + querySelector: vi.fn(() => null), + }); + + const result = await extractPageSnapshot(page, { selector: '.nonexistent' }); + + expect(result.tree).toHaveLength(0); + }); + }); + it('delegates locator generation to locator.evaluate', async () => { const evaluate = vi.fn(async () => ['#login', '[name="email"]']); const page = { locator: vi.fn(() => ({ - first: vi.fn(() => ({ - evaluate, - })), + count: vi.fn(async () => 1), + first: vi.fn(() => ({ evaluate })), })), } as unknown as Page; @@ -52,4 +351,17 @@ describe('dom utils', () => { expect(result).toEqual(['#login', '[name="email"]']); }); + + it('returns empty array from generateLocatorCandidates when selector matches nothing', async () => { + const page = { + locator: vi.fn(() => ({ + count: vi.fn(async () => 0), + first: vi.fn(), + })), + } as unknown as Page; + + const result = await generateLocatorCandidates(page, '.nonexistent'); + + expect(result).toEqual([]); + }); }); diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 864a306..4232401 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -3,27 +3,33 @@ import type { Page } from 'playwright-core'; interface PageSnapshotNode { tag: string; text: string; - role: string | null; - name: string | null; + attributes: Record; children: PageSnapshotNode[]; + hiddenByDepth?: number; // children omitted because maxDepth was reached + hiddenByCount?: number; // children omitted because maxChildren was reached + hiddenByNodeCap?: number; // children omitted because the 500-node global cap was reached + hiddenAttrCount?: number; // attributes omitted due to MAX_ATTRS limit } export interface PageSnapshotResult { title: string; url: string; tree: PageSnapshotNode[]; + hiddenTopLevelCount?: number; + params: { maxDepth: number; maxChildren: number; selector: string | null }; } +// node.text is already truncated to 120 chars by buildNode in extractPageSnapshot const formatNode = (node: PageSnapshotNode, depth: number): string => { const indent = ' '.repeat(depth); const parts = [node.tag]; - if (node.role !== null) { - parts.push(`role=${node.role}`); + for (const [k, v] of Object.entries(node.attributes)) { + parts.push(`${k}="${v}"`); } - if (node.name !== null) { - parts.push(`name="${node.name}"`); + if (node.hiddenAttrCount) { + parts.push(`[…${node.hiddenAttrCount} more attrs]`); } if (node.text.length > 0) { @@ -33,58 +39,151 @@ const formatNode = (node: PageSnapshotNode, depth: number): string => { const currentLine = `${indent}- ${parts.join(' | ')}`; const childLines = node.children.map((child) => formatNode(child, depth + 1)); + if (node.hiddenByDepth) { + childLines.push(`${indent} - […${node.hiddenByDepth} more children — increase maxDepth to expand]`); + } + if (node.hiddenByCount) { + childLines.push(`${indent} - […${node.hiddenByCount} more children — increase maxChildren to expand]`); + } + if (node.hiddenByNodeCap) { + childLines.push(`${indent} - […${node.hiddenByNodeCap} more children — node cap reached]`); + } + return [currentLine, ...childLines].join('\n'); }; -export const extractPageSnapshot = async (page: Page): Promise => - page.evaluate(() => { - const collectChildren = (element: Element, depth: number): PageSnapshotNode[] => { - if (depth > 2) { - return []; +const DEFAULT_MAX_DEPTH = 4; +const DEFAULT_MAX_CHILDREN = 20; + +export const extractPageSnapshot = async ( + page: Page, + options?: { maxDepth?: number; maxChildren?: number; selector?: string }, +): Promise => { + const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH; + const maxChildren = options?.maxChildren ?? DEFAULT_MAX_CHILDREN; + const selector = options?.selector ?? null; + + // Capture title, url, and tree in the same CDP call so all three describe the same + // navigation state — calling page.title() or page.url() separately could race a + // client-side navigation and return values from different pages. + const { tree, hiddenTopLevelCount, title, url } = await page.evaluate( + ({ maxDepth, maxChildren, selector }) => { + const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'link', 'meta', 'head']); + const MAX_ATTRS = 8; + const MAX_ATTR_VALUE_LEN = 80; + const PRIORITY_ATTRS = new Set(['role', 'aria-label', 'name', 'id']); + + const MAX_TOTAL_NODES = 500; + let nodesBuilt = 0; + + const buildNode = (element: Element, depth: number): PageSnapshotNode => { + nodesBuilt++; + + const visible = Array.from(element.children).filter( + (c) => !SKIP_TAGS.has(c.tagName.toLowerCase()), + ); + const atDepthLimit = depth >= maxDepth; + + let shown: Element[]; + let hiddenByDepth = 0; + let hiddenByCount = 0; + let hiddenByNodeCap = 0; + + if (atDepthLimit) { + shown = []; + hiddenByDepth = visible.length; + } else { + const byCount = visible.slice(0, maxChildren); + hiddenByCount = visible.length - byCount.length; + // Budget check is conservative: it limits children of this node but not their + // descendants, so the actual total can moderately exceed MAX_TOTAL_NODES. + const budgetLeft = MAX_TOTAL_NODES - nodesBuilt; + if (budgetLeft <= 0) { + shown = []; + hiddenByNodeCap = byCount.length; + } else if (budgetLeft < byCount.length) { + shown = byCount.slice(0, budgetLeft); + hiddenByNodeCap = byCount.length - shown.length; + } else { + shown = byCount; + } + } + + // `innerText` is only defined on HTMLElement — SVG/MathML elements + // and some custom elements return undefined here, so fall back to + // `textContent` (available on every Node) before failing to ''. + const rawText = (element as HTMLElement).innerText ?? element.textContent ?? ''; + const allAttrs = Array.from(element.attributes).map( + (a): [string, string] => [a.name, a.value.slice(0, MAX_ATTR_VALUE_LEN)], + ); + const prioritized = allAttrs.filter(([k]) => PRIORITY_ATTRS.has(k)); + const rest = allAttrs.filter(([k]) => !PRIORITY_ATTRS.has(k)); + const ordered = [...prioritized, ...rest]; + const shownAttrs = ordered.slice(0, MAX_ATTRS); + const hiddenAttrCount = ordered.length - shownAttrs.length; + const attributes: Record = Object.fromEntries(shownAttrs); + + return { + tag: element.tagName.toLowerCase(), + text: rawText.trim().replace(/\s+/g, ' ').slice(0, 120), + attributes, + children: shown.map((child) => buildNode(child, depth + 1)), + ...(hiddenByDepth > 0 ? { hiddenByDepth } : {}), + ...(hiddenByCount > 0 ? { hiddenByCount } : {}), + ...(hiddenByNodeCap > 0 ? { hiddenByNodeCap } : {}), + ...(hiddenAttrCount > 0 ? { hiddenAttrCount } : {}), + }; + }; + + if (selector !== null) { + const root = document.querySelector(selector); + if (root === null) return { tree: [], hiddenTopLevelCount: 0, title: document.title, url: window.location.href }; + return { tree: [buildNode(root, 0)], hiddenTopLevelCount: 0, title: document.title, url: window.location.href }; } - return Array.from(element.children) - .slice(0, 8) - .map((child) => { - // `innerText` is only defined on HTMLElement — SVG/MathML elements - // and some custom elements return undefined here, so fall back to - // `textContent` (available on every Node) before failing to '' . - const rawText = - (child as HTMLElement).innerText ?? child.textContent ?? ''; - - return { - tag: child.tagName.toLowerCase(), - text: rawText.trim().replace(/\s+/g, ' ').slice(0, 120), - role: child.getAttribute('role'), - name: - child.getAttribute('aria-label') ?? - child.getAttribute('name') ?? - child.getAttribute('id'), - children: collectChildren(child, depth + 1), - }; - }); - }; - - const body = document.body ?? document.documentElement; - - return { - title: document.title, - url: window.location.href, - tree: collectChildren(body, 0), - }; - }); + const body = document.body ?? document.documentElement; + const topLevel = Array.from(body.children).filter( + (c) => !SKIP_TAGS.has(c.tagName.toLowerCase()), + ); + const shown = topLevel.slice(0, maxChildren); + return { + tree: shown.map((child) => buildNode(child, 0)), + hiddenTopLevelCount: topLevel.length - shown.length, + title: document.title, + url: window.location.href, + }; + }, + { maxDepth, maxChildren, selector }, + ); + + return { + title, + url, + tree, + // Omit when 0 — the selector path always returns 0 (no top-level siblings to hide). + ...(hiddenTopLevelCount > 0 ? { hiddenTopLevelCount } : {}), + params: { maxDepth, maxChildren, selector }, + }; +}; export const formatPageStructure = (snapshot: PageSnapshotResult): string => { - const body = snapshot.tree.map((node) => formatNode(node, 0)).join('\n'); - - return [`Title: ${snapshot.title}`, `URL: ${snapshot.url}`, '', body].join('\n'); + const lines = [`Title: ${snapshot.title}`, `URL: ${snapshot.url}`, '']; + lines.push(...snapshot.tree.map((node) => formatNode(node, 0))); + if (snapshot.hiddenTopLevelCount) { + lines.push(`[…${snapshot.hiddenTopLevelCount} more top-level elements]`); + } + return lines.join('\n'); }; export const generateLocatorCandidates = async ( page: Page, selector: string, -): Promise => - page.locator(selector).first().evaluate((element) => { +): Promise => { + const locator = page.locator(selector); + // .first().evaluate() throws when the locator matches nothing — guard explicitly so + // callers get an empty array rather than an unhandled strict-mode error. + if (await locator.count() === 0) return []; + return locator.first().evaluate((element) => { const candidates = new Set(); const id = element.getAttribute('id'); const name = element.getAttribute('name'); @@ -114,3 +213,4 @@ export const generateLocatorCandidates = async ( return [...candidates]; }); +};