import { expect, it, onTestFinished, vi } from "vitest"; import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; import { CdpConnection } from "../../../src/browser/CdpConnection.js"; import { recordValue } from "../../../src/browser/CdpCaptureValues.js"; import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js"; import { startFakeCdpBrowser, type FakeOptions, } from "../../fixtures/fakeCdpBrowser.js"; import { trackBrowser, waitFor } from "./cdpBrowserProvider.support.js"; const clockResponseEvents: NonNullable = ( command, origin, ) => { if (command.method === "Page.enable") return [ { method: "Page.frameNavigated", sessionId: command.sessionId, params: { frame: { id: "frame-main", loaderId: "loader-before-window", url: `${origin}/initial`, }, }, }, ]; if (command.method === "Performance.getMetrics") return [ { method: "Page.lifecycleEvent", sessionId: command.sessionId, params: { frameId: "frame-main", loaderId: "loader-before-window", name: "load", timestamp: 19, }, }, { method: "Page.navigatedWithinDocument", sessionId: command.sessionId, params: { frameId: "frame-main", url: `${origin}#during-clock-response`, navigationType: "historyApi", }, }, { method: "Network.requestWillBeSent", sessionId: command.sessionId, params: { requestId: "request-before-window", timestamp: 19, type: "Document", frameId: "frame-main", redirectResponse: { url: `${origin}/before-redirect` }, request: { url: `${origin}/before-window` }, }, }, { method: "Network.requestWillBeSent", sessionId: command.sessionId, params: { requestId: "request-window-redirect", timestamp: 21, type: "Document", frameId: "frame-main", redirectResponse: { url: `${origin}/prior-hop-context` }, request: { url: `${origin}/after-cutoff` }, }, }, { method: "Network.loadingFailed", sessionId: command.sessionId, params: { requestId: "failure-before-window", timestamp: 19, frameId: "frame-main", errorText: "net::ERR_CONNECTION_REFUSED", }, }, { method: "Network.loadingFailed", sessionId: command.sessionId, params: { requestId: "failure-window", timestamp: 22, frameId: "frame-main", errorText: "net::ERR_CONNECTION_REFUSED", }, }, ]; if (command.method === "Page.setLifecycleEventsEnabled") return [ { method: "Page.lifecycleEvent", sessionId: command.sessionId, params: { frameId: "frame-main", loaderId: "loader-current-window", name: "load", timestamp: 21, }, }, { method: "Page.navigatedWithinDocument", sessionId: command.sessionId, params: { frameId: "frame-main", url: `${origin}#in-window`, navigationType: "historyApi", }, }, ]; return undefined; }; it("buffers clock-response events while dropping lifecycle history before the clock boundary", async () => { const browser = await startFakeCdpBrowser({ eventsBeforeReply: [ "Page.enable", "Performance.getMetrics", "Page.setLifecycleEventsEnabled", ], commandResult: (command) => { if (command.method === "Performance.enable") return {}; if (command.method === "Performance.getMetrics") return { metrics: [{ name: "Timestamp", value: 20 }] }; return undefined; }, commandEvents: clockResponseEvents, }); trackBrowser(browser); const result = await new CdpBrowserProvider().observeSession( observeWebSessionInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 20, }), ); if (!result.ok) throw result.error; expect(browser.commands.map(({ method }) => method)).toContain( "Performance.getMetrics", ); expect(result.value.timeline).toContainEqual( expect.objectContaining({ type: "lifecycle", loader_id: "loader-current-window", timestamp: 21, }), ); expect(result.value.timeline).not.toContainEqual( expect.objectContaining({ loader_id: "loader-before-window" }), ); expect(result.value.timeline).not.toContainEqual( expect.objectContaining({ type: "navigation_committed" }), ); expect(result.value.timeline).toContainEqual( expect.objectContaining({ type: "same_document_navigation", url: `${browser.allowedOrigin}#during-clock-response`, timestamp: null, }), ); expect(result.value.timeline).not.toContainEqual( expect.objectContaining({ request_id: "request-before-window" }), ); expect(result.value.timeline).toContainEqual( expect.objectContaining({ type: "redirect", request_id: "request-window-redirect", timestamp: 21, url: `${browser.allowedOrigin}/after-cutoff`, }), ); expect(result.value.timeline).not.toContainEqual( expect.objectContaining({ request_id: "failure-before-window" }), ); expect(result.value.timeline).toContainEqual( expect.objectContaining({ type: "load_failed", request_id: "failure-window", timestamp: 22, }), ); expect(result.value.limitations).toContain( "Timeline rows without a comparable producer timestamp have a null timestamp. Buffered events with unknown timing are retained as context only; they do not establish activity inside the selected observation window.", ); }); it.each([ ["missing Timestamp", { metrics: [] }], ["invalid Timestamp", { metrics: [{ name: "Timestamp", value: -1 }] }], ])( "fails closed when Performance.getMetrics returns %s", async (_name, resultValue) => { const browser = await startFakeCdpBrowser({ commandResult: (command) => command.method === "Performance.enable" ? {} : command.method === "Performance.getMetrics" ? resultValue : undefined, }); trackBrowser(browser); const result = await new CdpBrowserProvider().observeSession( observeWebSessionInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 20, }), ); expect(result).toMatchObject({ ok: false, error: { _tag: "BrowserObservationError", reason: "protocol_error", }, }); expect(browser.commands.map(({ method }) => method)).not.toContain( "Page.setLifecycleEventsEnabled", ); }, ); it.each([ ["out-of-scope navigation", "Page.frameNavigated"], ["target termination", "Inspector.targetCrashed"], ])( "fails closed when the pre-clock event buffer is exceeded before %s", async (_name, eventAfterBudget) => { const oversizedHash = "x".repeat(8 * 1024 * 1024); const browser = await startFakeCdpBrowser({ eventsBeforeReply: ["Performance.getMetrics"], commandResult: (command) => command.method === "Performance.enable" ? {} : command.method === "Performance.getMetrics" ? { metrics: [{ name: "Timestamp", value: 20 }] } : undefined, commandEvents: (command, origin) => { if (command.method !== "Performance.getMetrics") return undefined; return [ { method: "Page.frameNavigated", sessionId: command.sessionId, params: { frame: { id: "frame-main", loaderId: "oversized-loader", url: `${origin}#${oversizedHash}`, }, }, }, eventAfterBudget === "Page.frameNavigated" ? { method: "Page.frameNavigated", sessionId: command.sessionId, params: { frame: { id: "frame-main", loaderId: "outside-after-budget", url: "https://outside.example/path", }, }, } : { method: "Inspector.targetCrashed", sessionId: command.sessionId, params: {}, }, ]; }, }); trackBrowser(browser); const result = await new CdpBrowserProvider().observeSession( observeWebSessionInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 20, }), ); expect(result).toMatchObject({ ok: false, error: { _tag: "BrowserObservationError", reason: "payload_limit", userMessage: expect.stringContaining("retained CDP event buffer"), }, }); expect(browser.commands.map(({ method }) => method)).not.toContain( "Page.setLifecycleEventsEnabled", ); }, ); it("ignores unrelated protocol payloads before charging the timeline buffer", async () => { const browser = await startFakeCdpBrowser({ eventsBeforeReply: ["Performance.getMetrics"], commandResult: (command) => command.method === "Performance.getMetrics" ? { metrics: [{ name: "Timestamp", value: 20 }] } : undefined, commandEvents: (command, origin) => { if (command.method !== "Performance.getMetrics") return undefined; return [ { method: "Network.responseReceived", sessionId: command.sessionId, params: { requestId: "unselected-response", timestamp: 21, type: "Document", response: { url: origin, status: 200, headers: { "x-large-field": "x".repeat(8 * 1024 * 1024) }, }, }, }, { method: "Page.navigatedWithinDocument", sessionId: command.sessionId, params: { frameId: "frame-main", url: `${origin}#selected` }, }, ]; }, }); trackBrowser(browser); const result = await new CdpBrowserProvider().observeSession( observeWebSessionInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 1, }), ); if (!result.ok) throw result.error; expect(result.value.timeline).toContainEqual( expect.objectContaining({ type: "same_document_navigation", url: `${browser.allowedOrigin}#selected`, timestamp: null, }), ); }); it("keeps cancellation authoritative while an over-budget clock request is pending", async () => { let received = false; const onEvent = CdpConnection.prototype.onEvent; const subscribe = vi .spyOn(CdpConnection.prototype, "onEvent") .mockImplementation(function (this: CdpConnection, listener) { return onEvent.call(this, (event) => { listener(event); if ( event.method === "Page.frameNavigated" && recordValue(recordValue(event.params)?.frame)?.loaderId === "oversized-before-clock" ) // All subscribers finish synchronously before this microtask, including // the timeline owner that admits or rejects the received payload. queueMicrotask(() => { received = true; }); }); }); onTestFinished(() => subscribe.mockRestore()); const browser = await startFakeCdpBrowser({ hangOnMethod: "Performance.getMetrics", }); trackBrowser(browser); const controller = new AbortController(); const pending = new CdpBrowserProvider().observeSession( observeWebSessionInputSchema.parse({ cdp_endpoint: browser.endpoint, allowed_origins: [browser.allowedOrigin], target_id: "allowed-page", observation_ms: 20, }), { signal: controller.signal }, ); await waitFor( () => browser.commands.some( ({ method }) => method === "Performance.getMetrics", ), "the performance clock request did not start", ); browser.emitEvent({ method: "Page.frameNavigated", sessionId: "session-1", params: { frame: { id: "frame-main", loaderId: "oversized-before-clock", url: `https://outside.example/${"x".repeat(8 * 1024 * 1024)}`, }, }, }); await waitFor(() => received, "the over-budget event was not received"); controller.abort(); await expect(pending).resolves.toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError", operation: "observe_web_session", }, }); expect(browser.commands.map(({ method }) => method)).not.toContain( "Page.setLifecycleEventsEnabled", ); await waitFor( () => browser.activeConnectionCount() === 0, "the cancelled observation did not release its CDP connection", ); });