diff --git a/src/bin/chrome-devtools.ts b/src/bin/chrome-devtools.ts index eb967a418..2b9160b06 100644 --- a/src/bin/chrome-devtools.ts +++ b/src/bin/chrome-devtools.ts @@ -11,8 +11,14 @@ import process from 'node:process'; import yargs, {type Options, type PositionalOptions} from 'yargs'; import {hideBin} from 'yargs/helpers'; -import {startDaemon, stopDaemon, sendCommand} from '../daemon/client.js'; +import { + startDaemon, + stopDaemon, + sendCommand, + handleResponse, +} from '../daemon/client.js'; import {isDaemonRunning} from '../daemon/utils.js'; +import type {CallToolResult} from '../third_party/index.js'; import {VERSION} from '../version.js'; import {commands} from './cliDefinitions.js'; @@ -25,6 +31,8 @@ if (argv.length === 0 || argv[0] === '--custom-help') { process.exit(0); } +const defaultArgs = ['--viaCli', '--experimentalStructuredContent']; + const y = yargs(argv) .scriptName('chrome-devtools') .showHelpOnFail(true) @@ -44,7 +52,7 @@ y.command( // Extract args after 'start' const startIndex = process.argv.indexOf('start'); const args = startIndex !== -1 ? process.argv.slice(startIndex + 1) : []; - await startDaemon([...args, '--via-cli']); + await startDaemon([...args, ...defaultArgs]); }, ); @@ -75,6 +83,10 @@ for (const [commandName, commandDef] of Object.entries(commands)) { commandStr, commandDef.description, y => { + y.option('format', { + choices: ['text', 'json'], + default: 'text', + }); for (const [argName, opt] of Object.entries(args)) { const type = opt.type === 'integer' || opt.type === 'number' @@ -115,7 +127,7 @@ for (const [commandName, commandDef] of Object.entries(commands)) { async argv => { try { if (!isDaemonRunning()) { - await startDaemon(['--via-cli']); + await startDaemon(defaultArgs); } const commandArgs: Record = {}; @@ -132,7 +144,12 @@ for (const [commandName, commandDef] of Object.entries(commands)) { }); if (response.success) { - console.log(response.result); + console.log( + handleResponse( + JSON.parse(response.result) as unknown as CallToolResult, + argv['format'] as 'json' | 'text', + ), + ); } else { console.error('Error:', response.error); process.exit(1); diff --git a/src/daemon/client.ts b/src/daemon/client.ts index 090a058b5..f741ed64b 100644 --- a/src/daemon/client.ts +++ b/src/daemon/client.ts @@ -9,6 +9,7 @@ import fs from 'node:fs'; import net from 'node:net'; import {logger} from '../logger.js'; +import type {CallToolResult} from '../third_party/index.js'; import {PipeTransport} from '../third_party/index.js'; import type {DaemonMessage, DaemonResponse} from './types.js'; @@ -124,3 +125,27 @@ export async function stopDaemon() { await sendCommand({method: 'stop'}); } + +export function handleResponse( + response: CallToolResult, + format: 'json' | 'text', +): string { + if (response.isError) { + return JSON.stringify(response.content); + } + if (format === 'json') { + if (response.structuredContent) { + return JSON.stringify(response.structuredContent); + } + // Fall-through to text for backward compatibility. + } + const chunks = []; + for (const content of response.content) { + if (content.type === 'text') { + chunks.push(content.text); + } else { + throw new Error('Not supported response content type'); + } + } + return format === 'text' ? chunks.join('') : JSON.stringify(chunks); +} diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 1266fea3f..885bf1b6a 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -16,6 +16,7 @@ export type DaemonMessage = export interface DaemonResponse { success: boolean; - result: unknown; + // Stringified CallToolResult. + result: string; error: unknown; } diff --git a/tests/daemon/client.test.ts b/tests/daemon/client.test.ts index 626c89933..1ec9a7adb 100644 --- a/tests/daemon/client.test.ts +++ b/tests/daemon/client.test.ts @@ -7,43 +7,111 @@ import assert from 'node:assert'; import {describe, it, afterEach} from 'node:test'; -import {startDaemon, stopDaemon} from '../../src/daemon/client.js'; +import { + handleResponse, + startDaemon, + stopDaemon, +} from '../../src/daemon/client.js'; import {isDaemonRunning} from '../../src/daemon/utils.js'; describe('daemon client', () => { - afterEach(async () => { - if (isDaemonRunning()) { + describe('start/stop', () => { + afterEach(async () => { + if (isDaemonRunning()) { + await stopDaemon(); + // Wait a bit for the daemon to fully terminate and clean up its files. + await new Promise(resolve => setTimeout(resolve, 1000)); + } + }); + + it('should start and stop daemon', async () => { + assert.ok(!isDaemonRunning(), 'Daemon should not be running initially'); + + await startDaemon(); + assert.ok(isDaemonRunning(), 'Daemon should be running after start'); + await stopDaemon(); - // Wait a bit for the daemon to fully terminate and clean up its files. await new Promise(resolve => setTimeout(resolve, 1000)); - } - }); + assert.ok(!isDaemonRunning(), 'Daemon should not be running after stop'); + }); - it('should start and stop daemon', async () => { - assert.ok(!isDaemonRunning(), 'Daemon should not be running initially'); + it('should handle starting daemon when already running', async () => { + await startDaemon(); + assert.ok(isDaemonRunning(), 'Daemon should be running'); - await startDaemon(); - assert.ok(isDaemonRunning(), 'Daemon should be running after start'); + // Starting again should be a no-op + await startDaemon(); + assert.ok(isDaemonRunning(), 'Daemon should still be running'); + }); - await stopDaemon(); - await new Promise(resolve => setTimeout(resolve, 1000)); - assert.ok(!isDaemonRunning(), 'Daemon should not be running after stop'); + it('should handle stopping daemon when not running', async () => { + assert.ok(!isDaemonRunning(), 'Daemon should not be running initially'); + + // Stopping when not running should be a no-op + await stopDaemon(); + assert.ok(!isDaemonRunning(), 'Daemon should still not be running'); + }); }); - it('should handle starting daemon when already running', async () => { - await startDaemon(); - assert.ok(isDaemonRunning(), 'Daemon should be running'); + describe('parsing', () => { + it('handles MCP response with text format', () => { + const textResponse = {content: [{type: 'text' as const, text: 'test'}]}; + assert.strictEqual(handleResponse(textResponse, 'text'), 'test'); + }); - // Starting again should be a no-op - await startDaemon(); - assert.ok(isDaemonRunning(), 'Daemon should still be running'); - }); + it('handles JSON response', () => { + const jsonResponse = { + content: [], + structuredContent: { + test: 'data', + number: 123, + }, + }; + assert.strictEqual( + handleResponse(jsonResponse, 'json'), + JSON.stringify(jsonResponse.structuredContent), + ); + }); + + it('handles error response when isError is true', () => { + const errorResponse = { + isError: true, + content: [{type: 'text' as const, text: 'Something went wrong'}], + }; + assert.strictEqual( + handleResponse(errorResponse, 'text'), + JSON.stringify(errorResponse.content), + ); + }); - it('should handle stopping daemon when not running', async () => { - assert.ok(!isDaemonRunning(), 'Daemon should not be running initially'); + it('handles text response when json format is requested but no structured content', () => { + const textResponse = { + content: [{type: 'text' as const, text: 'Fall through text'}], + }; + assert.deepStrictEqual( + handleResponse(textResponse, 'json'), + JSON.stringify(['Fall through text']), + ); + }); - // Stopping when not running should be a no-op - await stopDaemon(); - assert.ok(!isDaemonRunning(), 'Daemon should still not be running'); + it('throws error for unsupported content type', () => { + const unsupportedContentResponse = { + content: [ + { + type: 'resource' as const, + resource: { + uri: 'data:image/png;base64,base64data', + blob: 'base64data', + mimeType: 'image/png', + }, + }, + ], + structuredContent: {}, + }; + assert.throws( + () => handleResponse(unsupportedContentResponse, 'text'), + new Error('Not supported response content type'), + ); + }); }); });