Files
rea/tests/boundary/browser/cdpObservationWindowBuffer.test.ts
T
morluto 2a0410f171 fix(browser)!: bind session timelines to the observation window
Sample the browser monotonic clock with observation already subscribed, filter historical lifecycle and network facts, and preserve scope and termination handling when event time is unknown.

Bound pre-arm retention using transport-owned wire bytes. Fail closed when the relevant-event buffer fills, and keep unrelated protocol payloads outside that budget.

BREAKING CHANGE: observe_web_session timeline timestamps can be null when the producer supplies no comparable timestamp. Consumers must handle unknown time explicitly.

Refs #5609
2026-10-21 03:27:31 +08:00

429 lines
13 KiB
TypeScript

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<FakeOptions["commandEvents"]> = (
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",
);
});