diff --git a/package-lock.json b/package-lock.json index e1e25bc..af327e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@modelcontextprotocol/sdk": "^1.0.0", - "chrome-remote-interface": "^0.33.2" + "chrome-remote-interface": "^0.34.0" }, "bin": { "perplexity-comet-mcp": "dist/index.js" @@ -1064,9 +1064,9 @@ } }, "node_modules/chrome-remote-interface": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.33.3.tgz", - "integrity": "sha512-zNnn0prUL86Teru6UCAZ1yU1XeXljHl3gj7OrfPcarEfU62OUU4IujDPdTDW3dAWwRqN3ZMG/Chhkh2gPL/wiw==", + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.34.0.tgz", + "integrity": "sha512-rTTcTZ3zemx8I+nvBii7d8BAF0Ms8LLEroypfvwwZOwSpyNGLE28nStXyCA6VwGp2YSQfmCrQH21F/E+oBFvMw==", "license": "MIT", "dependencies": { "commander": "2.11.x", diff --git a/package.json b/package.json index ee2876f..5e4b486 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@modelcontextprotocol/sdk": "^1.0.0", - "chrome-remote-interface": "^0.33.2" + "chrome-remote-interface": "^0.34.0" }, "devDependencies": { "@types/chrome-remote-interface": "^0.33.0", diff --git a/src/cdp-client.ts b/src/cdp-client.ts index 10eb74e..d524a9b 100644 --- a/src/cdp-client.ts +++ b/src/cdp-client.ts @@ -15,6 +15,160 @@ import type { TabContext, } from "./types.js"; +// chrome-remote-interface@^0.34.0 exposes `ProtocolError` at runtime +// (`module.exports.ProtocolError = ...`), but @types/chrome-remote-interface +// doesn't declare it yet. Cast at import so `instanceof` typechecks; remove +// the cast once DefinitelyTyped/DefinitelyTyped#74992 lands and we pick up +// the updated @types. +interface CdpProtocolError extends Error { + request: { method: string; params?: unknown }; + response: CDP.SendError; +} +const ProtocolError = (CDP as unknown as { + ProtocolError: new (...args: unknown[]) => CdpProtocolError; +}).ProtocolError; + +// Hidden targets (e.g. the Perplexity sidecar panel) can report a 0x0 layout +// viewport in some Comet window states (cold launch, no real browsing tabs in +// front). When that happens, Page.captureScreenshot waits for compositor +// frames that never arrive and stalls for ~2 minutes. Detecting that state +// via Page.getLayoutMetrics() and supplying an explicit clip + +// captureBeyondViewport=true makes the renderer produce a frame immediately. +// +// The predicate is intentionally tight: both dimensions must be zero. Live +// CDP testing confirmed that 0x0 is the only reproducible viewport state +// that triggers the stall — Chromium's Emulation.setDeviceMetricsOverride +// rejects single-zero dimensions and falls back to the natural viewport, +// and the natural 0x0 case is "no layout computed yet" (an all-or-nothing +// state). A 0xN or Nx0 viewport is not a state we can observe in practice. +const SCREENSHOT_FALLBACK_CLIP = { + x: 0, + y: 0, + width: 1280, + height: 800, + scale: 1, +} as const; + +/** + * The slice of the CDP Page domain that `captureScreenshotWithFallback` uses. + * Lets unit tests substitute a hand-written fake without dragging in the full + * `chrome-remote-interface` Page surface. + */ +export interface ScreenshotPageAPI { + bringToFront(): Promise; + getLayoutMetrics(): Promise<{ + cssLayoutViewport?: { clientWidth: number; clientHeight: number }; + layoutViewport?: { clientWidth: number; clientHeight: number }; + }>; + captureScreenshot(opts: { + format: "png" | "jpeg"; + captureBeyondViewport?: boolean; + clip?: typeof SCREENSHOT_FALLBACK_CLIP; + }): Promise; +} + +/** + * Capture a screenshot, falling back to an explicit clip when the layout + * viewport is degenerate. Extracted as a free function so it can be unit + * tested against a fake Page without a live CDP connection. + */ +export async function captureScreenshotWithFallback( + page: ScreenshotPageAPI, + format: "png" | "jpeg" = "png", +): Promise { + try { await page.bringToFront(); } catch { /* not all targets support it */ } + + let clip: typeof SCREENSHOT_FALLBACK_CLIP | undefined; + try { + const metrics = await page.getLayoutMetrics(); + const v = metrics.cssLayoutViewport ?? metrics.layoutViewport; + if (!v?.clientWidth && !v?.clientHeight) { + clip = SCREENSHOT_FALLBACK_CLIP; + } + } catch (err) { + // Chrome-side rejection (e.g. method unsupported on a non-page target): + // apply the fallback so captureScreenshot can still produce a frame. + // Anything else (websocket dropped, unexpected throw): propagate — the + // next CDP call would fail the same way, and masking with a synthetic + // 1280x800 capture would hide a real transport-level failure. + if (err instanceof ProtocolError) { + clip = SCREENSHOT_FALLBACK_CLIP; + } else { + throw err; + } + } + + const result = await page.captureScreenshot({ + format, + ...(clip ? { captureBeyondViewport: true, clip } : {}), + }); + + if (!result?.data) { + throw new Error( + "Screenshot returned empty data. Ensure you're connected to a visible tab with content.", + ); + } + + return result; +} + +/** Per-frame lifecycle accumulator: frameId -> { current loaderId, event names seen }. */ +export type FrameLifecycleMap = Map }>; + +/** + * The slice of the CDP Page domain that `waitForLifecycle` uses. The return + * type of `lifecycleEvent(handler)` is CRI's unsubscribe function — api.js:49 + * returns `() => chrome.removeListener(rawEventName, handler)`. + */ +export interface LifecyclePageAPI { + lifecycleEvent(handler: (params: { name?: string }) => void): () => unknown; +} + +/** + * Wait until any frame in `frameLifecycle` has fired the named + * Page.lifecycleEvent (e.g. 'firstContentfulPaint', 'networkAlmostIdle'). + * Resolves true if the event is in the cache or arrives before the timeout; + * false otherwise. Cleans up its listener and timer on every exit path. + * + * Defensive ordering: the listener is registered *before* scanning the + * cache, so even if the event arrived on an I/O turn between calls it's + * caught by the live listener rather than missed. Single-threaded JS makes + * the synchronous-only path safe today, but the order matters if anything + * upstream (CRI internals, scheduler) ever inserts a microtask here. + * + * Extracted as a free function so it can be unit tested against a fake + * Page + map without a live CDP connection. + */ +export function waitForLifecycle( + page: LifecyclePageAPI, + frameLifecycle: FrameLifecycleMap, + eventName: string, + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + let done = false; + let unsubscribe: (() => unknown) | null = null; + let timer: NodeJS.Timeout | null = null; + const finish = (val: boolean) => { + if (done) return; + done = true; + if (unsubscribe) { try { unsubscribe(); } catch { /* ignore */ } } + if (timer) clearTimeout(timer); + resolve(val); + }; + const listener = (params: { name?: string }) => { + if (params?.name === eventName) finish(true); + }; + try { + unsubscribe = page.lifecycleEvent(listener); + } catch { finish(false); return; } + for (const { events } of frameLifecycle.values()) { + if (events.has(eventName)) { finish(true); return; } + } + timer = setTimeout(() => finish(false), timeoutMs); + }); +} + // Detect if running in WSL (must be before windowsFetch) function isWSL(): boolean { if (platform() !== 'linux') return false; @@ -164,6 +318,13 @@ export class CometCDPClient { // Tab context registry for multi-tab workflow awareness private tabRegistry: Map = new Map(); + // Page lifecycle tracking — events accumulated per frame for the current + // document (loaderId). Used by waitForLifecycle() so screenshots and other + // ops can confirm the renderer has actually painted before they run. + private frameLifecycle: FrameLifecycleMap = new Map(); + private lifecycleListener: ((params: any) => void) | null = null; + private lifecycleUnsubscribe: (() => unknown) | null = null; + get isConnected(): boolean { return this.state.connected && this.client !== null; } @@ -1024,6 +1185,33 @@ export class CometCDPClient { } catch { /* continue */ } } + // Subscribe to Page.lifecycleEvent so we can wait for paint readiness + // (firstContentfulPaint, networkAlmostIdle, etc.) the way Lighthouse and + // Puppeteer do, instead of polling document.readyState. + this.frameLifecycle.clear(); + if (this.lifecycleUnsubscribe) { + try { this.lifecycleUnsubscribe(); } catch { /* ignore */ } + this.lifecycleUnsubscribe = null; + } + this.lifecycleListener = null; + try { + await this.client.Page.setLifecycleEventsEnabled({ enabled: true }); + this.lifecycleListener = (params: any) => { + const { frameId, loaderId, name } = params || {}; + if (!frameId || !loaderId || !name) return; + const existing = this.frameLifecycle.get(frameId); + if (!existing || existing.loaderId !== loaderId) { + this.frameLifecycle.set(frameId, { loaderId, events: new Set([name]) }); + } else { + existing.events.add(name); + } + }; + // chrome-remote-interface's domain event callbacks return an unsubscribe + // function (api.js: `() => chrome.removeListener(rawEventName, handler)`). + // Capture it so disconnect() can deregister cleanly. + this.lifecycleUnsubscribe = this.client.Page.lifecycleEvent(this.lifecycleListener); + } catch { /* lifecycle tracking is best-effort */ } + this.state.connected = true; this.state.activeTabId = targetId; this.lastTargetId = targetId; @@ -1039,6 +1227,12 @@ export class CometCDPClient { * Disconnect from current tab */ async disconnect(): Promise { + if (this.lifecycleUnsubscribe) { + try { this.lifecycleUnsubscribe(); } catch { /* ignore */ } + this.lifecycleUnsubscribe = null; + } + this.lifecycleListener = null; + this.frameLifecycle.clear(); if (this.client) { await this.client.close(); this.client = null; @@ -1112,7 +1306,13 @@ export class CometCDPClient { */ async screenshot(format: "png" | "jpeg" = "png"): Promise { this.ensureConnected(); - return this.client!.Page.captureScreenshot({ format }) as Promise; + + // Wait for paint readiness via Page.lifecycleEvent (Lighthouse/Puppeteer + // approach). If firstContentfulPaint already fired for this document the + // call returns synchronously; otherwise we wait up to 2s for the next FCP. + await waitForLifecycle(this.client!.Page, this.frameLifecycle, 'firstContentfulPaint', 2000); + + return captureScreenshotWithFallback(this.client!.Page, format); } /** diff --git a/tests/unit/cdp-client.lifecycle.test.ts b/tests/unit/cdp-client.lifecycle.test.ts new file mode 100644 index 0000000..58dd0ad --- /dev/null +++ b/tests/unit/cdp-client.lifecycle.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + type FrameLifecycleMap, + waitForLifecycle, +} from "../../src/cdp-client.js"; +import { FakeLifecyclePage } from "./fakes/fake-lifecycle-page.js"; + +function makeMap(): FrameLifecycleMap { + return new Map(); +} + +function seedFrame( + map: FrameLifecycleMap, + frameId: string, + loaderId: string, + events: string[], +): void { + map.set(frameId, { loaderId, events: new Set(events) }); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("waitForLifecycle — cache-hit path", () => { + it("resolves true when the event is already in the cache", async () => { + const page = new FakeLifecyclePage(); + const map = makeMap(); + seedFrame(map, "frame-1", "loader-A", ["firstContentfulPaint"]); + + await expect( + waitForLifecycle(page, map, "firstContentfulPaint", 2000), + ).resolves.toBe(true); + }); + + it("unsubscribes its listener even when the cache hit fires synchronously", async () => { + // Defensive ordering: the listener is registered before the cache scan, + // so a cache hit still has to release it. This guards finding #1 (no + // leaked listeners across calls). + const page = new FakeLifecyclePage(); + const map = makeMap(); + seedFrame(map, "frame-1", "loader-A", ["firstContentfulPaint"]); + + await waitForLifecycle(page, map, "firstContentfulPaint", 2000); + + expect(page.registrations).toBe(1); + expect(page.unsubscribes).toBe(1); + expect(page.liveHandlerCount()).toBe(0); + }); + + it("ignores cache entries for unrelated events and falls through to the listener path", async () => { + vi.useFakeTimers(); + const page = new FakeLifecyclePage(); + const map = makeMap(); + seedFrame(map, "frame-1", "loader-A", ["load", "domContentLoaded"]); + + const pending = waitForLifecycle(page, map, "firstContentfulPaint", 2000); + expect(page.liveHandlerCount()).toBe(1); + + vi.advanceTimersByTime(2000); + await expect(pending).resolves.toBe(false); + }); +}); + +describe("waitForLifecycle — listener path", () => { + it("resolves true when the event arrives after registration", async () => { + const page = new FakeLifecyclePage(); + const map = makeMap(); + + const pending = waitForLifecycle(page, map, "firstContentfulPaint", 2000); + page.emit("firstContentfulPaint"); + + await expect(pending).resolves.toBe(true); + }); + + it("unsubscribes after firing", async () => { + const page = new FakeLifecyclePage(); + const map = makeMap(); + + const pending = waitForLifecycle(page, map, "firstContentfulPaint", 2000); + page.emit("firstContentfulPaint"); + await pending; + + expect(page.unsubscribes).toBe(1); + expect(page.liveHandlerCount()).toBe(0); + }); + + it("ignores unrelated events", async () => { + vi.useFakeTimers(); + const page = new FakeLifecyclePage(); + const map = makeMap(); + + const pending = waitForLifecycle(page, map, "firstContentfulPaint", 2000); + page.emit("load"); + page.emit("domContentLoaded"); + page.emit("networkAlmostIdle"); + vi.advanceTimersByTime(2000); + + await expect(pending).resolves.toBe(false); + }); + + it("registers the listener before the cache scan (defensive ordering)", () => { + // Single-threaded JS makes this safe today even without the ordering; + // the test pins the behavior so a future refactor can't silently + // reintroduce a microtask-window race. + const page = new FakeLifecyclePage(); + const map = makeMap(); + + // The cache is empty — if the scan ran before registration, the + // listener would never be registered before the function returned + // its pending Promise. Asserting that the handler is live immediately + // after the call confirms the order. + waitForLifecycle(page, map, "firstContentfulPaint", 2000); + expect(page.liveHandlerCount()).toBe(1); + }); +}); + +describe("waitForLifecycle — timeout path", () => { + it("resolves false when the timeout elapses with no event", async () => { + vi.useFakeTimers(); + const page = new FakeLifecyclePage(); + const map = makeMap(); + + const pending = waitForLifecycle(page, map, "firstContentfulPaint", 2000); + vi.advanceTimersByTime(2000); + + await expect(pending).resolves.toBe(false); + }); + + it("unsubscribes on timeout", async () => { + vi.useFakeTimers(); + const page = new FakeLifecyclePage(); + const map = makeMap(); + + const pending = waitForLifecycle(page, map, "firstContentfulPaint", 2000); + vi.advanceTimersByTime(2000); + await pending; + + expect(page.unsubscribes).toBe(1); + expect(page.liveHandlerCount()).toBe(0); + }); + + it("does not resolve before the timeout fires", async () => { + vi.useFakeTimers(); + const page = new FakeLifecyclePage(); + const map = makeMap(); + + const pending = waitForLifecycle(page, map, "firstContentfulPaint", 2000); + vi.advanceTimersByTime(1999); + + const settled = await Promise.race([ + pending.then((v) => ({ resolved: true, value: v })), + Promise.resolve({ resolved: false as const }), + ]); + expect(settled).toEqual({ resolved: false }); + + vi.advanceTimersByTime(1); + await expect(pending).resolves.toBe(false); + }); +}); + +describe("waitForLifecycle — error handling", () => { + it("resolves false (best-effort) when page.lifecycleEvent throws", async () => { + // Models the connect()-time guard: if CRI rejects the subscription + // (older protocol versions, etc.), `waitForLifecycle` should degrade + // to "feature not available" rather than throw. + const page = new FakeLifecyclePage(); + page.setLifecycleEventToThrow(); + const map = makeMap(); + + await expect( + waitForLifecycle(page, map, "firstContentfulPaint", 2000), + ).resolves.toBe(false); + }); + + it("does not register a timer when the listener registration throws", async () => { + vi.useFakeTimers(); + const page = new FakeLifecyclePage(); + page.setLifecycleEventToThrow(); + const map = makeMap(); + + await waitForLifecycle(page, map, "firstContentfulPaint", 2000); + // If a timer leaked, advancing time after the Promise settled would + // do nothing observable here, but the more important assertion is that + // no handler was ever registered: + expect(page.registrations).toBe(0); + expect(page.liveHandlerCount()).toBe(0); + }); +}); + +describe("waitForLifecycle — scanning frameLifecycle", () => { + it("finds an event registered under any frameId", async () => { + const page = new FakeLifecyclePage(); + const map = makeMap(); + seedFrame(map, "frame-A", "loader-1", ["load"]); + seedFrame(map, "frame-B", "loader-2", ["firstContentfulPaint"]); + seedFrame(map, "frame-C", "loader-3", ["domContentLoaded"]); + + await expect( + waitForLifecycle(page, map, "firstContentfulPaint", 2000), + ).resolves.toBe(true); + }); +}); diff --git a/tests/unit/cdp-client.screenshot.test.ts b/tests/unit/cdp-client.screenshot.test.ts new file mode 100644 index 0000000..413ff9c --- /dev/null +++ b/tests/unit/cdp-client.screenshot.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from "vitest"; +import { captureScreenshotWithFallback } from "../../src/cdp-client.js"; +import { FakeScreenshotPage } from "./fakes/fake-screenshot-page.js"; + +const FALLBACK_CLIP = { + x: 0, + y: 0, + width: 1280, + height: 800, + scale: 1, +}; + +// The predicate that triggers the fallback is `!width && !height` — both +// dimensions must be zero. Live CDP testing showed 0×0 is the only +// reproducible state that hangs Page.captureScreenshot: Chromium's +// Emulation.setDeviceMetricsOverride rejects single-zero dimensions, and +// the natural 0×0 case (visibilityState='hidden' with no layout computed) +// is all-or-nothing. PNG/JPEG headers also require both dimensions to be +// >= 1, so a zero-dim image isn't a valid output format. The 0×N and N×0 +// boundary tests below therefore expect the wrapper to NOT apply the +// fallback (no synthetic 1280×800 masking the anomaly) and to surface +// the inevitable encoder failure via the empty-data guard. +describe("captureScreenshotWithFallback — non-degenerate viewport", () => { + it("forwards format and supplies no clip when the cssLayoutViewport is non-zero", async () => { + const page = new FakeScreenshotPage(); + page.setMetrics({ cssLayoutViewport: { clientWidth: 1024, clientHeight: 768 } }); + + await captureScreenshotWithFallback(page, "png"); + + expect(page.captureScreenshotCalls).toHaveLength(1); + expect(page.captureScreenshotCalls[0]).toEqual({ format: "png" }); + }); + + it("falls back to layoutViewport when cssLayoutViewport is missing", async () => { + const page = new FakeScreenshotPage(); + page.setMetrics({ layoutViewport: { clientWidth: 800, clientHeight: 600 } }); + + await captureScreenshotWithFallback(page, "png"); + + expect(page.captureScreenshotCalls[0]).toEqual({ format: "png" }); + }); + + it("passes through (no fallback) and fails loudly when only width is 0", async () => { + const page = new FakeScreenshotPage(); + page.setMetrics({ cssLayoutViewport: { clientWidth: 0, clientHeight: 800 } }); + // Model real-browser behavior: no encoder can produce output for a + // zero-dim image, so the captureScreenshot call returns empty data. + page.setScreenshotData(undefined); + + await expect(captureScreenshotWithFallback(page, "png")).rejects.toThrow( + /empty data/i, + ); + expect(page.captureScreenshotCalls[0]).toEqual({ format: "png" }); + }); + + it("passes through (no fallback) and fails loudly when only height is 0", async () => { + const page = new FakeScreenshotPage(); + page.setMetrics({ cssLayoutViewport: { clientWidth: 1024, clientHeight: 0 } }); + page.setScreenshotData(undefined); + + await expect(captureScreenshotWithFallback(page, "png")).rejects.toThrow( + /empty data/i, + ); + expect(page.captureScreenshotCalls[0]).toEqual({ format: "png" }); + }); + + it("forwards jpeg format unchanged", async () => { + const page = new FakeScreenshotPage(); + page.setMetrics({ cssLayoutViewport: { clientWidth: 1024, clientHeight: 768 } }); + + await captureScreenshotWithFallback(page, "jpeg"); + + expect(page.captureScreenshotCalls[0]).toEqual({ format: "jpeg" }); + }); + + it("calls bringToFront before capture (best-effort)", async () => { + const page = new FakeScreenshotPage(); + await captureScreenshotWithFallback(page, "png"); + expect(page.bringToFrontCalls).toBe(1); + }); +}); + +describe("captureScreenshotWithFallback — degenerate viewport", () => { + it("supplies the fallback clip + captureBeyondViewport when viewport is 0×0", async () => { + const page = new FakeScreenshotPage(); + page.setMetrics({ cssLayoutViewport: { clientWidth: 0, clientHeight: 0 } }); + + await captureScreenshotWithFallback(page, "png"); + + expect(page.captureScreenshotCalls[0]).toEqual({ + format: "png", + captureBeyondViewport: true, + clip: FALLBACK_CLIP, + }); + }); + + // Chrome-side rejection (ProtocolError) on getLayoutMetrics typically + // means the method isn't supported on this target type. The wrapper + // recovers because captureScreenshot may still succeed at the fallback + // dimensions. + it("supplies the fallback clip when getLayoutMetrics throws a ProtocolError", async () => { + const page = new FakeScreenshotPage(); + page.setMetricsToThrowProtocolError(); + + await captureScreenshotWithFallback(page, "png"); + + expect(page.captureScreenshotCalls[0]).toEqual({ + format: "png", + captureBeyondViewport: true, + clip: FALLBACK_CLIP, + }); + }); + + // Transport-level or unexpected errors must NOT be masked with a + // synthetic 1280x800 capture — propagate so the caller sees the real + // failure (websocket dropped, target detached, etc.). + it("propagates non-ProtocolError throws from getLayoutMetrics", async () => { + const page = new FakeScreenshotPage(); + page.setMetricsToThrowGeneric(); + + await expect(captureScreenshotWithFallback(page, "png")).rejects.toThrow( + /websocket dropped/i, + ); + expect(page.captureScreenshotCalls).toHaveLength(0); + }); + + it("supplies the fallback clip when both viewport fields are absent", async () => { + const page = new FakeScreenshotPage(); + page.setMetrics({}); + + await captureScreenshotWithFallback(page, "png"); + + expect(page.captureScreenshotCalls[0]).toEqual({ + format: "png", + captureBeyondViewport: true, + clip: FALLBACK_CLIP, + }); + }); +}); + +describe("captureScreenshotWithFallback — error handling", () => { + it("swallows bringToFront errors and continues to capture", async () => { + const page = new FakeScreenshotPage(); + page.setBringToFrontToThrow(); + + const result = await captureScreenshotWithFallback(page, "png"); + + expect(page.captureScreenshotCalls).toHaveLength(1); + expect(result.data).toBeTruthy(); + }); + + it("throws a descriptive error when captureScreenshot returns empty data", async () => { + const page = new FakeScreenshotPage(); + page.setScreenshotData(undefined); + + await expect(captureScreenshotWithFallback(page, "png")).rejects.toThrow( + /empty data/i, + ); + }); +}); diff --git a/tests/unit/fakes/fake-lifecycle-page.ts b/tests/unit/fakes/fake-lifecycle-page.ts new file mode 100644 index 0000000..ecd7a9b --- /dev/null +++ b/tests/unit/fakes/fake-lifecycle-page.ts @@ -0,0 +1,49 @@ +// Minimal fake of the CDP `Page` slice that `waitForLifecycle` uses. +// Implements just `lifecycleEvent` — enough to satisfy `LifecyclePageAPI` — +// and exposes hooks so tests can fire events or assert on unsubscribe. + +import type { LifecyclePageAPI } from "../../../src/cdp-client.js"; + +type Handler = (params: { name?: string }) => void; + +export class FakeLifecyclePage implements LifecyclePageAPI { + /** Handlers currently registered via `lifecycleEvent`. */ + private readonly handlers: Set = new Set(); + + /** Number of times `lifecycleEvent` has been called (handler registrations). */ + public registrations = 0; + + /** Number of unsubscribe-function invocations. */ + public unsubscribes = 0; + + /** When true, `lifecycleEvent` throws on next call (simulates CRI rejection). */ + private throwOnRegister = false; + + setLifecycleEventToThrow(): void { + this.throwOnRegister = true; + } + + /** Emit a lifecycle event to every currently-registered handler. */ + emit(name: string): void { + for (const handler of this.handlers) { + handler({ name }); + } + } + + /** Number of currently-live handlers (registered minus unsubscribed). */ + liveHandlerCount(): number { + return this.handlers.size; + } + + lifecycleEvent(handler: Handler): () => unknown { + if (this.throwOnRegister) { + throw new Error("lifecycleEvent registration failed"); + } + this.registrations++; + this.handlers.add(handler); + return () => { + this.unsubscribes++; + this.handlers.delete(handler); + }; + } +} diff --git a/tests/unit/fakes/fake-screenshot-page.ts b/tests/unit/fakes/fake-screenshot-page.ts new file mode 100644 index 0000000..94e82e1 --- /dev/null +++ b/tests/unit/fakes/fake-screenshot-page.ts @@ -0,0 +1,85 @@ +// Minimal fake of the CDP `Page` slice that `captureScreenshotWithFallback` +// uses. Implements just `bringToFront`, `getLayoutMetrics`, and +// `captureScreenshot` — enough to satisfy the `ScreenshotPageAPI` type — and +// records every call so tests can assert on the args. + +import CDP from "chrome-remote-interface"; +import type { ScreenshotPageAPI } from "../../../src/cdp-client.js"; +import type { ScreenshotResult } from "../../../src/types.js"; + +// chrome-remote-interface@^0.34.0 exposes ProtocolError but @types doesn't +// declare it; mirror the cast pattern used in src/cdp-client.ts so tests can +// construct one for the discriminator check. +const ProtocolError = (CDP as unknown as { + ProtocolError: new ( + request: { method: string }, + response: { code: number; message: string; data?: string }, + ) => Error; +}).ProtocolError; + +type LayoutMetrics = Awaited>; +type CaptureScreenshotArgs = Parameters[0]; + +type ThrowMode = "none" | "protocol-error" | "generic"; + +export class FakeScreenshotPage implements ScreenshotPageAPI { + public bringToFrontCalls = 0; + public readonly captureScreenshotCalls: CaptureScreenshotArgs[] = []; + + private bringToFrontShouldThrow = false; + private metricsResponse: LayoutMetrics = { + cssLayoutViewport: { clientWidth: 1024, clientHeight: 768 }, + }; + private metricsThrowMode: ThrowMode = "none"; + private screenshotData: string | undefined = "ZmFrZS1zY3JlZW5zaG90LWRhdGE="; // base64 "fake-screenshot-data" + + setMetrics(metrics: LayoutMetrics): void { + this.metricsResponse = metrics; + this.metricsThrowMode = "none"; + } + + /** Make `getLayoutMetrics` throw a `CDP.ProtocolError` — models Chrome + * rejecting the method (e.g. unsupported on a non-page target). */ + setMetricsToThrowProtocolError(): void { + this.metricsThrowMode = "protocol-error"; + } + + /** Make `getLayoutMetrics` throw a plain `Error` — models transport-level + * or unexpected failures that should propagate, not trigger the fallback. */ + setMetricsToThrowGeneric(): void { + this.metricsThrowMode = "generic"; + } + + setBringToFrontToThrow(): void { + this.bringToFrontShouldThrow = true; + } + + setScreenshotData(data: string | undefined): void { + this.screenshotData = data; + } + + async bringToFront(): Promise { + this.bringToFrontCalls++; + if (this.bringToFrontShouldThrow) { + throw new Error("bringToFront not supported on this target"); + } + } + + async getLayoutMetrics(): Promise { + if (this.metricsThrowMode === "protocol-error") { + throw new ProtocolError( + { method: "Page.getLayoutMetrics" }, + { code: -32601, message: "'Page.getLayoutMetrics' wasn't found" }, + ); + } + if (this.metricsThrowMode === "generic") { + throw new Error("websocket dropped"); + } + return this.metricsResponse; + } + + async captureScreenshot(opts: CaptureScreenshotArgs): Promise { + this.captureScreenshotCalls.push(opts); + return { data: this.screenshotData } as ScreenshotResult; + } +}