diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 000000000..34d21fc0f --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,4 @@ +# Instructions + +- use `npm run build` to run tsc and test build +- use `npm run test` to run tests, run all tests to verify correctness diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 6db0d1831..c104c719a 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -17,7 +17,7 @@ import { getShortDescriptionForRequest, getStatusFromRequest, } from './formatters/networkFormatter.js'; -import {formatSnapshotNode} from './formatters/snapshotFormatter.js'; +import {SnapshotFormatter} from './formatters/SnapshotFormatter.js'; import type {McpContext} from './McpContext.js'; import {DevTools} from './third_party/index.js'; import type { @@ -181,29 +181,31 @@ export class McpResponse implements Response { async handle( toolName: string, context: McpContext, - ): Promise> { + ): Promise<{ + content: Array; + structuredContent: object; + }> { if (this.#includePages) { await context.createPagesSnapshot(); } - let formattedSnapshot: string | undefined; + let snapshot: SnapshotFormatter | string | undefined; if (this.#snapshotParams) { await context.createTextSnapshot( this.#snapshotParams.verbose, this.#devToolsData, ); - const snapshot = context.getTextSnapshot(); - if (snapshot) { + const textSnapshot = context.getTextSnapshot(); + if (textSnapshot) { + const formatter = new SnapshotFormatter(textSnapshot); if (this.#snapshotParams.filePath) { await context.saveFile( - new TextEncoder().encode( - formatSnapshotNode(snapshot.root, snapshot), - ), + new TextEncoder().encode(formatter.toString()), this.#snapshotParams.filePath, ); - formattedSnapshot = `Saved snapshot to ${this.#snapshotParams.filePath}.`; + snapshot = this.#snapshotParams.filePath; } else { - formattedSnapshot = formatSnapshotNode(snapshot.root, snapshot); + snapshot = formatter; } } } @@ -335,7 +337,7 @@ export class McpResponse implements Response { bodies, consoleData, consoleListData, - formattedSnapshot, + snapshot, }); } @@ -349,9 +351,9 @@ export class McpResponse implements Response { }; consoleData: ConsoleMessageData | undefined; consoleListData: ConsoleMessageData[] | undefined; - formattedSnapshot: string | undefined; + snapshot: SnapshotFormatter | string | undefined; }, - ): Array { + ): {content: Array; structuredContent: object} { const response = [`# ${toolName} response`]; for (const line of this.#textResponseLines) { response.push(line); @@ -393,9 +395,20 @@ Call ${handleDialog.name} to handle it before continuing.`); response.push(...parts); } - if (data.formattedSnapshot) { - response.push('## Latest page snapshot'); - response.push(data.formattedSnapshot); + const structuredContent: { + snapshot?: object; + snapshotFilePath?: string; + } = {}; + + if (data.snapshot) { + if (typeof data.snapshot === 'string') { + response.push(`Saved snapshot to ${data.snapshot}.`); + structuredContent.snapshotFilePath = data.snapshot; + } else { + response.push('## Latest page snapshot'); + response.push(data.snapshot.toString()); + structuredContent.snapshot = data.snapshot.toJSON(); + } } response.push(...this.#formatNetworkRequestData(context, data.bodies)); @@ -468,7 +481,10 @@ Call ${handleDialog.name} to handle it before continuing.`); } as const; }); - return [text, ...images]; + return { + content: [text, ...images], + structuredContent, + }; } #dataWithPagination(data: T[], pagination?: PaginationOptions) { diff --git a/src/cli.ts b/src/cli.ts index f3f8071f6..985ad66bb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -157,6 +157,11 @@ export const cliOptions = { describe: 'Whether to enable vision tools', hidden: true, }, + experimentalStructuredContent: { + type: 'boolean', + describe: 'Whether to output structured formatted content.', + hidden: true, + }, experimentalIncludeAllPages: { type: 'boolean', describe: diff --git a/src/formatters/SnapshotFormatter.ts b/src/formatters/SnapshotFormatter.ts new file mode 100644 index 000000000..dabd937e7 --- /dev/null +++ b/src/formatters/SnapshotFormatter.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import type {TextSnapshot, TextSnapshotNode} from '../McpContext.js'; + +export class SnapshotFormatter { + #snapshot: TextSnapshot; + + constructor(snapshot: TextSnapshot) { + this.#snapshot = snapshot; + } + + toString(): string { + const chunks: string[] = []; + const root = this.#snapshot.root; + + // Top-level content of the snapshot. + if ( + this.#snapshot.verbose && + this.#snapshot.hasSelectedElement && + !this.#snapshot.selectedElementUid + ) { + chunks.push(`Note: there is a selected element in the DevTools Elements panel but it is not included into the current a11y tree snapshot. +Get a verbose snapshot to include all elements if you are interested in the selected element.\n\n`); + } + + chunks.push(this.#formatNode(root, 0)); + return chunks.join(''); + } + + toJSON(): object { + return this.#nodeToJSON(this.#snapshot.root); + } + + #formatNode(node: TextSnapshotNode, depth = 0): string { + const chunks: string[] = []; + const attributes = this.#getAttributes(node); + const line = + ' '.repeat(depth * 2) + + attributes.join(' ') + + (node.id === this.#snapshot.selectedElementUid + ? ' [selected in the DevTools Elements panel]' + : '') + + '\n'; + chunks.push(line); + + for (const child of node.children) { + chunks.push(this.#formatNode(child, depth + 1)); + } + return chunks.join(''); + } + + #nodeToJSON(node: TextSnapshotNode): object { + const rawAttrs = this.#getAttributesMap(node); + const children = node.children.map(child => this.#nodeToJSON(child)); + const result: Record = structuredClone(rawAttrs); + if (children.length > 0) { + result.children = children; + } + return result; + } + + #getAttributes(serializedAXNodeRoot: TextSnapshotNode): string[] { + const attributes = [`uid=${serializedAXNodeRoot.id}`]; + + if (serializedAXNodeRoot.role) { + attributes.push( + serializedAXNodeRoot.role === 'none' + ? 'ignored' + : serializedAXNodeRoot.role, + ); + } + if (serializedAXNodeRoot.name) { + attributes.push(`"${serializedAXNodeRoot.name}"`); + } + + const simpleAttrs = this.#getAttributesMap( + serializedAXNodeRoot, + /* excludeSpecial */ true, + ); + + for (const attr of Object.keys(serializedAXNodeRoot).sort()) { + if (excludedAttributes.has(attr)) { + continue; + } + + const mapped = booleanPropertyMap[attr]; + if (mapped && simpleAttrs[mapped]) { + attributes.push(mapped); + } + + const val = simpleAttrs[attr]; + if (val === true) { + attributes.push(attr); + } else if (typeof val === 'string' || typeof val === 'number') { + attributes.push(`${attr}="${val}"`); + } + } + + return attributes; + } + + #getAttributesMap( + node: TextSnapshotNode, + excludeSpecial = false, + ): Record { + const result: Record = {}; + if (!excludeSpecial) { + result.id = node.id; + if (node.role) { + result.role = node.role; + } + if (node.name) { + result.name = node.name; + } + } + + // Re-implementing the exact logic from original function for #getAttributes to be safe: + return { + ...result, + ...this.#extractedAttributes(node), + }; + } + + #extractedAttributes(node: TextSnapshotNode): Record { + const result: Record = {}; + + for (const attr of Object.keys(node).sort()) { + if (excludedAttributes.has(attr)) { + continue; + } + const value = (node as unknown as Record)[attr]; + if (typeof value === 'boolean') { + if (booleanPropertyMap[attr]) { + result[booleanPropertyMap[attr]] = true; + } + if (value) { + result[attr] = true; + } + } else if (typeof value === 'string' || typeof value === 'number') { + result[attr] = value; + } + } + return result; + } +} + +const booleanPropertyMap: Record = { + disabled: 'disableable', + expanded: 'expandable', + focused: 'focusable', + selected: 'selectable', +}; + +const excludedAttributes = new Set([ + 'id', + 'role', + 'name', + 'elementHandle', + 'children', + 'backendNodeId', +]); diff --git a/src/formatters/snapshotFormatter.ts b/src/formatters/snapshotFormatter.ts deleted file mode 100644 index 15464dd22..000000000 --- a/src/formatters/snapshotFormatter.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type {TextSnapshot, TextSnapshotNode} from '../McpContext.js'; - -export function formatSnapshotNode( - root: TextSnapshotNode, - snapshot?: TextSnapshot, - depth = 0, -): string { - const chunks: string[] = []; - - if (depth === 0) { - // Top-level content of the snapshot. - if ( - snapshot?.verbose && - snapshot?.hasSelectedElement && - !snapshot.selectedElementUid - ) { - chunks.push(`Note: there is a selected element in the DevTools Elements panel but it is not included into the current a11y tree snapshot. -Get a verbose snapshot to include all elements if you are interested in the selected element.\n\n`); - } - } - - const attributes = getAttributes(root); - const line = - ' '.repeat(depth * 2) + - attributes.join(' ') + - (root.id === snapshot?.selectedElementUid - ? ' [selected in the DevTools Elements panel]' - : '') + - '\n'; - chunks.push(line); - - for (const child of root.children) { - chunks.push(formatSnapshotNode(child, snapshot, depth + 1)); - } - - return chunks.join(''); -} - -function getAttributes(serializedAXNodeRoot: TextSnapshotNode): string[] { - const attributes = [`uid=${serializedAXNodeRoot.id}`]; - if (serializedAXNodeRoot.role) { - // To match representation in DevTools. - attributes.push( - serializedAXNodeRoot.role === 'none' - ? 'ignored' - : serializedAXNodeRoot.role, - ); - } - if (serializedAXNodeRoot.name) { - attributes.push(`"${serializedAXNodeRoot.name}"`); - } - - const excluded = new Set([ - 'id', - 'role', - 'name', - 'elementHandle', - 'children', - 'backendNodeId', - ]); - - const booleanPropertyMap: Record = { - disabled: 'disableable', - expanded: 'expandable', - focused: 'focusable', - selected: 'selectable', - }; - - for (const attr of Object.keys(serializedAXNodeRoot).sort()) { - if (excluded.has(attr)) { - continue; - } - const value = (serializedAXNodeRoot as unknown as Record)[ - attr - ]; - if (typeof value === 'boolean') { - if (booleanPropertyMap[attr]) { - attributes.push(booleanPropertyMap[attr]); - } - if (value) { - attributes.push(attr); - } - } else if (typeof value === 'string' || typeof value === 'number') { - attributes.push(`${attr}="${value}"`); - } - } - return attributes; -} diff --git a/src/main.ts b/src/main.ts index 049214d54..143c7a9a8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -153,10 +153,22 @@ function registerTool(tool: ToolDefinition): void { response, context, ); - const content = await response.handle(tool.name, context); - return { + const {content, structuredContent} = await response.handle( + tool.name, + context, + ); + const result: CallToolResult & { + structuredContent?: Record; + } = { content, }; + if (args.experimentalStructuredContent) { + result.structuredContent = structuredContent as Record< + string, + unknown + >; + } + return result; } catch (err) { logger(`${tool.name} error:`, err, err?.stack); let errorText = err && 'message' in err ? err.message : String(err); diff --git a/tests/McpResponse.test.js.snapshot b/tests/McpResponse.test.js.snapshot index 5f5f79045..461c98d2b 100644 --- a/tests/McpResponse.test.js.snapshot +++ b/tests/McpResponse.test.js.snapshot @@ -104,7 +104,7 @@ uid=1_0 RootWebArea "My test page" url="about:blank" `; -exports[`McpResponse > returns verbose snapshot 1`] = ` +exports[`McpResponse > returns verbose snapshot and structured content 1`] = ` # test response ## Latest page snapshot uid=1_0 RootWebArea "My test page" url="about:blank" @@ -116,13 +116,61 @@ uid=1_0 RootWebArea "My test page" url="about:blank" `; -exports[`McpResponse > saves snapshot to file 1`] = ` +exports[`McpResponse > returns verbose snapshot and structured content 2`] = ` +{ + "snapshot": { + "id": "1_0", + "role": "RootWebArea", + "name": "My test page", + "url": "about:blank", + "children": [ + { + "id": "1_1", + "role": "none", + "children": [ + { + "id": "1_2", + "role": "none", + "children": [ + { + "id": "1_3", + "role": "complementary", + "children": [ + { + "id": "1_4", + "role": "StaticText", + "name": "test", + "children": [ + { + "id": "1_5", + "role": "InlineTextBox", + "name": "test" + } + ] + } + ] + } + ] + } + ] + } + ] + } +} +`; + +exports[`McpResponse > saves snapshot to file and returns structured content 1`] = ` # test response -## Latest page snapshot Saved snapshot to `; -exports[`McpResponse > saves snapshot to file 2`] = ` +exports[`McpResponse > saves snapshot to file and returns structured content 2`] = ` +{ + "snapshotFilePath": "" +} +`; + +exports[`McpResponse > saves snapshot to file and returns structured content 3`] = ` uid=1_0 RootWebArea "My test page" url="about:blank" uid=1_1 ignored uid=1_2 ignored diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 70f8dbf37..f6c68b370 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -18,6 +18,7 @@ import { getTextContent, html, stabilizeResponseOutput, + stabilizeStructuredContent, withMcpContext, } from './utils.js'; @@ -25,9 +26,9 @@ describe('McpResponse', () => { it('list pages', async t => { await withMcpContext(async (response, context) => { response.setIncludePages(true); - const result = await response.handle('test', context); - assert.equal(result[0].type, 'text'); - t.assert.snapshot?.(getTextContent(result[0])); + const {content} = await response.handle('test', context); + assert.equal(content[0].type, 'text'); + t.assert.snapshot?.(getTextContent(content[0])); }); }); @@ -35,9 +36,9 @@ describe('McpResponse', () => { await withMcpContext(async (response, context) => { response.appendResponseLine('Testing 1'); response.appendResponseLine('Testing 2'); - const result = await response.handle('test', context); - assert.equal(result[0].type, 'text'); - t.assert.snapshot?.(getTextContent(result[0])); + const {content} = await response.handle('test', context); + assert.equal(content[0].type, 'text'); + t.assert.snapshot?.(getTextContent(content[0])); }); }); @@ -45,9 +46,9 @@ describe('McpResponse', () => { await withMcpContext(async (response, context) => { const page = context.getSelectedPage(); page.accessibility.snapshot = async () => null; - const result = await response.handle('test', context); - assert.equal(result[0].type, 'text'); - assert.deepStrictEqual(getTextContent(result[0]), `# test response`); + const {content} = await response.handle('test', context); + assert.equal(content[0].type, 'text'); + assert.deepStrictEqual(getTextContent(content[0]), `# test response`); }); }); @@ -63,9 +64,9 @@ describe('McpResponse', () => { ); await page.focus('button'); response.includeSnapshot(); - const result = await response.handle('test', context); - assert.equal(result[0].type, 'text'); - t.assert.snapshot?.(getTextContent(result[0])); + const {content} = await response.handle('test', context); + assert.equal(content[0].type, 'text'); + t.assert.snapshot?.(getTextContent(content[0])); }); }); @@ -81,26 +82,30 @@ describe('McpResponse', () => { ); await page.focus('input'); response.includeSnapshot(); - const result = await response.handle('test', context); - assert.equal(result[0].type, 'text'); - t.assert.snapshot?.(getTextContent(result[0])); + const {content} = await response.handle('test', context); + assert.equal(content[0].type, 'text'); + t.assert.snapshot?.(getTextContent(content[0])); }); }); - it('returns verbose snapshot', async t => { + it('returns verbose snapshot and structured content', async t => { await withMcpContext(async (response, context) => { const page = context.getSelectedPage(); await page.setContent(html``); response.includeSnapshot({ verbose: true, }); - const result = await response.handle('test', context); - assert.equal(result[0].type, 'text'); - t.assert.snapshot?.(getTextContent(result[0])); + const {content, structuredContent} = await response.handle( + 'test', + context, + ); + assert.equal(content[0].type, 'text'); + t.assert.snapshot?.(getTextContent(content[0])); + t.assert.snapshot?.(JSON.stringify(structuredContent, null, 2)); }); }); - it('saves snapshot to file', async t => { + it('saves snapshot to file and returns structured content', async t => { const filePath = join(tmpdir(), 'test-screenshot.png'); try { await withMcpContext(async (response, context) => { @@ -110,9 +115,21 @@ describe('McpResponse', () => { verbose: true, filePath, }); - const result = await response.handle('test', context); - assert.equal(result[0].type, 'text'); - t.assert.snapshot?.(stabilizeResponseOutput(getTextContent(result[0]))); + const {content, structuredContent} = await response.handle( + 'test', + context, + ); + assert.equal(content[0].type, 'text'); + t.assert.snapshot?.( + stabilizeResponseOutput(getTextContent(content[0])), + ); + t.assert.snapshot?.( + JSON.stringify( + stabilizeStructuredContent(structuredContent), + null, + 2, + ), + ); }); const content = await readFile(filePath, 'utf-8'); t.assert.snapshot?.(stabilizeResponseOutput(content)); @@ -124,44 +141,44 @@ describe('McpResponse', () => { it('adds throttling setting when it is not null', async t => { await withMcpContext(async (response, context) => { context.setNetworkConditions('Slow 3G'); - const result = await response.handle('test', context); - assert.equal(result[0].type, 'text'); - t.assert.snapshot?.(getTextContent(result[0])); + const {content} = await response.handle('test', context); + assert.equal(content[0].type, 'text'); + t.assert.snapshot?.(getTextContent(content[0])); }); }); it('does not include throttling setting when it is null', async () => { await withMcpContext(async (response, context) => { - const result = await response.handle('test', context); + const {content} = await response.handle('test', context); context.setNetworkConditions(null); - assert.equal(result[0].type, 'text'); - assert.strictEqual(getTextContent(result[0]), `# test response`); + assert.equal(content[0].type, 'text'); + assert.strictEqual(getTextContent(content[0]), `# test response`); }); }); it('adds image when image is attached', async () => { await withMcpContext(async (response, context) => { response.attachImage({data: 'imageBase64', mimeType: 'image/png'}); - const result = await response.handle('test', context); - assert.strictEqual(getTextContent(result[0]), `# test response`); - assert.equal(result[1].type, 'image'); - assert.strictEqual(getImageContent(result[1]).data, 'imageBase64'); - assert.strictEqual(getImageContent(result[1]).mimeType, 'image/png'); + const {content} = await response.handle('test', context); + assert.strictEqual(getTextContent(content[0]), `# test response`); + assert.equal(content[1].type, 'image'); + assert.strictEqual(getImageContent(content[1]).data, 'imageBase64'); + assert.strictEqual(getImageContent(content[1]).mimeType, 'image/png'); }); }); it('adds cpu throttling setting when it is over 1', async t => { await withMcpContext(async (response, context) => { context.setCpuThrottlingRate(4); - const result = await response.handle('test', context); - t.assert.snapshot?.(getTextContent(result[0])); + const {content} = await response.handle('test', context); + t.assert.snapshot?.(getTextContent(content[0])); }); }); it('does not include cpu throttling setting when it is 1', async () => { await withMcpContext(async (response, context) => { context.setCpuThrottlingRate(1); - const result = await response.handle('test', context); - assert.strictEqual(getTextContent(result[0]), `# test response`); + const {content} = await response.handle('test', context); + assert.strictEqual(getTextContent(content[0]), `# test response`); }); }); @@ -177,9 +194,9 @@ describe('McpResponse', () => { prompt('message', 'default'); }); await dialogPromise; - const result = await response.handle('test', context); + const {content} = await response.handle('test', context); await context.getDialog()?.dismiss(); - t.assert.snapshot?.(getTextContent(result[0])); + t.assert.snapshot?.(getTextContent(content[0])); }); }); @@ -195,9 +212,9 @@ describe('McpResponse', () => { alert('message'); }); await dialogPromise; - const result = await response.handle('test', context); + const {content} = await response.handle('test', context); await context.getDialog()?.dismiss(); - t.assert.snapshot?.(getTextContent(result[0])); + t.assert.snapshot?.(getTextContent(content[0])); }); }); @@ -207,8 +224,8 @@ describe('McpResponse', () => { context.getNetworkRequests = () => { return [getMockRequest({stableId: 1}), getMockRequest({stableId: 2})]; }; - const result = await response.handle('test', context); - t.assert.snapshot?.(getTextContent(result[0])); + const {content} = await response.handle('test', context); + t.assert.snapshot?.(getTextContent(content[0])); }); }); @@ -218,8 +235,8 @@ describe('McpResponse', () => { context.getNetworkRequests = () => { return [getMockRequest()]; }; - const result = await response.handle('test', context); - assert.strictEqual(getTextContent(result[0]), `# test response`); + const {content} = await response.handle('test', context); + assert.strictEqual(getTextContent(content[0]), `# test response`); }); }); @@ -249,9 +266,9 @@ describe('McpResponse', () => { }; response.attachNetworkRequest(1); - const result = await response.handle('test', context); + const {content} = await response.handle('test', context); - t.assert.snapshot?.(getTextContent(result[0])); + t.assert.snapshot?.(getTextContent(content[0])); }); }); @@ -266,8 +283,8 @@ describe('McpResponse', () => { return request; }; response.attachNetworkRequest(1); - const result = await response.handle('test', context); - t.assert.snapshot?.(getTextContent(result[0])); + const {content} = await response.handle('test', context); + t.assert.snapshot?.(getTextContent(content[0])); }); }); @@ -284,18 +301,18 @@ describe('McpResponse', () => { console.log('Hello from the test'); }); await consoleMessagePromise; - const result = await response.handle('test', context); - assert.ok(getTextContent(result[0])); - t.assert.snapshot?.(getTextContent(result[0])); + const {content} = await response.handle('test', context); + assert.ok(getTextContent(content[0])); + t.assert.snapshot?.(getTextContent(content[0])); }); }); it('adds a message when no console messages exist', async t => { await withMcpContext(async (response, context) => { response.setIncludeConsoleData(true); - const result = await response.handle('test', context); - assert.ok(getTextContent(result[0])); - t.assert.snapshot?.(getTextContent(result[0])); + const {content} = await response.handle('test', context); + assert.ok(getTextContent(content[0])); + t.assert.snapshot?.(getTextContent(content[0])); }); }); @@ -312,8 +329,8 @@ describe('McpResponse', () => { return [mockAggregatedIssue]; }; - const result = await response.handle('test', context); - const text = getTextContent(result[0]); + const {content} = await response.handle('test', context); + const text = getTextContent(content[0]); assert.ok(text.includes('')); }); }); @@ -354,8 +371,8 @@ describe('McpResponse network request filtering', () => { getMockRequest({resourceType: 'document'}), ]; }; - const result = await response.handle('test', context); - t.assert.snapshot?.(getTextContent(result[0])); + const {content} = await response.handle('test', context); + t.assert.snapshot?.(getTextContent(content[0])); }); }); @@ -371,8 +388,8 @@ describe('McpResponse network request filtering', () => { getMockRequest({resourceType: 'stylesheet'}), ]; }; - const result = await response.handle('test', context); - t.assert.snapshot?.(getTextContent(result[0])); + const {content} = await response.handle('test', context); + t.assert.snapshot?.(getTextContent(content[0])); }); }); @@ -388,8 +405,8 @@ describe('McpResponse network request filtering', () => { getMockRequest({resourceType: 'stylesheet'}), ]; }; - const result = await response.handle('test', context); - t.assert.snapshot?.(getTextContent(result[0])); + const {content} = await response.handle('test', context); + t.assert.snapshot?.(getTextContent(content[0])); }); }); @@ -405,9 +422,9 @@ describe('McpResponse network request filtering', () => { getMockRequest({resourceType: 'font'}), ]; }; - const result = await response.handle('test', context); + const {content} = await response.handle('test', context); - t.assert.snapshot?.(getTextContent(result[0])); + t.assert.snapshot?.(getTextContent(content[0])); }); }); @@ -425,8 +442,8 @@ describe('McpResponse network request filtering', () => { getMockRequest({resourceType: 'font'}), ]; }; - const result = await response.handle('test', context); - t.assert.snapshot?.(getTextContent(result[0])); + const {content} = await response.handle('test', context); + t.assert.snapshot?.(getTextContent(content[0])); }); }); }); @@ -437,8 +454,8 @@ describe('McpResponse network pagination', () => { const requests = Array.from({length: 5}, () => getMockRequest()); context.getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true); - const result = await response.handle('test', context); - const text = getTextContent(result[0]); + const {content} = await response.handle('test', context); + const text = getTextContent(content[0]); assert.ok(text.includes('Showing 1-5 of 5 (Page 1 of 1).')); assert.ok(!text.includes('Next page:')); assert.ok(!text.includes('Previous page:')); @@ -454,8 +471,8 @@ describe('McpResponse network pagination', () => { return requests; }; response.setIncludeNetworkRequests(true, {pageSize: 10}); - const result = await response.handle('test', context); - const text = getTextContent(result[0]); + const {content} = await response.handle('test', context); + const text = getTextContent(content[0]); assert.ok(text.includes('Showing 1-10 of 30 (Page 1 of 3).')); assert.ok(text.includes('Next page: 1')); assert.ok(!text.includes('Previous page:')); @@ -472,8 +489,8 @@ describe('McpResponse network pagination', () => { pageSize: 10, pageIdx: 1, }); - const result = await response.handle('test', context); - const text = getTextContent(result[0]); + const {content} = await response.handle('test', context); + const text = getTextContent(content[0]); assert.ok(text.includes('Showing 11-20 of 25 (Page 2 of 3).')); assert.ok(text.includes('Next page: 2')); assert.ok(text.includes('Previous page: 0')); @@ -488,8 +505,8 @@ describe('McpResponse network pagination', () => { pageSize: 2, pageIdx: 10, // Invalid page number }); - const result = await response.handle('test', context); - const text = getTextContent(result[0]); + const {content} = await response.handle('test', context); + const text = getTextContent(content[0]); assert.ok( text.includes('Invalid page number provided. Showing first page.'), ); diff --git a/tests/formatters/snapshotFormatter.test.ts b/tests/formatters/snapshotFormatter.test.ts index 3ec8d5df7..281cc17ab 100644 --- a/tests/formatters/snapshotFormatter.test.ts +++ b/tests/formatters/snapshotFormatter.test.ts @@ -9,8 +9,8 @@ import {describe, it} from 'node:test'; import type {ElementHandle} from 'puppeteer-core'; -import {formatSnapshotNode} from '../../src/formatters/snapshotFormatter.js'; -import type {TextSnapshotNode} from '../../src/McpContext.js'; +import {SnapshotFormatter} from '../../src/formatters/SnapshotFormatter.js'; +import type {TextSnapshot, TextSnapshotNode} from '../../src/McpContext.js'; describe('snapshotFormatter', () => { it('formats a snapshot with value properties', () => { @@ -35,7 +35,8 @@ describe('snapshotFormatter', () => { }, }; - const formatted = formatSnapshotNode(node); + const formatter = new SnapshotFormatter({root: node} as TextSnapshot); + const formatted = formatter.toString(); assert.strictEqual( formatted, `uid=1_1 textbox "textbox" value="value" @@ -66,7 +67,8 @@ describe('snapshotFormatter', () => { }, }; - const formatted = formatSnapshotNode(node); + const formatter = new SnapshotFormatter({root: node} as TextSnapshot); + const formatted = formatter.toString(); assert.strictEqual( formatted, `uid=1_1 button "button" disableable disabled @@ -97,7 +99,8 @@ describe('snapshotFormatter', () => { }, }; - const formatted = formatSnapshotNode(node); + const formatter = new SnapshotFormatter({root: node} as TextSnapshot); + const formatted = formatter.toString(); assert.strictEqual( formatted, `uid=1_1 checkbox "checkbox" checked @@ -139,7 +142,8 @@ describe('snapshotFormatter', () => { }, }; - const formatted = formatSnapshotNode(node); + const formatter = new SnapshotFormatter({root: node} as TextSnapshot); + const formatted = formatter.toString(); assert.strictEqual( formatted, `uid=1_1 root "root" @@ -171,13 +175,14 @@ describe('snapshotFormatter', () => { }, }; - const formatted = formatSnapshotNode(node, { + const formatter = new SnapshotFormatter({ snapshotId: '1', root: node, idToNode: new Map(), hasSelectedElement: true, verbose: false, }); + const formatted = formatter.toString(); t.assert.snapshot?.(formatted); }); @@ -204,13 +209,14 @@ describe('snapshotFormatter', () => { }, }; - const formatted = formatSnapshotNode(node, { + const formatter = new SnapshotFormatter({ snapshotId: '1', root: node, idToNode: new Map(), hasSelectedElement: true, verbose: true, }); + const formatted = formatter.toString(); t.assert.snapshot?.(formatted); }); @@ -237,7 +243,7 @@ describe('snapshotFormatter', () => { }, }; - const formatted = formatSnapshotNode(node, { + const formatter = new SnapshotFormatter({ snapshotId: '1', root: node, idToNode: new Map(), @@ -245,7 +251,45 @@ describe('snapshotFormatter', () => { selectedElementUid: '1_1', verbose: false, }); + const formatted = formatter.toString(); t.assert.snapshot?.(formatted); }); + + it('toJSON returns expected structure', () => { + const node: TextSnapshotNode = { + id: '1_1', + role: 'root', + name: 'root', + children: [ + { + id: '1_2', + role: 'button', + name: 'button', + disabled: true, + children: [], + elementHandle: async () => null, + }, + ], + elementHandle: async () => null, + }; + + const formatter = new SnapshotFormatter({root: node} as TextSnapshot); + const json = formatter.toJSON(); + + assert.deepStrictEqual(json, { + id: '1_1', + role: 'root', + name: 'root', + children: [ + { + id: '1_2', + role: 'button', + name: 'button', + disableable: true, + disabled: true, + }, + ], + }); + }); }); diff --git a/tests/tools/console.test.ts b/tests/tools/console.test.ts index 1c2ca9344..190281c55 100644 --- a/tests/tools/console.test.ts +++ b/tests/tools/console.test.ts @@ -37,7 +37,7 @@ describe('console', () => { ); await listConsoleMessages.handler({params: {}}, response, context); const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse[0]); + const textContent = getTextContent(formattedResponse.content[0]); assert.ok(textContent.includes('msgid=1 [error] This is an error')); }); }); @@ -48,7 +48,7 @@ describe('console', () => { await page.setContent(''); await listConsoleMessages.handler({params: {}}, response, context); const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse[0]); + const textContent = getTextContent(formattedResponse.content[0]); assert.ok(textContent.includes('msgid=1 [error] undefined (0 args)')); }); }); @@ -66,7 +66,7 @@ describe('console', () => { await issuePromise; await listConsoleMessages.handler({params: {}}, response, context); const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse[0]); + const textContent = getTextContent(formattedResponse.content[0]); assert.ok( textContent.includes( `msgid=1 [issue] An element doesn't have an autocomplete attribute (count: 1)`, @@ -89,7 +89,7 @@ describe('console', () => { await listConsoleMessages.handler({params: {}}, response, context); { const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse[0]); + const textContent = getTextContent(formattedResponse.content[0]); assert.ok( textContent.includes( `msgid=1 [issue] An element doesn't have an autocomplete attribute (count: 1)`, @@ -107,7 +107,7 @@ describe('console', () => { await anotherIssuePromise; { const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse[0]); + const textContent = getTextContent(formattedResponse.content[0]); assert.ok( textContent.includes( `msgid=2 [issue] An element doesn't have an autocomplete attribute (count: 1)`, @@ -134,7 +134,7 @@ describe('console', () => { context, ); const formattedResponse = await response.handle('test', context); - const textContent = getTextContent(formattedResponse[0]); + const textContent = getTextContent(formattedResponse.content[0]); assert.ok( textContent.includes('msgid=1 [error] This is an error'), 'Should contain console message body', @@ -164,7 +164,7 @@ describe('console', () => { context, ); const formattedResponse = await response2.handle('test', context); - t.assert.snapshot?.(getTextContent(formattedResponse[0])); + t.assert.snapshot?.(getTextContent(formattedResponse.content[0])); }); }); it('gets issue details with request id parsing', async t => { @@ -219,7 +219,7 @@ describe('console', () => { context, ); const formattedResponse = await response2.handle('test', context); - const rawText = getTextContent(formattedResponse[0]); + const rawText = getTextContent(formattedResponse.content[0]); const sanitizedText = rawText .replaceAll(/ID: \d+/g, 'ID: ') .replaceAll(/reqid=\d+/g, 'reqid=') diff --git a/tests/tools/network.test.ts b/tests/tools/network.test.ts index 26e195407..63a66f851 100644 --- a/tests/tools/network.test.ts +++ b/tests/tools/network.test.ts @@ -50,7 +50,7 @@ describe('network', () => { ); const responseData = await response.handle('list_request', context); t.assert.snapshot?.( - stabilizeResponseOutput(getTextContent(responseData[0])), + stabilizeResponseOutput(getTextContent(responseData.content[0])), ); }); }); @@ -77,7 +77,7 @@ describe('network', () => { ); const responseData = await response.handle('list_request', context); t.assert.snapshot?.( - stabilizeResponseOutput(getTextContent(responseData[0])), + stabilizeResponseOutput(getTextContent(responseData.content[0])), ); }); }); @@ -117,7 +117,7 @@ describe('network', () => { ); const responseData = await response.handle('list_request', context); t.assert.snapshot?.( - stabilizeResponseOutput(getTextContent(responseData[0])), + stabilizeResponseOutput(getTextContent(responseData.content[0])), ); }); }); @@ -171,7 +171,7 @@ describe('network', () => { const responseData = await response.handle('get_request', context); t.assert.snapshot?.( - stabilizeResponseOutput(getTextContent(responseData[0])), + stabilizeResponseOutput(getTextContent(responseData.content[0])), ); }); }); diff --git a/tests/utils.ts b/tests/utils.ts index c2d005ef1..6e480ffbb 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -188,6 +188,27 @@ export function html( `; } +export function stabilizeStructuredContent(content: unknown): unknown { + if (typeof content === 'string') { + return stabilizeResponseOutput(content); + } + if (Array.isArray(content)) { + return content.map(item => stabilizeStructuredContent(item)); + } + if (typeof content === 'object' && content !== null) { + const result: Record = {}; + for (const [key, value] of Object.entries(content)) { + if (key === 'snapshotFilePath' && typeof value === 'string') { + result[key] = ''; + } else { + result[key] = stabilizeStructuredContent(value); + } + } + return result; + } + return content; +} + export function stabilizeResponseOutput(text: unknown) { if (typeof text !== 'string') { throw new Error('Input must be string');