diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 8b69f41cbdb29..78f03f1f91b72 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -5,7 +5,16 @@ import { type CDPSession, type Page, } from "playwright-core"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; import * as ServerBrowserPage from "./ServerBrowserPage.ts"; @@ -269,6 +278,67 @@ describe("server browser element refs", () => { await ServerBrowserPage.click(page, { selector: 'button[data-example=" >> aria-ref=e1"]' }); expect(await page.locator("button").textContent()).toBe("saved"); }); + + it("evaluates top-level await and returns the last expression's value", async () => { + await page.setContent("Ready"); + const result = await ServerBrowserPage.evaluate( + cdp, + { expression: "await Promise.resolve(); document.title" }, + 5_000, + ); + expect(result).toBe("Ready"); + }); + + it("still awaits the promise an async function returns", async () => { + const result = await ServerBrowserPage.evaluate( + cdp, + { expression: "(async () => { await 0; return 1 })()" }, + 5_000, + ); + expect(result).toBe(1); + }); + + it("does not rerun an expression whose SyntaxError is thrown at runtime", async () => { + const evaluation = ServerBrowserPage.evaluate( + cdp, + { expression: "window.runs = (window.runs ?? 0) + 1; new Function('await 0')" }, + 5_000, + ); + await expect(evaluation).rejects.toThrow("await is only valid"); + expect(await page.evaluate("window.runs")).toBe(1); + }); + + it("does not rerun an expression whose awaited browser call rejects with a SyntaxError", async () => { + let posts = 0; + await page.route("http://preview.test/**", (route) => { + if (route.request().method() === "POST") posts++; + return route.fulfill({ contentType: "text/html", body: "Ready" }); + }); + await page.goto("http://preview.test/"); + const evaluation = ServerBrowserPage.evaluate( + cdp, + { + expression: + "(async () => { const r = await fetch('/api', { method: 'POST' }); return r.json() })()", + }, + 5_000, + ); + await expect(evaluation).rejects.toThrow("is not valid JSON"); + expect(posts).toBe(1); + }); + + it("returns the result of a top-level await expression that reloads the page", async () => { + await page.route("http://preview.test/", (route) => + route.fulfill({ contentType: "text/html", body: "Ready" }), + ); + await page.goto("http://preview.test/"); + const result = await ServerBrowserPage.evaluate( + cdp, + { expression: "await 0; location.reload(); ({ clicked: true })" }, + 5_000, + ); + expect(result).toEqual({ clicked: true }); + }); }); describe("server browser snapshot screenshot", () => { @@ -417,6 +487,40 @@ describe("server browser drag", () => { }); }); +describe("server browser evaluate", () => { + it("does not retry a parse error that arrives after the timeout", async () => { + vi.useFakeTimers(); + try { + const classic = Promise.withResolvers(); + const send = vi.fn((method: string) => + method === "Runtime.evaluate" ? classic.promise : Promise.resolve({}), + ); + const cdp = { send } as unknown as CDPSession; + const evaluation = ServerBrowserPage.evaluate(cdp, { expression: "await 0; 1" }, 10); + const rejected = expect(evaluation).rejects.toMatchObject({ + tag: "PreviewAutomationTimeoutError", + }); + await vi.advanceTimersByTimeAsync(10); + await rejected; + classic.resolve({ + result: { type: "object" }, + exceptionDetails: { + text: "Uncaught", + scriptId: "4", + exception: { type: "object", className: "SyntaxError" }, + }, + }); + await classic.promise; + expect(send.mock.calls.map(([method]) => method)).toEqual([ + "Runtime.evaluate", + "Runtime.terminateExecution", + ]); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("server browser net errors", () => { it.each([ ["net::ERR_CONNECTION_REFUSED", "ERR_CONNECTION_REFUSED", -102], diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index fdba996799180..31cff821c6e42 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -892,8 +892,10 @@ export const evaluate = async ( timeoutMs: number, ) => { let timer: ReturnType | undefined; + let timedOut = false; const expired = new Promise((_, reject) => { timer = setTimeout(() => { + timedOut = true; // Stops a busy script; an awaited promise is abandoned and settles unread. void cdp.send("Runtime.terminateExecution").catch(constVoid); reject( @@ -904,10 +906,23 @@ export const evaluate = async ( ); }, timeoutMs); }); - const evaluation = cdp.send("Runtime.evaluate", { + const params = { expression: input.expression, awaitPromise: input.awaitPromise ?? true, returnByValue: input.returnByValue ?? true, + }; + // The devtools console's replMode allows top-level await but returns a trailing promise unawaited, + // so it only retries what the page's parser rejected. A parse error has a scriptId but no stack and + // ran nothing; a browser-internal rejection has neither. + const evaluation = cdp.send("Runtime.evaluate", params).then((classic) => { + const failure = classic.exceptionDetails; + const unparsed = + failure?.exception?.className === "SyntaxError" && + failure.scriptId !== undefined && + failure.stackTrace === undefined; + return unparsed && !timedOut && input.expression.includes("await") + ? cdp.send("Runtime.evaluate", { ...params, replMode: true }) + : classic; }); void evaluation.catch(constVoid); const result = await Promise.race([evaluation, expired]).finally(() => clearTimeout(timer));