Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 105 additions & 1 deletion apps/server/src/preview/ServerBrowserPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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("<title>Ready</title>");
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: "<title>Ready</title>" });
});
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: "<title>Ready</title>" }),
);
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", () => {
Expand Down Expand Up @@ -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<unknown>();
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],
Expand Down
17 changes: 16 additions & 1 deletion apps/server/src/preview/ServerBrowserPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -892,8 +892,10 @@ export const evaluate = async (
timeoutMs: number,
) => {
let timer: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
const expired = new Promise<never>((_, 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(
Expand All @@ -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));
Expand Down
Loading