mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): capture waits for a mid-read navigation to settle and reads the page again once (#6567)
* fix(cli): capture waits for a mid-read navigation to settle and reads the page again once A site that redirects or reloads after load destroyed the execution context under a page read, and the capture failed with Execution context was destroyed. The capture page's evaluate now settles the new document and runs the read once more on that error only. * fix(cli): the capture browser opens every page with reads that ride out a navigation
This commit is contained in:
@@ -38,7 +38,11 @@ it.each(["SIGINT", "SIGTERM"] as const)(
|
||||
child.emit("exit", 0);
|
||||
browser.emit("disconnected");
|
||||
});
|
||||
const browser = Object.assign(new EventEmitter(), { process: () => child, close });
|
||||
const browser = Object.assign(new EventEmitter(), {
|
||||
process: () => child,
|
||||
close,
|
||||
newPage: vi.fn(),
|
||||
});
|
||||
mocks.launch.mockResolvedValue(browser);
|
||||
mocks.proxyClose.mockResolvedValue(undefined);
|
||||
const { launchCaptureBrowser } = await import("./launchCaptureBrowser.js");
|
||||
|
||||
@@ -28,7 +28,11 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.proxyClose.mockResolvedValue(undefined);
|
||||
mocks.browserClose.mockResolvedValue(undefined);
|
||||
mocks.launch.mockResolvedValue({ close: mocks.browserClose, once: mocks.once });
|
||||
mocks.launch.mockResolvedValue({
|
||||
close: mocks.browserClose,
|
||||
once: mocks.once,
|
||||
newPage: vi.fn(),
|
||||
});
|
||||
});
|
||||
describe("capture browser transport lifecycle", () => {
|
||||
it("forces proxy for loopback and disables direct UDP routes", async () => {
|
||||
@@ -101,3 +105,24 @@ describe("capture browser transport lifecycle", () => {
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pages the capture browser opens", () => {
|
||||
it("ride out a navigation that destroys the execution context mid-read", async () => {
|
||||
const evaluate = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new Error("Execution context was destroyed, most likely because of a navigation."),
|
||||
)
|
||||
.mockResolvedValue("ok");
|
||||
const rawPage = { evaluate, waitForNetworkIdle: vi.fn(async () => undefined) };
|
||||
mocks.launch.mockResolvedValue({
|
||||
close: mocks.browserClose,
|
||||
once: mocks.once,
|
||||
newPage: vi.fn(async () => rawPage),
|
||||
});
|
||||
const session = await launchCaptureBrowser(options);
|
||||
const page = await session.browser.newPage();
|
||||
await expect(page.evaluate(() => "read")).resolves.toBe("ok");
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
registerBrowserSessionCleanup,
|
||||
} from "../browser/launch.js";
|
||||
import { captureBrowserArgs } from "./browserLaunchArgs.js";
|
||||
import { settleAcrossNavigation } from "./navigateForCapture.js";
|
||||
import { startCaptureProxy } from "./captureProxy.js";
|
||||
import { removeTreeBestEffortAsync } from "@hyperframes/core/remove-tree";
|
||||
|
||||
@@ -66,6 +67,8 @@ export async function launchCaptureBrowser(options: {
|
||||
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
|
||||
],
|
||||
});
|
||||
const newPage = browser.newPage.bind(browser);
|
||||
browser.newPage = async () => settleAcrossNavigation(await newPage());
|
||||
browser.once("disconnected", () => void proxy.close());
|
||||
return {
|
||||
browser,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isNavigationTimeoutError,
|
||||
navigateForCapture,
|
||||
networkIdleAttemptTimeoutMs,
|
||||
settleAcrossNavigation,
|
||||
waitForPageReady,
|
||||
NETWORK_IDLE_ATTEMPT_MS,
|
||||
} from "./navigateForCapture.js";
|
||||
@@ -416,3 +417,51 @@ describe("gotoWhenSent", () => {
|
||||
await failed;
|
||||
});
|
||||
});
|
||||
|
||||
describe("settleAcrossNavigation", () => {
|
||||
const destroyed = () =>
|
||||
new Error("Execution context was destroyed, most likely because of a navigation.");
|
||||
|
||||
function stubPage(failures: Error[]) {
|
||||
const waitForNetworkIdle = vi.fn(async () => undefined);
|
||||
const read = () => "html";
|
||||
const reads = vi.fn();
|
||||
const evaluate = vi.fn(async (fn: unknown) => {
|
||||
if (fn !== read) return false;
|
||||
reads();
|
||||
const failure = failures.shift();
|
||||
if (failure) throw failure;
|
||||
return "html";
|
||||
});
|
||||
const page = { evaluate, waitForNetworkIdle } as unknown as Page;
|
||||
settleAcrossNavigation(page);
|
||||
return { page, read, reads, waitForNetworkIdle };
|
||||
}
|
||||
|
||||
it("settles the new document and reads again when a navigation lands mid-read", async () => {
|
||||
const { page, read, reads, waitForNetworkIdle } = stubPage([destroyed()]);
|
||||
await expect(page.evaluate(read)).resolves.toBe("html");
|
||||
expect(reads).toHaveBeenCalledTimes(2);
|
||||
expect(waitForNetworkIdle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads once when nothing navigates", async () => {
|
||||
const { page, read, reads, waitForNetworkIdle } = stubPage([]);
|
||||
await expect(page.evaluate(read)).resolves.toBe("html");
|
||||
expect(reads).toHaveBeenCalledTimes(1);
|
||||
expect(waitForNetworkIdle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gives up after one more try, so a page that never stops navigating still fails", async () => {
|
||||
const { page, read, reads } = stubPage([destroyed(), destroyed()]);
|
||||
await expect(page.evaluate(read)).rejects.toThrow(/Execution context was destroyed/);
|
||||
expect(reads).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry any other error", async () => {
|
||||
const { page, read, reads, waitForNetworkIdle } = stubPage([new Error("boom")]);
|
||||
await expect(page.evaluate(read)).rejects.toThrow("boom");
|
||||
expect(reads).toHaveBeenCalledTimes(1);
|
||||
expect(waitForNetworkIdle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +126,27 @@ export async function waitForPageReady(
|
||||
if (waitedOutLoad && remaining() > 0) await idle();
|
||||
}
|
||||
|
||||
function isNavigationRaceError(error: unknown): boolean {
|
||||
return error instanceof Error && /Execution context was destroyed/i.test(error.message);
|
||||
}
|
||||
|
||||
export function settleAcrossNavigation(page: Page): Page {
|
||||
const read = page.evaluate.bind(page);
|
||||
page.evaluate = (async (...args: Parameters<Page["evaluate"]>) => {
|
||||
try {
|
||||
return await read(...args);
|
||||
} catch (error) {
|
||||
if (!isNavigationRaceError(error)) throw error;
|
||||
await waitForPageReady({
|
||||
evaluate: (fn) => read(fn),
|
||||
waitForNetworkIdle: (o) => page.waitForNetworkIdle(o),
|
||||
});
|
||||
return read(...args);
|
||||
}
|
||||
}) as Page["evaluate"];
|
||||
return page;
|
||||
}
|
||||
|
||||
const PUPPETEER_LAUNCH_DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const PAGE_LOAD_BLOCKING_TYPES = new Set(["Document", "Script", "Stylesheet"]);
|
||||
const NAMED_BLOCKING_REQUESTS = 3;
|
||||
|
||||
Reference in New Issue
Block a user