Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions src/bin/chrome-devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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)
Expand All @@ -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]);
},
);

Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, unknown> = {};
Expand All @@ -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);
Expand Down
25 changes: 25 additions & 0 deletions src/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
3 changes: 2 additions & 1 deletion src/daemon/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type DaemonMessage =

export interface DaemonResponse {
success: boolean;
result: unknown;
// Stringified CallToolResult.
result: string;
error: unknown;
}
118 changes: 93 additions & 25 deletions tests/daemon/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
);
});
});
});
Loading