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));