@@ -210,6 +210,10 @@ async function getSavedMaxTokens(page: Page): Promise<unknown> {
210210// same pair that spec uses (the turn mounts, then the generating indicator
211211// clears), then assert the badge separately so each failure names its own cause.
212212// No timeout was loosened: the worst case went from ~248 s to ~205 s.
213+ //
214+ // The third state — "finished with an error" — is resolved explicitly at both
215+ // points it can appear, because upstream renders the error card INSTEAD of the
216+ // bot bubble, so every wait keyed on the bubble outlives it (#1188).
213217async function runPrompt ( page : Page ) : Promise < string > {
214218 const node = page . locator (
215219 '[data-testid^="rf__node-ChatInput"] [data-testid="textarea_str_input_value"]' ,
@@ -224,35 +228,94 @@ async function runPrompt(page: Page): Promise<string> {
224228 await expect ( page . getByTestId ( "input-chat-playground" ) . last ( ) ) . toBeVisible ( { timeout : 30000 } ) ;
225229
226230 const messages = page . getByTestId ( "div-chat-message" ) ;
231+ const errorCard = page . getByTestId ( "error-card-stack" ) ;
227232 const before = await messages . count ( ) ;
228233 await page . getByTestId ( "button-send" ) . last ( ) . click ( ) ;
229234
230235 // 1. The turn actually started — guards the "checked completion before
231236 // generation started" race that an indicator-only wait returns early on
232237 // (#354). `toBeGreaterThan` rather than an exact count, so it holds whether
233- // or not the user bubble carries this testid.
238+ // or not the user bubble carries this testid. An errored turn is accepted
239+ // here as a start too: upstream renders `ErrorView` INSTEAD of the bot
240+ // bubble (`chat-message.tsx`: `chat.category === "error"`), so a run that
241+ // fails before any bubble mounts would otherwise wait the full 60 s for an
242+ // element that is never coming (#1188).
234243 await expect
235- . poll ( ( ) => messages . count ( ) , { timeout : 60000 } )
236- . toBeGreaterThan ( before ) ;
244+ . poll (
245+ async ( ) => ( await errorCard . count ( ) ) > 0 || ( await messages . count ( ) ) > before ,
246+ {
247+ timeout : 60000 ,
248+ message : "the run neither started a reply nor rendered an error card" ,
249+ } ,
250+ )
251+ . toBe ( true ) ;
252+ await failIfRunErrored ( page ) ;
237253 // 2. Generation finished. This is the completion signal because it is emitted
238254 // for every model and every response (#569).
239255 await expect ( page . getByTestId ( "button-stop" ) ) . toBeHidden ( { timeout : 120000 } ) ;
240256 await expect ( page . getByTestId ( "button-send" ) . last ( ) ) . toBeVisible ( { timeout : 10000 } ) ;
241-
242- const reply = ( await messages . last ( ) . innerText ( ) ) . trim ( ) ;
257+ // The error can also arrive AFTER a bubble mounted, and that is the measured
258+ // case on 1.12.0.dev10: the bubble is replaced by the error card, so
259+ // `messages.last()` resolves to nothing and every later step waits on an
260+ // element the error path does not render (#1188).
261+ await failIfRunErrored ( page ) ;
262+
263+ // A finished turn that rendered no bubble at all must still reach the badge
264+ // assertion below — reading `.last()` unguarded is what turned that state into
265+ // a bare `locator.innerText` timeout with no cause in it.
266+ const reply =
267+ ( await messages . count ( ) ) > 0
268+ ? ( await messages . last ( ) . innerText ( { timeout : 5000 } ) . catch ( ( ) => "" ) ) . trim ( )
269+ : "" ;
243270
244271 // 3. Only now the observable itself. A finished turn that renders no badge is a
245272 // MISSING OBSERVABLE, not a slow model — and the message says so, quoting the
246- // reply that did render (an error bubble included) instead of timing out blind.
273+ // reply that did render instead of timing out blind.
247274 await expect (
248275 page . getByTestId ( "chat-message-token-usage" ) ,
249276 `the finished response must expose a token-usage badge — it is the max_tokens ` +
250- `observable this spec reads. Rendered reply: ${ reply . slice ( 0 , 300 ) } ` ,
277+ `observable this spec reads. Rendered reply: ${ reply . slice ( 0 , 300 ) || "(none rendered)" } ` ,
251278 ) . toHaveCount ( 1 , { timeout : 15000 } ) ;
252279
253280 return reply ;
254281}
255282
283+ // An errored run is a real outcome of this spec and must name itself. Without
284+ // this, the run fails several steps later on whatever element the error path
285+ // happens not to render — measured on `main` as `locator.innerText: Timeout
286+ // 20000ms exceeded` with the provider's 400 nowhere in the message (#1188).
287+ async function failIfRunErrored ( page : Page ) : Promise < void > {
288+ if ( ( await page . getByTestId ( "error-card-stack" ) . count ( ) ) === 0 ) return ;
289+ throw new Error (
290+ `the agent run errored instead of returning a response — ${ await readRunError ( page ) } ` ,
291+ ) ;
292+ }
293+
294+ // The provider's message sits in a collapsed accordion inside the error card
295+ // (upstream `error-message.tsx`), so expand it before reading — otherwise the
296+ // failure says "An error occurred" and nothing else, which is the same dead end
297+ // the timeout was.
298+ async function readRunError ( page : Page ) : Promise < string > {
299+ // `.last()`: the throw is gated on "any error card", so read the newest one.
300+ const stack = page . getByTestId ( "error-card-stack" ) . last ( ) ;
301+ // Best-effort: expanding is how we reach the provider text, never how we
302+ // decide the run failed.
303+ await stack
304+ . getByText ( "An error occurred" )
305+ . last ( )
306+ . click ( { timeout : 5000 } )
307+ . catch ( ( ) => { } ) ;
308+ const text = ( await stack . innerText ( ) . catch ( ( ) => "" ) ) . replace ( / \s + / g, " " ) . trim ( ) ;
309+ // Upstream renders the provider message only when the error carries a
310+ // component (`error-message.tsx`), so a bare label is a real outcome — and it
311+ // must not read as "here is the cause", or this helper reproduces the dead end
312+ // it exists to remove.
313+ return text && text . replace ( / a n e r r o r o c c u r r e d / i, "" ) . trim ( ) . length > 0
314+ ? text
315+ : `${ text || "(empty error card)" } — the error card carried no provider message; ` +
316+ `check the run's flow-error advisory in the test log or the flow's build log` ;
317+ }
318+
256319// "1.9K" -> 1900, "46" -> 46, missing/empty -> 0 (a tight cap can be fully
257320// consumed by reasoning before any visible token — observed with max_tokens=1).
258321function parseTokenCount ( raw : string | undefined ) : number {
0 commit comments