authorgravatar for snorteman@gmail.comScott Norteman <snorteman@gmail.com> 2026-10-09 01:10:34-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 00:10:34-05:00
log8f760371978f05b49e6f6b6aa4b2c523ab14b4fe
treead7e672377d3c3f0ad9497b6231d546de3de8426
parent5785df19ea748be0755244582cb70d785f95f830
signature Signed by PGP key B5690EEEBB952194

fix(server): a timed-out browser drag no longer exits the server (#17360)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

2 files changed, 50 insertions(+), 3 deletions(-)

apps/server/src/preview/ServerBrowserPage.test.ts+42
...@@ -277,3 +277,45 @@ describe("server browser element refs", () => {...@@ -277,3 +277,45 @@ describe("server browser element refs", () => {
277 expect(await ServerBrowserPage.evaluate(cdp, { expression: "1 + 1" }, 2_000)).toBe(2);277 expect(await ServerBrowserPage.evaluate(cdp, { expression: "1 + 1" }, 2_000)).toBe(2);
278 });278 });
279});279});
280
281describe("server browser drag", () => {
282 it("fails the drag, not the server, when it times out while the cursor is still moving", async () => {
283 const timedOut = new Error("locator.dragTo: Timeout 30000ms exceeded.");
284 const box = { x: 0, y: 0, width: 20, height: 20 };
285 const targetBox = Promise.withResolvers<typeof box>();
286 // Only the calls `drag` makes. The drag fails at once; the target's position arrives only
287 // when the test hands it over, after the rejection has had a turn to go unobserved.
288 const page = {
289 locator: (selector: string) =>
290 selector === "#card"
291 ? {
292 scrollIntoViewIfNeeded: async () => {},
293 boundingBox: async () => box,
294 dragTo: () => Promise.reject(timedOut),
295 }
296 : { boundingBox: () => targetBox.promise },
297 } as unknown as Page;
298 const unobserved: Array<unknown> = [];
299 const onUnhandled = (reason: unknown) => unobserved.push(reason);
300 process.on("unhandledRejection", onUnhandled);
301 try {
302 let finished = false;
303 const settled = ServerBrowserPage.drag(page, { source: "#card", target: "#lane" }).then(
304 () => null,
305 (error: unknown) => error,
306 );
307 void settled.then(() => (finished = true));
308 // Two macrotask turns: Node reports a rejection nobody observed after the first.
309 await new Promise((resolve) => setImmediate(resolve));
310 await new Promise((resolve) => setImmediate(resolve));
311 // The failed drag waits for the cursor to reach the target before it reports.
312 expect(finished).toBe(false);
313 targetBox.resolve(box);
314
315 expect(await settled).toBe(timedOut);
316 expect(unobserved).toEqual([]);
317 } finally {
318 process.off("unhandledRejection", onUnhandled);
319 }
320 });
321});
apps/server/src/preview/ServerBrowserPage.ts+8-3
...@@ -380,11 +380,16 @@ export const drag = async (...@@ -380,11 +380,16 @@ export const drag = async (
380 const source = targetLocator(page, { locator: input.source })!;380 const source = targetLocator(page, { locator: input.source })!;
381 const target = targetLocator(page, { locator: input.target })!;381 const target = targetLocator(page, { locator: input.target })!;
382 await pointer(await targetPoint(page, source, {}, timeout), "move");382 await pointer(await targetPoint(page, source, {}, timeout), "move");
383 // The cursor travels with the drag; the page sees one continuous gesture.383 // The cursor travels with the drag; the page sees one continuous gesture. Observe the drag's
384 const dropped = source.dragTo(target, { timeout });384 // outcome as it starts: a rejection nobody handles while the cursor moves exits the server.
385 const dragFailure = source.dragTo(target, { timeout }).then(
386 () => null,
387 (error: unknown) => ({ error }),
388 );
385 const end = await target.boundingBox({ timeout }).catch(() => null);389 const end = await target.boundingBox({ timeout }).catch(() => null);
386 if (end) await pointer({ x: end.x + end.width / 2, y: end.y + end.height / 2 }, "move");390 if (end) await pointer({ x: end.x + end.width / 2, y: end.y + end.height / 2 }, "move");
387 await dropped;391 const failure = await dragFailure;
392 if (failure) throw failure.error;
388};393};
389394
390/** Sets files on one file input; false when no locator or selector names one. */395/** Sets files on one file input; false when no locator or selector names one. */