11import { describe , it , expect } from "vitest" ;
2- import { runConversation } from "./conversation-runner.js" ;
2+ import {
3+ runConversation ,
4+ fillAndVerifySend ,
5+ readUserMessageCount ,
6+ } from "./conversation-runner.js" ;
37import type { ConversationTurn , Page } from "./conversation-runner.js" ;
48
59/**
@@ -31,10 +35,39 @@ interface PageScript {
3135 throwOnPress ?: Error ;
3236 // Recorded inputs the runner sent; tests assert on these.
3337 recorded ?: { fills : string [ ] ; presses : string [ ] } ;
38+ // Scripted user-message counts for the send-verification retry loop.
39+ // When provided, `page.evaluate` checks whether the evaluate function
40+ // body references user-message selectors and returns from this queue
41+ // instead of the main `evaluateValues` queue.
42+ userMessageValues ?: number [ ] ;
43+ }
44+
45+ /**
46+ * Wrap an evaluate function so that user-message reads (from
47+ * `readUserMessageCount`) return a monotonically increasing count —
48+ * simulating that the user message appeared on first try after each
49+ * fill+press. This prevents `fillAndVerifySend` from burning 2s×3
50+ * retries in tests that don't care about the send-verification loop.
51+ */
52+ function wrapEvaluateForUserMessages (
53+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54+ inner : ( ...args : any [ ] ) => Promise < any > ,
55+ ) : Page [ "evaluate" ] {
56+ let userCalls = 0 ;
57+ return ( async < R > ( fn : ( ) => R ) : Promise < R > => {
58+ if ( fn . toString ( ) . includes ( "copilot-user-message" ) ) {
59+ return userCalls ++ as never ;
60+ }
61+ return inner ( fn ) as Promise < R > ;
62+ } ) as Page [ "evaluate" ] ;
3463}
3564
3665function makePage ( script : PageScript = { } ) : Page {
3766 const queue = [ ...( script . evaluateValues ?? [ ] ) ] ;
67+ const userQueue = [ ...( script . userMessageValues ?? [ ] ) ] ;
68+ // Auto-succeed counter: first user-message read = 0 (baseline),
69+ // subsequent reads = 1 (growth detected → verify loop succeeds).
70+ let autoUserCalls = 0 ;
3871 return {
3972 async waitForSelector ( ) {
4073 // No-op — the runner uses this only to confirm the chat input exists.
@@ -49,6 +82,25 @@ function makePage(script: PageScript = {}): Page {
4982 } ,
5083 async evaluate ( fn ) {
5184 if ( script . evaluate ) return script . evaluate ( fn ) as never ;
85+
86+ // Detect whether the evaluate call is reading user messages or
87+ // assistant messages by inspecting the function body. The
88+ // readUserMessageCount function references "copilot-user-message"
89+ // while readMessageCount references "copilot-assistant-message".
90+ const fnBody = fn . toString ( ) ;
91+ if ( fnBody . includes ( "copilot-user-message" ) ) {
92+ if ( userQueue . length > 0 ) {
93+ if ( userQueue . length === 1 ) return userQueue [ 0 ] ! as never ;
94+ return userQueue . shift ( ) ! as never ;
95+ }
96+ // No explicit user-message script — auto-succeed so the
97+ // verify loop doesn't burn time in tests that only care about
98+ // assistant-message settling. Returns a monotonically
99+ // increasing count so every fillAndVerifySend call sees
100+ // growth past its baseline on the first poll.
101+ return autoUserCalls ++ as never ;
102+ }
103+
52104 // Drain one value per call. Once exhausted, freeze on the last
53105 // value so any post-script poll sees the steady-state count
54106 // (matches a real assistant message that has finished streaming).
@@ -238,12 +290,12 @@ describe("runConversation", () => {
238290 } ,
239291 async fill ( ) { } ,
240292 async press ( ) { } ,
241- async evaluate ( ) {
293+ evaluate : wrapEvaluateForUserMessages ( async ( ) => {
242294 // First read is the baseline (= 0); subsequent reads return 1
243295 // and stay there → growth past baseline + stable → settled.
244296 evalCalls ++ ;
245297 return ( evalCalls === 1 ? 0 : 1 ) as never ;
246- } ,
298+ } ) ,
247299 } ;
248300
249301 const result = await runConversation ( page , [ { input : "hi" } ] , {
@@ -300,10 +352,10 @@ describe("runConversation", () => {
300352 }
301353 } ,
302354 async press ( ) { } ,
303- async evaluate ( ) {
355+ evaluate : wrapEvaluateForUserMessages ( async ( ) => {
304356 evalCalls ++ ;
305357 return ( evalCalls === 1 ? 0 : 1 ) as never ;
306- } ,
358+ } ) ,
307359 } ;
308360
309361 const result = await runConversation ( page , [ { input : "hello" } ] , {
@@ -326,10 +378,10 @@ describe("runConversation", () => {
326378 } ,
327379 async fill ( ) { } ,
328380 async press ( ) { } ,
329- async evaluate ( ) {
381+ evaluate : wrapEvaluateForUserMessages ( async ( ) => {
330382 evalCalls ++ ;
331383 return ( evalCalls === 1 ? 0 : 1 ) as never ;
332- } ,
384+ } ) ,
333385 } ;
334386
335387 const result = await runConversation ( page , [ { input : "hi" } ] , {
@@ -397,14 +449,20 @@ describe("runConversation", () => {
397449 // on subsequent reads. The runner's readMessageCount catch returns 0
398450 // on error so the baseline becomes 0 and the turn still settles when
399451 // the count grows.
400- let calls = 0 ;
452+ let assistantCalls = 0 ;
453+ let userCalls = 0 ;
401454 const page : Page = {
402455 async waitForSelector ( ) { } ,
403456 async fill ( ) { } ,
404457 async press ( ) { } ,
405- async evaluate ( ) {
406- calls ++ ;
407- if ( calls === 1 ) throw new Error ( "evaluate boom" ) ;
458+ async evaluate ( fn ) {
459+ // User-message reads return a monotonically increasing count
460+ // so fillAndVerifySend sees growth and doesn't retry.
461+ if ( fn . toString ( ) . includes ( "copilot-user-message" ) ) {
462+ return userCalls ++ as never ;
463+ }
464+ assistantCalls ++ ;
465+ if ( assistantCalls === 1 ) throw new Error ( "evaluate boom" ) ;
408466 return 1 as never ;
409467 } ,
410468 } ;
@@ -452,11 +510,17 @@ describe("runConversation", () => {
452510 } ,
453511 } ;
454512
513+ let userMsgCalls1 = 0 ;
455514 const page : Page = {
456515 async waitForSelector ( ) { } ,
457516 async fill ( ) { } ,
458517 async press ( ) { } ,
459518 async evaluate ( fn ) {
519+ // User-message reads bypass the fake document entirely — we
520+ // only care about assistant-message selector queries here.
521+ if ( fn . toString ( ) . includes ( "copilot-user-message" ) ) {
522+ return userMsgCalls1 ++ as never ;
523+ }
460524 // Patch globalThis.document with our fake for the duration
461525 // of the evaluate call. The selector-fn closes over
462526 // globalThis at runtime, mirroring the browser-side execution.
@@ -510,11 +574,16 @@ describe("runConversation", () => {
510574 } ,
511575 } ;
512576
577+ let userMsgCalls2 = 0 ;
513578 const page : Page = {
514579 async waitForSelector ( ) { } ,
515580 async fill ( ) { } ,
516581 async press ( ) { } ,
517582 async evaluate ( fn ) {
583+ // User-message reads bypass the fake document entirely.
584+ if ( fn . toString ( ) . includes ( "copilot-user-message" ) ) {
585+ return userMsgCalls2 ++ as never ;
586+ }
518587 const originalDoc = ( globalThis as { document ?: unknown } ) . document ;
519588 ( globalThis as { document ?: unknown } ) . document = fakeDocument ;
520589 try {
@@ -564,3 +633,134 @@ describe("runConversation", () => {
564633 expect ( result . error ) . toContain ( "non-error string boom" ) ;
565634 } ) ;
566635} ) ;
636+
637+ describe ( "fillAndVerifySend" , ( ) => {
638+ it ( "succeeds on first attempt when user message appears immediately" , async ( ) => {
639+ const recorded = { fills : [ ] as string [ ] , presses : [ ] as string [ ] } ;
640+ // User message count: baseline=0, then 1 after Enter → success on first attempt.
641+ const page = makePage ( {
642+ recorded,
643+ userMessageValues : [ 0 , 1 ] ,
644+ } ) ;
645+
646+ await fillAndVerifySend ( page , "textarea" , "hello world" ) ;
647+
648+ // fill+press called exactly once — no retry needed.
649+ expect ( recorded . fills ) . toEqual ( [ "hello world" ] ) ;
650+ expect ( recorded . presses ) . toEqual ( [ "Enter" ] ) ;
651+ } ) ;
652+
653+ it ( "retries when first attempt fails (no user message) and succeeds on second" , async ( ) => {
654+ const recorded = { fills : [ ] as string [ ] , presses : [ ] as string [ ] } ;
655+ // Attempt 1: baseline=0, single poll returns 0 → timeout, retry.
656+ // Attempt 2: single poll returns 1 → success.
657+ // With short delays (initialDelayMs=10, timeoutMs=50) each attempt
658+ // fits exactly 1 poll (POLL_INTERVAL_MS=100 > remaining 40ms window).
659+ const userValues : number [ ] = [
660+ 0 , // baseline read
661+ 0 , // attempt 1: single poll → no growth → retry
662+ 1 , // attempt 2: single poll → growth → success
663+ ] ;
664+ const page = makePage ( {
665+ recorded,
666+ userMessageValues : userValues ,
667+ } ) ;
668+
669+ await fillAndVerifySend ( page , "textarea" , "retry me" , {
670+ initialDelayMs : 10 ,
671+ timeoutMs : 50 ,
672+ } ) ;
673+
674+ // fill+press called twice — first attempt failed, second succeeded.
675+ expect ( recorded . fills ) . toEqual ( [ "retry me" , "retry me" ] ) ;
676+ expect ( recorded . presses ) . toEqual ( [ "Enter" , "Enter" ] ) ;
677+ } ) ;
678+
679+ it ( "falls through after all 3 retries fail without throwing" , async ( ) => {
680+ const recorded = { fills : [ ] as string [ ] , presses : [ ] as string [ ] } ;
681+ // All 3 attempts see user message count stuck at 0. The function
682+ // should return silently (not throw) so the downstream timeout
683+ // produces a clear failure message.
684+ const page = makePage ( {
685+ recorded,
686+ userMessageValues : [ 0 ] , // stays at 0 forever (single-value freeze)
687+ } ) ;
688+
689+ // Should NOT throw — just returns after exhausting retries.
690+ // Use short delays so the test doesn't exceed the 5s timeout.
691+ await fillAndVerifySend ( page , "textarea" , "doomed" , {
692+ initialDelayMs : 10 ,
693+ timeoutMs : 50 ,
694+ } ) ;
695+
696+ // fill+press called 3 times (max attempts).
697+ expect ( recorded . fills ) . toEqual ( [ "doomed" , "doomed" , "doomed" ] ) ;
698+ expect ( recorded . presses ) . toEqual ( [ "Enter" , "Enter" , "Enter" ] ) ;
699+ } ) ;
700+ } ) ;
701+
702+ describe ( "readUserMessageCount" , ( ) => {
703+ it ( "returns 0 when no user messages exist" , async ( ) => {
704+ const fakeDocument = {
705+ querySelectorAll : ( _sel : string ) : { length : number } => ( { length : 0 } ) ,
706+ } ;
707+ const page : Page = {
708+ async waitForSelector ( ) { } ,
709+ async fill ( ) { } ,
710+ async press ( ) { } ,
711+ async evaluate ( fn ) {
712+ const originalDoc = ( globalThis as { document ?: unknown } ) . document ;
713+ ( globalThis as { document ?: unknown } ) . document = fakeDocument ;
714+ try {
715+ return fn ( ) as never ;
716+ } finally {
717+ ( globalThis as { document ?: unknown } ) . document = originalDoc ;
718+ }
719+ } ,
720+ } ;
721+ expect ( await readUserMessageCount ( page ) ) . toBe ( 0 ) ;
722+ } ) ;
723+
724+ it ( "prefers canonical testid when present" , async ( ) => {
725+ const queriedSelectors : string [ ] = [ ] ;
726+ const fakeDocument = {
727+ querySelectorAll : ( sel : string ) : { length : number } => {
728+ queriedSelectors . push ( sel ) ;
729+ if ( sel === '[data-testid="copilot-user-message"]' ) {
730+ return { length : 3 } ;
731+ }
732+ // Should never reach these — canonical short-circuits.
733+ return { length : 999 } ;
734+ } ,
735+ } ;
736+ const page : Page = {
737+ async waitForSelector ( ) { } ,
738+ async fill ( ) { } ,
739+ async press ( ) { } ,
740+ async evaluate ( fn ) {
741+ const originalDoc = ( globalThis as { document ?: unknown } ) . document ;
742+ ( globalThis as { document ?: unknown } ) . document = fakeDocument ;
743+ try {
744+ return fn ( ) as never ;
745+ } finally {
746+ ( globalThis as { document ?: unknown } ) . document = originalDoc ;
747+ }
748+ } ,
749+ } ;
750+ expect ( await readUserMessageCount ( page ) ) . toBe ( 3 ) ;
751+ // Should NOT have fallen through to the other selectors.
752+ expect ( queriedSelectors ) . toEqual ( [ '[data-testid="copilot-user-message"]' ] ) ;
753+ } ) ;
754+
755+ it ( "returns 0 on evaluate error" , async ( ) => {
756+ const page : Page = {
757+ async waitForSelector ( ) { } ,
758+ async fill ( ) { } ,
759+ async press ( ) { } ,
760+ async evaluate ( ) {
761+ throw new Error ( "page crashed" ) ;
762+ } ,
763+ } ;
764+ expect ( await readUserMessageCount ( page ) ) . toBe ( 0 ) ;
765+ } ) ;
766+ } ) ;
0 commit comments