@@ -12,6 +12,7 @@ import { join } from "node:path";
1212import { afterAll , beforeAll , describe , expect , it } from "vitest" ;
1313
1414import { WebuiBrowserService } from "../../src/runtime/browser/browser-service.js" ;
15+ import { WebuiBrowserOperationError } from "../../src/runtime/browser/errors.js" ;
1516import type {
1617 WebuiBrowserAuthorization ,
1718 WebuiBrowserImageFrame ,
@@ -59,6 +60,19 @@ async function startPageServer(respond: (path: string) => string): Promise<{
5960
6061const chromeAvailable = ( await resolveChromePath ( ) ) !== undefined ;
6162
63+ /**
64+ * Whether a failure is the page behind a reference moving on.
65+ *
66+ * A page that is still loading is observed as a replaced page: the runtime
67+ * raises the target's generation once the navigation lands, and every
68+ * reference taken before that is stale. The page under test did not change —
69+ * only the generation that names it did — so this is a state to wait out
70+ * rather than a failure.
71+ */
72+ function isStaleTarget ( error : unknown ) : boolean {
73+ return error instanceof WebuiBrowserOperationError && error . code === "browser_target_stale" ;
74+ }
75+
6276describe . skipIf ( ! chromeAvailable ) ( "the browser panel's real Chromium navigation flows" , ( ) => {
6377 let dataDir : string ;
6478 let service : WebuiBrowserService ;
@@ -137,25 +151,66 @@ describe.skipIf(!chromeAvailable)("the browser panel's real Chromium navigation
137151 return state . id ;
138152 }
139153
154+ /**
155+ * Runs a read against the target the runtime currently holds.
156+ *
157+ * The reference a caller keeps names a page; its generation moves when the
158+ * page behind it is replaced. A read that lands on a stale reference
159+ * therefore re-reads the current target instead of failing: the page is the
160+ * same one either way.
161+ */
162+ async function readThrough < T > (
163+ target : WebuiBrowserTargetId ,
164+ read : ( current : WebuiBrowserTargetId ) => Promise < T > ,
165+ ) : Promise < T > {
166+ try {
167+ return await read ( target ) ;
168+ } catch ( error ) {
169+ if ( ! isStaleTarget ( error ) ) throw error ;
170+ return await read ( await currentTarget ( target ) ) ;
171+ }
172+ }
173+
174+ /**
175+ * Sends a command to the page the runtime currently holds.
176+ *
177+ * The same generation rule as the reads: a command that lands on a stale
178+ * reference is sent again to the current target, because the page under
179+ * test is the same one.
180+ */
181+ async function sendCommand (
182+ target : WebuiBrowserTargetId ,
183+ command : string ,
184+ params : Record < string , unknown > = { } ,
185+ ) : Promise < void > {
186+ await readThrough ( target , async ( current ) => {
187+ await service . sendCommandToTarget ( "isolated-chromium" , current , command , params ) ;
188+ } ) ;
189+ }
190+
140191 async function readTitle ( target : WebuiBrowserTargetId ) : Promise < string > {
141- const result = await service . sendCommandToTarget (
142- "isolated-chromium" ,
143- target ,
144- "Runtime.evaluate" ,
145- { expression : "document.title" , returnByValue : true } ,
146- ) ;
147- const value = ( result . result as { result ?: { value ?: unknown } } | undefined ) ?. result ?. value ;
148- return typeof value === "string" ? value : "" ;
192+ return readThrough ( target , async ( current ) => {
193+ const result = await service . sendCommandToTarget (
194+ "isolated-chromium" ,
195+ current ,
196+ "Runtime.evaluate" ,
197+ { expression : "document.title" , returnByValue : true } ,
198+ ) ;
199+ const value = ( result . result as { result ?: { value ?: unknown } } | undefined ) ?. result ?. value ;
200+ return typeof value === "string" ? value : "" ;
201+ } ) ;
149202 }
150203
151204 async function evaluate ( target : WebuiBrowserTargetId , expression : string ) : Promise < unknown > {
152- const result = await service . sendCommandToTarget (
153- "isolated-chromium" ,
154- target ,
155- "Runtime.evaluate" ,
156- { expression, returnByValue : true , awaitPromise : true } ,
157- ) ;
158- return ( result . result as { result ?: { value ?: unknown } } | undefined ) ?. result ?. value ;
205+ return readThrough ( target , async ( current ) => {
206+ const result = await service . sendCommandToTarget (
207+ "isolated-chromium" ,
208+ current ,
209+ "Runtime.evaluate" ,
210+ { expression, returnByValue : true , awaitPromise : true } ,
211+ ) ;
212+ return ( result . result as { result ?: { value ?: unknown } } | undefined ) ?. result ?. value ;
213+ } ) ;
159214 }
160215
161216 async function elementCentre ( target : WebuiBrowserTargetId , selector : string ) : Promise < { x : number ; y : number } > {
@@ -169,14 +224,22 @@ describe.skipIf(!chromeAvailable)("the browser panel's real Chromium navigation
169224
170225 async function click ( target : WebuiBrowserTargetId , selector : string ) : Promise < void > {
171226 const point = await elementCentre ( target , selector ) ;
172- await service . sendInput ( { kind : "pointer" , action : "press" , target, ...point , button : "left" } ) ;
173- await service . sendInput ( { kind : "pointer" , action : "release" , target, ...point , button : "left" } ) ;
227+ const current = await currentTarget ( target ) ;
228+ await service . sendInput ( { kind : "pointer" , action : "press" , target : current , ...point , button : "left" } ) ;
229+ await service . sendInput ( { kind : "pointer" , action : "release" , target : current , ...point , button : "left" } ) ;
174230 }
175231
176232 async function waitFor ( condition : ( ) => boolean | Promise < boolean > , description : string ) : Promise < void > {
177233 const deadline = Date . now ( ) + TIMEOUT_MS ;
178234 while ( Date . now ( ) < deadline ) {
179- if ( await condition ( ) ) return ;
235+ try {
236+ if ( await condition ( ) ) return ;
237+ } catch ( error ) {
238+ // A stale reference means the page is still loading and its generation
239+ // moved on; the condition gets another turn. Any other failure is the
240+ // flow's own and belongs to the caller.
241+ if ( ! isStaleTarget ( error ) ) throw error ;
242+ }
180243 await new Promise ( ( resolve ) => setTimeout ( resolve , POLL_MS ) ) ;
181244 }
182245 throw new Error ( `timed out waiting for ${ description } ` ) ;
@@ -228,24 +291,24 @@ describe.skipIf(!chromeAvailable)("the browser panel's real Chromium navigation
228291 await streamTask ?. catch ( ( ) => undefined ) ;
229292 const resumedFrames : WebuiBrowserImageFrame [ ] = [ ] ;
230293 watch ( target , resumedFrames ) ;
231- await service . sendCommandToTarget ( "isolated-chromium" , target , "Runtime.evaluate" , {
294+ await sendCommand ( target , "Runtime.evaluate" , {
232295 expression : "document.body.style.background = 'rgb(12, 34, 56)'" ,
233296 } ) ;
234297 await waitFor ( ( ) => resumedFrames . length > 0 , "screencast after navigation and resubscription" ) ;
235298 expect ( resumedFrames . at ( - 1 ) ?. target . generation ) . toBe ( target . generation ) ;
236299 expect ( frames . length ) . toBeGreaterThanOrEqual ( afterNavigation ) ;
237300
238- await service . sendCommandToTarget ( "isolated-chromium" , target , "back" , { } ) ;
301+ await sendCommand ( target , "back" ) ;
239302 await waitFor ( async ( ) => ( await readTitle ( await currentTarget ( target ) ) ) === "Same-origin destination" , "back navigation" ) ;
240303 target = await currentTarget ( target ) ;
241- await service . sendCommandToTarget ( "isolated-chromium" , target , "back" , { } ) ;
304+ await sendCommand ( target , "back" ) ;
242305 await waitFor ( async ( ) => ( await readTitle ( await currentTarget ( target ) ) ) === "Browser panel start" , "second back navigation" ) ;
243306 target = await currentTarget ( target ) ;
244- await service . sendCommandToTarget ( "isolated-chromium" , target , "forward" , { } ) ;
307+ await sendCommand ( target , "forward" ) ;
245308 await waitFor ( async ( ) => ( await readTitle ( await currentTarget ( target ) ) ) === "Same-origin destination" , "forward navigation" ) ;
246309 target = await currentTarget ( target ) ;
247310 const generationBeforeReload = target . generation ;
248- await service . sendCommandToTarget ( "isolated-chromium" , target , "reload" , { } ) ;
311+ await sendCommand ( target , "reload" ) ;
249312 await waitFor ( async ( ) => ( await currentTarget ( target ) ) . generation > generationBeforeReload , "reload generation" ) ;
250313 } , TIMEOUT_MS ) ;
251314
@@ -273,7 +336,7 @@ describe.skipIf(!chromeAvailable)("the browser panel's real Chromium navigation
273336 expect ( await readTitle ( newTarget ! ) ) . toBe ( "New tab destination" ) ;
274337 const frames : WebuiBrowserImageFrame [ ] = [ ] ;
275338 watch ( newTarget ! , frames ) ;
276- await service . sendCommandToTarget ( "isolated-chromium" , newTarget ! , "Runtime.evaluate" , {
339+ await sendCommand ( newTarget ! , "Runtime.evaluate" , {
277340 expression : "document.body.style.background = 'rgb(56, 34, 12)'" ,
278341 } ) ;
279342 await waitFor ( ( ) => frames . length > 0 , "new tab screencast" ) ;
0 commit comments