278 lines
7.4 KiB
TypeScript
278 lines
7.4 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import {pathToFileURL} from 'node:url';
|
|
import {parseArgs} from 'node:util';
|
|
|
|
import {GoogleGenAI, mcpToTool} from '@google/genai';
|
|
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
|
|
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
|
|
import {TestServer} from '../build/tests/server.js';
|
|
|
|
const ROOT_DIR = path.resolve(import.meta.dirname, '..');
|
|
const SCENARIOS_DIR = path.join(import.meta.dirname, 'eval_scenarios');
|
|
const SKILL_PATH = path.join(ROOT_DIR, 'skills', 'chrome-devtools', 'SKILL.md');
|
|
|
|
import type {CapturedFunctionCall, TestScenario} from './eval_result.ts';
|
|
import {Result} from './eval_result.ts';
|
|
export type {CapturedFunctionCall, TestScenario};
|
|
export {Result};
|
|
|
|
async function loadScenario(scenarioPath: string): Promise<TestScenario> {
|
|
const module = await import(pathToFileURL(scenarioPath).href);
|
|
if (!module.scenario) {
|
|
throw new Error(
|
|
`Scenario file ${scenarioPath} does not export a 'scenario' object.`,
|
|
);
|
|
}
|
|
return module.scenario;
|
|
}
|
|
|
|
async function runSingleScenario(
|
|
scenarioPath: string,
|
|
apiKey: string,
|
|
server: TestServer,
|
|
modelId: string,
|
|
debug: boolean,
|
|
includeSkill: boolean,
|
|
extraServerArgs: string[] = [],
|
|
): Promise<void> {
|
|
const debugLog = (...args: unknown[]) => {
|
|
if (debug) {
|
|
console.log(...args);
|
|
}
|
|
};
|
|
const absolutePath = path.resolve(scenarioPath);
|
|
debugLog(
|
|
`\n### Running Scenario: ${path.relative(ROOT_DIR, absolutePath)} ###`,
|
|
);
|
|
|
|
let client: Client | undefined;
|
|
let transport: StdioClientTransport | undefined;
|
|
|
|
try {
|
|
const loadedScenario = await loadScenario(absolutePath);
|
|
const scenario = {...loadedScenario};
|
|
|
|
// Prepend skill content if requested
|
|
if (includeSkill) {
|
|
if (!fs.existsSync(SKILL_PATH)) {
|
|
throw new Error(
|
|
`Skill file not found at ${SKILL_PATH}. Please ensure the skill file exists.`,
|
|
);
|
|
}
|
|
const skillContent = fs.readFileSync(SKILL_PATH, 'utf-8');
|
|
scenario.prompt = `${skillContent}\n\n---\n\n${scenario.prompt}`;
|
|
}
|
|
|
|
// Append random queryid to avoid caching issues and test distinct runs
|
|
const randomId = Math.floor(Math.random() * 1000000);
|
|
scenario.prompt = `${scenario.prompt}\nqueryid=${randomId}`;
|
|
|
|
if (scenario.htmlRoute) {
|
|
server.addHtmlRoute(
|
|
scenario.htmlRoute.path,
|
|
scenario.htmlRoute.htmlContent,
|
|
);
|
|
scenario.prompt = scenario.prompt.replace(
|
|
'<TEST_URL>',
|
|
server.getRoute(scenario.htmlRoute.path),
|
|
);
|
|
}
|
|
|
|
// Path to the compiled MCP server
|
|
const serverPath = path.join(
|
|
ROOT_DIR,
|
|
'build/src/bin/chrome-devtools-mcp.js',
|
|
);
|
|
if (!fs.existsSync(serverPath)) {
|
|
throw new Error(
|
|
`MCP server not found at ${serverPath}. Please run 'npm run build' first.`,
|
|
);
|
|
}
|
|
|
|
// Environment variables
|
|
const env: Record<string, string> = {};
|
|
Object.entries(process.env).forEach(([key, value]) => {
|
|
if (value !== undefined) {
|
|
env[key] = value;
|
|
}
|
|
});
|
|
env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS'] = 'true';
|
|
|
|
const args = [serverPath];
|
|
if (!debug) {
|
|
args.push('--headless');
|
|
}
|
|
if (scenario.serverArgs) {
|
|
args.push(...scenario.serverArgs);
|
|
}
|
|
if (extraServerArgs.length > 0) {
|
|
args.push(...extraServerArgs);
|
|
}
|
|
|
|
transport = new StdioClientTransport({
|
|
command: 'node',
|
|
args,
|
|
env,
|
|
stderr: debug ? 'inherit' : 'ignore',
|
|
});
|
|
|
|
client = new Client(
|
|
{name: 'gemini-eval-client', version: '1.0.0'},
|
|
{capabilities: {}},
|
|
);
|
|
|
|
await client.connect(transport);
|
|
|
|
const allCalls: CapturedFunctionCall[] = [];
|
|
const originalCallTool = client.callTool.bind(client);
|
|
client.callTool = async (request, schema) => {
|
|
// NOTE: request.name is the original name as the MCP client sees it.
|
|
// mcpToTool handles the conversion from Gemini sanitized name to original name.
|
|
debugLog(
|
|
`Executing tool: ${request.name} with args: ${JSON.stringify(request.arguments)}`,
|
|
);
|
|
allCalls.push({
|
|
name: request.name,
|
|
args: (request.arguments as Record<string, unknown>) || {},
|
|
});
|
|
const response = await originalCallTool(request, schema);
|
|
debugLog(`Tool response: ${JSON.stringify(response)}`);
|
|
return response;
|
|
};
|
|
|
|
const ai = new GoogleGenAI({apiKey});
|
|
|
|
debugLog(`\n--- Prompt ---\n${scenario.prompt}`);
|
|
|
|
const result = await ai.models.generateContent({
|
|
model: modelId,
|
|
contents: scenario.prompt,
|
|
config: {
|
|
tools: [mcpToTool(client)],
|
|
automaticFunctionCalling: {
|
|
maximumRemoteCalls: scenario.maxTurns,
|
|
},
|
|
},
|
|
});
|
|
|
|
debugLog(`\n--- Response ---\n${result.text}`);
|
|
|
|
debugLog('\nVerifying expectations...');
|
|
scenario.expectations(new Result(allCalls, args));
|
|
} finally {
|
|
try {
|
|
await client?.close();
|
|
} catch (e) {
|
|
console.error('Error closing client:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const apiKey = process.env.GEMINI_API_KEY;
|
|
if (!apiKey) {
|
|
throw new Error('GEMINI_API_KEY environment variable is required.');
|
|
}
|
|
|
|
const {values, positionals} = parseArgs({
|
|
options: {
|
|
model: {
|
|
type: 'string',
|
|
default: 'gemini-3-flash-preview',
|
|
},
|
|
debug: {
|
|
type: 'boolean',
|
|
default: false,
|
|
},
|
|
repeat: {
|
|
type: 'boolean',
|
|
default: false,
|
|
},
|
|
'include-skill': {
|
|
type: 'boolean',
|
|
default: false,
|
|
},
|
|
'server-args': {
|
|
type: 'string',
|
|
},
|
|
},
|
|
allowPositionals: true,
|
|
});
|
|
|
|
const modelId = values.model;
|
|
const debug = values.debug;
|
|
const repeat = values.repeat;
|
|
const includeSkill = values['include-skill'];
|
|
const extraServerArgs = values['server-args']
|
|
? values['server-args'].split(/\s+/)
|
|
: [];
|
|
|
|
const scenarioFiles =
|
|
positionals.length > 0
|
|
? positionals.map(p => path.resolve(p))
|
|
: fs
|
|
.readdirSync(SCENARIOS_DIR)
|
|
.filter(file => file.endsWith('.ts') || file.endsWith('.js'))
|
|
.map(file => path.join(SCENARIOS_DIR, file));
|
|
|
|
const server = new TestServer(TestServer.randomPort());
|
|
await server.start();
|
|
|
|
let successCount = 0;
|
|
let failureCount = 0;
|
|
|
|
try {
|
|
for (const scenarioPath of scenarioFiles) {
|
|
for (let i = 1; i <= (repeat ? 3 : 1); i++) {
|
|
try {
|
|
if (debug) {
|
|
console.log(
|
|
`Running scenario: ${path.relative(ROOT_DIR, scenarioPath)} (Run ${i}/3)`,
|
|
);
|
|
}
|
|
await runSingleScenario(
|
|
scenarioPath,
|
|
apiKey,
|
|
server,
|
|
modelId,
|
|
debug,
|
|
includeSkill,
|
|
extraServerArgs,
|
|
);
|
|
console.log(`✔ ${path.relative(ROOT_DIR, scenarioPath)} (Run ${i})`);
|
|
successCount++;
|
|
} catch (e) {
|
|
console.error(
|
|
`✖ ${path.relative(ROOT_DIR, scenarioPath)} (Run ${i})`,
|
|
);
|
|
console.error(e);
|
|
failureCount++;
|
|
} finally {
|
|
server.restore();
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
|
|
console.log(`\nSummary: ${successCount} passed, ${failureCount} failed`);
|
|
|
|
if (failureCount > 0) {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main().catch(error => {
|
|
console.error('Fatal error:', error);
|
|
process.exit(1);
|
|
});
|