1- import { mkdirSync , writeFileSync , readFileSync , existsSync } from "node:fs" ;
1+ import { mkdirSync , writeFileSync , readFileSync } from "node:fs" ;
22import path from "node:path" ;
33import { InteractiveCLI , poll } from "./interactive-cli.js" ;
44import { AgentTestRunner } from "./agent-test-runner.js" ;
5+ import {
6+ ParsedToolLog ,
7+ getToolName ,
8+ toolArgumentsMatch ,
9+ getToolArgumentsDebug ,
10+ } from "./tool-matcher.js" ;
11+ import fs from "fs" ;
12+ import { throwFailure } from "./logging.js" ;
513
614const READY_PROMPT = "Type your message" ;
715
16+ interface ParsedTelemetryLog {
17+ attributes ?: {
18+ "event.name" ?: string ;
19+ function_name ?: string ;
20+ function_args ?: string ;
21+ success ?: boolean ;
22+ duration_ms ?: number ;
23+ } ;
24+ scopeMetrics ?: {
25+ metrics : {
26+ descriptor : {
27+ name : string ;
28+ } ;
29+ } [ ] ;
30+ } [ ] ;
31+ }
32+
833export class GeminiCliRunner implements AgentTestRunner {
934 private readonly cli : InteractiveCLI ;
1035 private readonly telemetryPath : string ;
1136 private readonly telemetryTimeout = 15000 ;
1237
38+ // Determines which tools to start from for this turn so we don't detect tool
39+ // calls from previous turns
40+ private turnToolIndex = 0 ;
41+
1342 constructor (
1443 private readonly testName : string ,
1544 testDir : string ,
@@ -29,8 +58,6 @@ export class GeminiCliRunner implements AgentTestRunner {
2958 } ,
3059 mcpServers : {
3160 firebase : {
32- // TODO: Add a mode where developers can run against their npm run watch command
33- // command: path.resolve(runDir, "../../../../../lib/bin/firebase.js"),
3461 command : "firebase" ,
3562 args : [ "experimental:mcp" ] ,
3663 } ,
@@ -52,6 +79,8 @@ export class GeminiCliRunner implements AgentTestRunner {
5279 }
5380
5481 async type ( text : string ) : Promise < void > {
82+ const toolLogs = this . readToolLogs ( ) ;
83+ this . turnToolIndex = toolLogs . length ;
5584 return this . cli . type ( text ) ;
5685 }
5786
@@ -67,21 +96,115 @@ export class GeminiCliRunner implements AgentTestRunner {
6796 * Reads the agent's telemetry file and looks for the given event. Throws if
6897 * the event is not found
6998 */
70- async expectTelemetryEvent ( eventName : string ) : Promise < void > {
71- // NOTE: This doesn't take into account "turns" yet. It will likely look
72- // through the entire history, not just the last turn
73- const found = await poll ( ( ) => {
74- if ( ! existsSync ( this . telemetryPath ) ) {
99+ async expectToolCalls ( tools : string [ ] ) : Promise < void > {
100+ await this . waitForTelemetryReady ( ) ;
101+
102+ // We still need to poll because telemetry can take time to write each turn
103+ let messages : string [ ] = [ ] ;
104+ const success = await poll ( ( ) => {
105+ messages = [ ] ;
106+ let allSucceeded = true ;
107+ // Start at this.turnToolIndex so we only read the tools used this turn
108+ const toolLogs = this . readToolLogs ( ) . slice ( this . turnToolIndex ) ;
109+ const foundToolNames = toolLogs . map ( ( log ) => log . name ) ;
110+ for ( const toolDef of tools ) {
111+ const toolName = getToolName ( toolDef ) ;
112+ const matchingTool = toolLogs . find ( ( log ) => log . name === toolName ) ;
113+ if ( ! matchingTool ) {
114+ messages . push (
115+ `Did not find expected tool call: "${ toolName } " in the telemetry log. Found [${ foundToolNames } ]` ,
116+ ) ;
117+ allSucceeded = false ;
118+ } else {
119+ const foundMatchingArguments = toolLogs . some (
120+ ( log ) => log . name === toolName && toolArgumentsMatch ( toolDef , log ) ,
121+ ) ;
122+ if ( ! foundMatchingArguments ) {
123+ messages . push (
124+ `Tool arguments matcher "${ getToolArgumentsDebug ( toolDef ) } " for "${ toolName } " did not match any tool results in the telemetry log. All tools are: [${ JSON . stringify ( toolLogs ) } ]` ,
125+ ) ;
126+ allSucceeded = false ;
127+ }
128+ }
129+ }
130+ return allSucceeded ;
131+ } , this . telemetryTimeout ) ;
132+
133+ if ( ! success ) {
134+ throwFailure ( messages . join ( "\n" ) ) ;
135+ }
136+ }
137+
138+ // Implementation for this is borrowed from the Gemini CLI's test-helper
139+ private async waitForTelemetryReady ( ) {
140+ // Wait for telemetry file to exist and have content
141+ await poll ( ( ) => {
142+ if ( ! fs . existsSync ( this . telemetryPath ) ) return false ;
143+ try {
144+ const content = readFileSync ( this . telemetryPath , "utf-8" ) ;
145+ // Check if file has at lease one event in it
146+ return content . includes ( '"event.name"' ) ;
147+ } catch {
75148 return false ;
76149 }
77- const content = readFileSync ( this . telemetryPath , "utf-8" ) ;
78- return content . includes ( eventName ) ;
79150 } , this . telemetryTimeout ) ;
151+ }
152+
153+ // Implementation for this is borrowed from the Gemini CLI's test-helper
154+ private readToolLogs ( ) : ParsedToolLog [ ] {
155+ const parsedLogs = this . readAndParseTelemetryLog ( ) ;
156+ const logs : ParsedToolLog [ ] = [ ] ;
80157
81- if ( ! found ) {
82- throw new Error ( `Did not find expected telemetry event: "${ eventName } " in the telemetry log` ) ;
83- } else {
84- console . log ( ` [FOUND] expectTelemetryEvent: ${ eventName } ` ) ;
158+ for ( const logData of parsedLogs ) {
159+ // Look for tool call logs
160+ if (
161+ logData . attributes ?. function_name &&
162+ logData . attributes [ "event.name" ] === "gemini_cli.tool_call"
163+ ) {
164+ logs . push ( {
165+ name : logData . attributes . function_name ,
166+ args : logData . attributes . function_args ?? "{}" ,
167+ success : logData . attributes . success ?? false ,
168+ duration_ms : logData . attributes . duration_ms ?? 0 ,
169+ } ) ;
170+ }
171+ }
172+
173+ return logs ;
174+ }
175+
176+ // Implementation for this is borrowed from the Gemini CLI's test-helper
177+ private readAndParseTelemetryLog ( ) : ParsedTelemetryLog [ ] {
178+ const logFilePath = this . telemetryPath ;
179+ if ( ! logFilePath || ! fs . existsSync ( logFilePath ) ) {
180+ return [ ] ;
181+ }
182+
183+ const content = readFileSync ( logFilePath , "utf-8" ) ;
184+
185+ // Split the content into individual JSON objects
186+ // They are separated by " }\n{"
187+ const jsonObjects = content
188+ . split ( / } \n { / )
189+ . map ( ( obj , index , array ) => {
190+ // Add back the braces we removed during split
191+ if ( index > 0 ) obj = "{" + obj ;
192+ if ( index < array . length - 1 ) obj = obj + "}" ;
193+ return obj . trim ( ) ;
194+ } )
195+ . filter ( ( obj ) => obj ) ;
196+
197+ const logs : ParsedTelemetryLog [ ] = [ ] ;
198+
199+ for ( const jsonStr of jsonObjects ) {
200+ try {
201+ const logData = JSON . parse ( jsonStr ) ;
202+ logs . push ( logData ) ;
203+ } catch ( e ) {
204+ // Skip objects that aren't valid JSON
205+ }
85206 }
207+
208+ return logs ;
86209 }
87210}
0 commit comments