Skip to content

Commit 6e4f4a6

Browse files
author
probe
committed
test(webui): read the browser panel's page through the target it still holds
The real-Chromium navigation flows read a page through the target the runtime handed them, and a page that is still loading is observed as a replaced page: the runtime raises the target's generation once the navigation lands, so the reference the open call returned goes stale. The flow treated that as a failure. The first read after the open used the open call's own reference, and `waitFor` re-threw whatever its condition raised — so a slow runner lost the case to a one-frame race, with `browser_target_stale` as the only symptom. The reads and the commands now go through the target the runtime currently holds: a stale reference is re-read against the current target, and `waitFor` gives the condition another turn rather than failing. Every other failure still belongs to the caller.
1 parent ea62949 commit 6e4f4a6

1 file changed

Lines changed: 87 additions & 24 deletions

File tree

‎packages/webui/test/integration/browser-panel-live-navigation.test.ts‎

Lines changed: 87 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { join } from "node:path";
1212
import { afterAll, beforeAll, describe, expect, it } from "vitest";
1313

1414
import { WebuiBrowserService } from "../../src/runtime/browser/browser-service.js";
15+
import { WebuiBrowserOperationError } from "../../src/runtime/browser/errors.js";
1516
import type {
1617
WebuiBrowserAuthorization,
1718
WebuiBrowserImageFrame,
@@ -59,6 +60,19 @@ async function startPageServer(respond: (path: string) => string): Promise<{
5960

6061
const 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+
6276
describe.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

Comments
 (0)