fix(wrapper): track real window geometry on headed launches

Headed launches applied a fixed emulated viewport on top of the real
browser window, yielding outerWidth < innerWidth (an impossible window).
Default headed new_page()/new_context() to no_viewport so the page tracks
the real window; headless keeps a deterministic viewport. Covers Python
launch/launch_context/launch_persistent_context (+async) and the JS
Playwright/Puppeteer wrappers. Explicit viewport still honored.
This commit is contained in:
CloakHQ
2026-06-20 22:53:53 +02:00
parent d67c21abbe
commit 50bf14b3f9
11 changed files with 369 additions and 40 deletions
+26
View File
@@ -84,6 +84,32 @@ describe("composable Playwright launch helpers", () => {
expect(buildContextOptions({ viewport: null }).viewport).toBeNull();
});
it("buildContextOptions uses no viewport (null) when headed, so the page tracks the real window", async () => {
const { buildContextOptions } = await import("../src/index.js");
// Headed: no emulated viewport (CDP emulation would force outerWidth < innerWidth).
expect(buildContextOptions({ headless: false }).viewport).toBeNull();
// Headless keeps the deterministic default.
expect(buildContextOptions({ headless: true }).viewport).toEqual(DEFAULT_VIEWPORT);
// Explicit viewport always honored, even headed.
const custom = { width: 800, height: 600 };
expect(buildContextOptions({ headless: false, viewport: custom }).viewport).toEqual(custom);
});
it("buildContextOptions reads effective headless from launchOptions.headless", async () => {
const { buildContextOptions } = await import("../src/index.js");
// buildLaunchOptions spreads launchOptions LAST, so launchOptions.headless wins
// at the actual launch. Viewport must follow it — a raw headless:false (browser
// actually headed) must NOT get a fixed viewport (would reintroduce outer<inner).
expect(buildContextOptions({ launchOptions: { headless: false } }).viewport).toBeNull();
// And launchOptions.headless:true forces the deterministic viewport even if the
// top-level field said headed.
expect(
buildContextOptions({ headless: false, launchOptions: { headless: true } }).viewport,
).toEqual(DEFAULT_VIEWPORT);
});
it("buildLaunchOptions returns Playwright options without launching a browser", async () => {
const freshConfig = await import("../src/config.js");
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
+63
View File
@@ -65,6 +65,53 @@ describe("puppeteer launch", () => {
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(false);
});
it("headless (default) uses a fixed defaultViewport; headed uses null", async () => {
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
const { launch } = await import("../src/puppeteer.js");
// Headless (default): deterministic viewport.
await launch();
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toEqual(DEFAULT_VIEWPORT);
// Headed: null so the page tracks the real window (else Puppeteer forces 800x600).
vi.mocked(puppeteerMock.default.launch).mockClear();
await launch({ headless: false });
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toBeNull();
});
it("honors an explicit launchOptions.defaultViewport (incl. null)", async () => {
const { launch } = await import("../src/puppeteer.js");
const custom = { width: 640, height: 480 };
await launch({ headless: true, launchOptions: { defaultViewport: custom } });
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toEqual(custom);
// Explicit null honored even in headless (would otherwise default to DEFAULT_VIEWPORT).
vi.mocked(puppeteerMock.default.launch).mockClear();
await launch({ headless: true, launchOptions: { defaultViewport: null } });
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toBeNull();
});
it("Puppeteer headless precedence: top-level headless wins over launchOptions.headless", async () => {
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
const { launch } = await import("../src/puppeteer.js");
// Puppeteer sets headless AFTER the launchOptions spread, so top-level wins at
// launch — the viewport decision must follow the same (top-level) value.
await launch({ headless: true, launchOptions: { headless: false } });
const opts = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(opts.headless).toBe(true);
expect(opts.defaultViewport).toEqual(DEFAULT_VIEWPORT);
});
it("adds --proxy-server for string proxy", async () => {
const { launch } = await import("../src/puppeteer.js");
await launch({ proxy: "http://proxy:8080" });
@@ -212,6 +259,22 @@ describe("puppeteer launchPersistentContext", () => {
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
});
it("headed persistent context uses null defaultViewport (tracks real window)", async () => {
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
const { launchPersistentContext } = await import("../src/puppeteer.js");
await launchPersistentContext({ userDataDir: "./my-profile", headless: false });
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toBeNull();
vi.mocked(puppeteerMock.default.launch).mockClear();
await launchPersistentContext({ userDataDir: "./my-profile", headless: true });
expect(
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
).toEqual(DEFAULT_VIEWPORT);
});
it("uses page.authenticate fallback for http proxy in persistent context on unsupported platform", async () => {
const config = await import("../src/config.js");
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");