mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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
This commit is contained in:
1 parent
1629266e3c
commit
2a0410f171
12 files changed
+1218
-64
No files matched your search
@@ -288,6 +288,20 @@ Call `list_browser_targets` first because target IDs are browser-instance-specif
|
||||
|
||||
REA establishes a main-frame origin boundary before enabling Runtime, Debugger, or Network observation, rechecks it before document capture, and validates it again afterward. If the main frame navigates during final capture, REA discards the mixed result and returns `target_changed`.
|
||||
|
||||
The web-session timeline uses CDP monotonic seconds where the event producer
|
||||
provides a comparable timestamp; absent or invalid timestamps are `null` rather
|
||||
than a synthetic zero. Events buffered while REA samples the window boundary are
|
||||
retained only when their timestamp places them after that boundary; older events
|
||||
are excluded. A buffered event without a comparable timestamp may remain as
|
||||
contextual evidence with `timestamp: null`, accompanied by a limitation stating
|
||||
that it does not establish activity in the selected window. Main-frame scope
|
||||
enforcement still applies to contextual navigation events. Network redirect
|
||||
events retain their paired prior-hop response context when the timestamped
|
||||
next-request event falls inside the window. The pre-arm timeline buffer is bounded
|
||||
by original CDP message bytes. If clock sampling completes after that budget is
|
||||
exhausted, the operation returns `payload_limit` instead of a partial window.
|
||||
Cancellation and clock-request failures retain their own outcomes.
|
||||
|
||||
## Data minimization
|
||||
|
||||
The Link projection parses repeated CDP header field lines independently and
|
||||
|
||||
@@ -15,7 +15,12 @@ const MAX_WEBSOCKET_PAYLOAD_BYTES = 0x7fff_ffff;
|
||||
export interface CdpEvent {
|
||||
readonly method: string;
|
||||
readonly params: unknown;
|
||||
readonly sessionId?: string;
|
||||
readonly sessionId?: string | undefined;
|
||||
}
|
||||
|
||||
export interface CdpTransportEvent extends CdpEvent {
|
||||
/** Original WebSocket message payload size, before JSON parsing. */
|
||||
readonly wireBytes: number;
|
||||
}
|
||||
|
||||
interface PendingCommand {
|
||||
@@ -29,7 +34,7 @@ interface PendingCommand {
|
||||
/** Correlated JSON-RPC transport for one CDP target operation. */
|
||||
export class CdpConnection {
|
||||
readonly #pending = new Map<number, PendingCommand>();
|
||||
readonly #listeners = new Set<(event: CdpEvent) => void>();
|
||||
readonly #listeners = new Set<(event: CdpTransportEvent) => void>();
|
||||
readonly #disconnectListeners = new Set<
|
||||
(error: BrowserObservationError) => void
|
||||
>();
|
||||
@@ -114,7 +119,7 @@ export class CdpConnection {
|
||||
}
|
||||
|
||||
/** Subscribe to validated CDP event envelopes. */
|
||||
onEvent(listener: (event: CdpEvent) => void): () => void {
|
||||
onEvent(listener: (event: CdpTransportEvent) => void): () => void {
|
||||
this.#listeners.add(listener);
|
||||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
@@ -316,9 +321,11 @@ export class CdpConnection {
|
||||
this.#failPending("protocol_error");
|
||||
return;
|
||||
}
|
||||
const event: CdpEvent = {
|
||||
const wireBytes = rawByteLength(data);
|
||||
const event: CdpTransportEvent = {
|
||||
method: message.method,
|
||||
params: "params" in message ? message.params : {},
|
||||
wireBytes,
|
||||
...(typeof message.sessionId === "string"
|
||||
? { sessionId: message.sessionId }
|
||||
: {}),
|
||||
@@ -437,6 +444,11 @@ const rawText = (data: RawData): string =>
|
||||
? data.toString("utf8")
|
||||
: Buffer.from(data).toString("utf8");
|
||||
|
||||
const rawByteLength = (data: RawData): number =>
|
||||
Array.isArray(data)
|
||||
? data.reduce((bytes, chunk) => bytes + chunk.byteLength, 0)
|
||||
: data.byteLength;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
|
||||
@@ -5,8 +5,13 @@ import type {
|
||||
} from "../domain/browserSession.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { BrowserObservationError } from "../domain/browserObservationError.js";
|
||||
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
|
||||
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
|
||||
import { CdpConnection, type CdpEvent } from "./CdpConnection.js";
|
||||
import {
|
||||
CdpConnection,
|
||||
type CdpEvent,
|
||||
type CdpTransportEvent,
|
||||
} from "./CdpConnection.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import { authorizedMainFrame } from "./CdpAuthorizedMainFrame.js";
|
||||
import {
|
||||
@@ -15,6 +20,7 @@ import {
|
||||
isHttpUrl,
|
||||
numberValue,
|
||||
recordValue,
|
||||
recordsValue,
|
||||
cdpStringValue,
|
||||
type UnknownRecord,
|
||||
} from "./CdpCaptureValues.js";
|
||||
@@ -38,6 +44,7 @@ type TimelineEvent = WebObservationSession["timeline"][number];
|
||||
interface TimelineEntryOptions {
|
||||
readonly type: TimelineEvent["type"];
|
||||
readonly params: UnknownRecord;
|
||||
readonly timestamp?: number | null;
|
||||
readonly rawUrl?: string | null;
|
||||
readonly requestId?: string | null;
|
||||
readonly detail?: string | null;
|
||||
@@ -60,9 +67,7 @@ export const observeCdpSession = async (
|
||||
if (mainFrameId === undefined)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
const capture = new TimelineCapture(allowedOrigins, mainFrameId, initialUrl);
|
||||
const removeListener = context.connection.onEvent((event) => {
|
||||
if (cdpTargetEventMatches(event, context.sessionId)) capture.ingest(event);
|
||||
});
|
||||
let removeListener = (): void => {};
|
||||
const removeDisconnectListener = context.connection.onDisconnect(() =>
|
||||
capture.targetTerminated(),
|
||||
);
|
||||
@@ -79,13 +84,35 @@ export const observeCdpSession = async (
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
await context.connection.send(
|
||||
"Performance.enable",
|
||||
{ timeDomain: "timeTicks" },
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
removeListener = context.connection.onEvent((event) => {
|
||||
if (cdpTargetEventMatches(event, context.sessionId))
|
||||
capture.ingest(event);
|
||||
});
|
||||
// Host wall time marks command dispatch. The returned browser monotonic
|
||||
// timestamp is the authoritative cutoff for timestamped lifecycle facts.
|
||||
const armedAt = new Date().toISOString();
|
||||
const armTimestamp = performanceTimestamp(
|
||||
await context.connection.send(
|
||||
"Performance.getMetrics",
|
||||
{},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
),
|
||||
);
|
||||
capture.assertPreArmBufferWithinLimit();
|
||||
capture.arm(armTimestamp);
|
||||
await context.connection.send(
|
||||
"Page.setLifecycleEventsEnabled",
|
||||
{ enabled: true },
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
);
|
||||
const armedAt = new Date().toISOString();
|
||||
await context.progress?.report({
|
||||
phase: "browser_observation",
|
||||
completed: 1,
|
||||
@@ -127,6 +154,11 @@ export const observeCdpSession = async (
|
||||
"The timeline begins after REA attaches; earlier navigation and network activity is unavailable.",
|
||||
"Only navigation metadata is retained; headers, bodies, cookies, console values, and payloads are not captured by this tool.",
|
||||
"An out-of-policy destination ends collection immediately and is recorded without its URL.",
|
||||
...(capture.hasUnknownEventTime
|
||||
? [
|
||||
"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.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
@@ -143,6 +175,11 @@ class TimelineCapture {
|
||||
#pendingReload = false;
|
||||
#scopeExcluded = false;
|
||||
#endReason: EndReason | undefined;
|
||||
#armTimestamp: number | undefined;
|
||||
#eventsBeforeArm: CdpTransportEvent[] = [];
|
||||
#eventsBeforeArmBytes = 0;
|
||||
#preArmBufferExceeded = false;
|
||||
#hasUnknownEventTime = false;
|
||||
readonly #listeners = new Set<(reason: EndReason) => void>();
|
||||
|
||||
constructor(
|
||||
@@ -154,36 +191,87 @@ class TimelineCapture {
|
||||
allowedSanitizedUrl(initialUrl, allowedOrigins)?.url ?? null;
|
||||
}
|
||||
|
||||
ingest(event: CdpEvent): void {
|
||||
ingest(event: CdpTransportEvent): void {
|
||||
if (this.#endReason !== undefined || this.#scopeExcluded) return;
|
||||
if (!isTimelineEvent(event.method)) return;
|
||||
if (this.#armTimestamp === undefined) {
|
||||
this.#bufferBeforeArm(event);
|
||||
return;
|
||||
}
|
||||
this.#ingestArmed(event);
|
||||
}
|
||||
|
||||
#ingestArmed(event: CdpTransportEvent, bufferedBeforeArm = false): void {
|
||||
if (this.#endReason !== undefined || this.#scopeExcluded) return;
|
||||
const params = recordValue(event.params);
|
||||
if (params === undefined) return;
|
||||
const contextOnly =
|
||||
bufferedBeforeArm && hasNoComparableProducerTimestamp(event.method);
|
||||
if (contextOnly) this.#hasUnknownEventTime = true;
|
||||
switch (event.method) {
|
||||
case "Page.frameRequestedNavigation":
|
||||
this.#navigationRequested(event, params);
|
||||
this.#navigationRequested(event, params, contextOnly);
|
||||
break;
|
||||
case "Page.frameNavigated":
|
||||
this.#navigationCommitted(event, params);
|
||||
this.#navigationCommitted(event, params, contextOnly);
|
||||
break;
|
||||
case "Page.navigatedWithinDocument":
|
||||
this.#sameDocument(event, params);
|
||||
this.#sameDocument(event, params, contextOnly);
|
||||
break;
|
||||
case "Network.requestWillBeSent":
|
||||
this.#redirect(params);
|
||||
this.#redirect(params, bufferedBeforeArm);
|
||||
break;
|
||||
case "Network.loadingFailed":
|
||||
this.#loadingFailed(params);
|
||||
this.#loadingFailed(params, bufferedBeforeArm);
|
||||
break;
|
||||
case "Page.lifecycleEvent":
|
||||
this.#lifecycle(params);
|
||||
break;
|
||||
case "Inspector.targetCrashed":
|
||||
case "Target.detachedFromTarget":
|
||||
this.targetTerminated();
|
||||
this.targetTerminated(params);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
arm(timestamp: number): void {
|
||||
this.#armTimestamp = timestamp;
|
||||
// Events can arrive after the browser samples Timestamp but before its
|
||||
// command response. Defer interpretation so timestamped facts use the
|
||||
// sampled cutoff instead of being lost at the transport boundary.
|
||||
const pending = this.#eventsBeforeArm;
|
||||
this.#eventsBeforeArm = [];
|
||||
this.#eventsBeforeArmBytes = 0;
|
||||
for (const event of pending) this.#ingestArmed(event, true);
|
||||
}
|
||||
|
||||
assertPreArmBufferWithinLimit(): void {
|
||||
if (!this.#preArmBufferExceeded) return;
|
||||
this.#eventsBeforeArm = [];
|
||||
this.#eventsBeforeArmBytes = 0;
|
||||
throw new BrowserObservationError("observe_web_session", "payload_limit", {
|
||||
detail: `The retained CDP event buffer exceeded its ${String(WEB_RUNTIME_LIMITS.retainedEventBytes)} byte limit before the observation clock was sampled.`,
|
||||
});
|
||||
}
|
||||
|
||||
#bufferBeforeArm(event: CdpTransportEvent): void {
|
||||
if (this.#preArmBufferExceeded) return;
|
||||
// The transport carries the original payload length, so budget admission
|
||||
// does not reconstruct or reserialize parsed protocol data.
|
||||
const bytes = event.wireBytes;
|
||||
if (
|
||||
bytes >
|
||||
WEB_RUNTIME_LIMITS.retainedEventBytes - this.#eventsBeforeArmBytes
|
||||
) {
|
||||
this.#preArmBufferExceeded = true;
|
||||
this.#eventsBeforeArm = [];
|
||||
this.#eventsBeforeArmBytes = 0;
|
||||
return;
|
||||
}
|
||||
this.#eventsBeforeArm.push(event);
|
||||
this.#eventsBeforeArmBytes += bytes;
|
||||
}
|
||||
|
||||
onEnd(listener: (reason: EndReason) => void): () => void {
|
||||
if (this.#endReason !== undefined) listener(this.#endReason);
|
||||
else this.#listeners.add(listener);
|
||||
@@ -197,11 +285,12 @@ class TimelineCapture {
|
||||
this.#listeners.clear();
|
||||
}
|
||||
|
||||
targetTerminated(): void {
|
||||
targetTerminated(params: UnknownRecord = {}): void {
|
||||
if (this.#endReason !== undefined) return;
|
||||
this.#add({
|
||||
type: "target_terminated",
|
||||
params: {},
|
||||
params,
|
||||
timestamp: null,
|
||||
rawUrl: null,
|
||||
requestId: null,
|
||||
detail: "target_terminated",
|
||||
@@ -219,6 +308,10 @@ class TimelineCapture {
|
||||
return this.#scopeExcluded;
|
||||
}
|
||||
|
||||
get hasUnknownEventTime(): boolean {
|
||||
return this.#hasUnknownEventTime;
|
||||
}
|
||||
|
||||
excludeTargetScope(
|
||||
reason: "invalid_protocol_value" | "out_of_target_scope",
|
||||
): void {
|
||||
@@ -228,58 +321,76 @@ class TimelineCapture {
|
||||
this.completeness.exclude("timeline", reason);
|
||||
}
|
||||
|
||||
#navigationRequested(event: CdpEvent, params: UnknownRecord): void {
|
||||
#navigationRequested(
|
||||
event: CdpEvent,
|
||||
params: UnknownRecord,
|
||||
contextOnly: boolean,
|
||||
): void {
|
||||
if (!isMainFrameNavigation(event, this.mainFrameId)) return;
|
||||
const reason = safeDetail(params.reason);
|
||||
this.#pendingReload = reason === "reload";
|
||||
const rawUrl = cdpStringValue(params.url);
|
||||
const destination =
|
||||
rawUrl === undefined ? undefined : scopedUrl(rawUrl, this.allowedOrigins);
|
||||
if (!contextOnly) this.#pendingReload = reason === "reload";
|
||||
this.#add({
|
||||
type: "navigation_requested",
|
||||
params,
|
||||
rawUrl: cdpStringValue(params.url) ?? null,
|
||||
timestamp: null,
|
||||
rawUrl: rawUrl ?? null,
|
||||
requestId: null,
|
||||
detail: reason,
|
||||
});
|
||||
if (destination !== undefined) this.#stopIfOutOfScope(destination);
|
||||
}
|
||||
|
||||
#navigationCommitted(event: CdpEvent, params: UnknownRecord): void {
|
||||
#navigationCommitted(
|
||||
event: CdpEvent,
|
||||
params: UnknownRecord,
|
||||
contextOnly: boolean,
|
||||
): void {
|
||||
const frame = recordValue(params.frame);
|
||||
if (!isMainFrameNavigation(event, this.mainFrameId)) return;
|
||||
const rawUrl = cdpStringValue(frame?.url);
|
||||
const destination = scopedUrl(rawUrl, this.allowedOrigins);
|
||||
this.#add({
|
||||
type: this.#pendingReload ? "same_origin_reload" : "navigation_committed",
|
||||
type:
|
||||
!contextOnly && this.#pendingReload
|
||||
? "same_origin_reload"
|
||||
: "navigation_committed",
|
||||
params: { ...params, frameId: frame?.id, loaderId: frame?.loaderId },
|
||||
timestamp: null,
|
||||
rawUrl: rawUrl ?? null,
|
||||
requestId: null,
|
||||
detail: safeDetail(frame?.transitionType),
|
||||
});
|
||||
if (this.#stopIfOutOfScope(destination)) return;
|
||||
if (contextOnly) return;
|
||||
this.#pendingReload = false;
|
||||
if (destination.scope === "approved") this.finalUrl = destination.url;
|
||||
else {
|
||||
this.excludeTargetScope("out_of_target_scope");
|
||||
this.end("target_left_scope");
|
||||
}
|
||||
this.finalUrl = destination.url;
|
||||
}
|
||||
|
||||
#sameDocument(event: CdpEvent, params: UnknownRecord): void {
|
||||
#sameDocument(
|
||||
event: CdpEvent,
|
||||
params: UnknownRecord,
|
||||
contextOnly: boolean,
|
||||
): void {
|
||||
if (!isMainFrameNavigation(event, this.mainFrameId)) return;
|
||||
const rawUrl = cdpStringValue(params.url);
|
||||
const destination = scopedUrl(rawUrl, this.allowedOrigins);
|
||||
this.#add({
|
||||
type: "same_document_navigation",
|
||||
params,
|
||||
timestamp: null,
|
||||
rawUrl: rawUrl ?? null,
|
||||
requestId: null,
|
||||
detail: null,
|
||||
});
|
||||
if (destination.scope === "approved") this.finalUrl = destination.url;
|
||||
else {
|
||||
this.excludeTargetScope("out_of_target_scope");
|
||||
this.end("target_left_scope");
|
||||
}
|
||||
if (this.#stopIfOutOfScope(destination)) return;
|
||||
if (contextOnly) return;
|
||||
this.finalUrl = destination.url;
|
||||
}
|
||||
|
||||
#redirect(params: UnknownRecord): void {
|
||||
#redirect(params: UnknownRecord, bufferedBeforeArm: boolean): void {
|
||||
const resourceType = cdpStringValue(params.type);
|
||||
if (resourceType !== undefined && resourceType !== "Document") return;
|
||||
if (
|
||||
@@ -287,36 +398,82 @@ class TimelineCapture {
|
||||
recordValue(params.redirectResponse) === undefined
|
||||
)
|
||||
return;
|
||||
const eventTime = this.#networkEventTime(params, bufferedBeforeArm);
|
||||
if (!eventTime.keep) return;
|
||||
const request = recordValue(params.request);
|
||||
const rawUrl = cdpStringValue(request?.url);
|
||||
const destination = scopedUrl(rawUrl, this.allowedOrigins);
|
||||
this.#add({
|
||||
type: "redirect",
|
||||
params,
|
||||
...(eventTime.timestamp === undefined
|
||||
? {}
|
||||
: { timestamp: eventTime.timestamp }),
|
||||
rawUrl: rawUrl ?? null,
|
||||
requestId: cdpStringValue(params.requestId) ?? null,
|
||||
detail: null,
|
||||
});
|
||||
if (destination.scope !== "approved") {
|
||||
this.excludeTargetScope("out_of_target_scope");
|
||||
this.end("target_left_scope");
|
||||
}
|
||||
if (this.#stopIfOutOfScope(destination)) return;
|
||||
}
|
||||
|
||||
#loadingFailed(params: UnknownRecord): void {
|
||||
#stopIfOutOfScope(destination: {
|
||||
readonly url: string | null;
|
||||
readonly scope: "approved" | "outside_policy" | "unsupported";
|
||||
}): boolean {
|
||||
if (destination.scope === "approved") return false;
|
||||
this.excludeTargetScope("out_of_target_scope");
|
||||
this.end("target_left_scope");
|
||||
return true;
|
||||
}
|
||||
|
||||
#loadingFailed(params: UnknownRecord, bufferedBeforeArm: boolean): void {
|
||||
const frame = cdpStringValue(params.frameId);
|
||||
if (frame !== undefined && frame !== this.mainFrameId) return;
|
||||
const eventTime = this.#networkEventTime(params, bufferedBeforeArm);
|
||||
if (!eventTime.keep) return;
|
||||
this.#add({
|
||||
type: "load_failed",
|
||||
params,
|
||||
...(eventTime.timestamp === undefined
|
||||
? {}
|
||||
: { timestamp: eventTime.timestamp }),
|
||||
rawUrl: null,
|
||||
requestId: cdpStringValue(params.requestId) ?? null,
|
||||
detail: safeNetworkError(params.errorText),
|
||||
});
|
||||
}
|
||||
|
||||
#networkEventTime(
|
||||
params: UnknownRecord,
|
||||
bufferedBeforeArm: boolean,
|
||||
): {
|
||||
readonly keep: boolean;
|
||||
readonly timestamp?: number | null;
|
||||
} {
|
||||
if (!bufferedBeforeArm) return { keep: true };
|
||||
const timestamp = numberValue(params.timestamp);
|
||||
if (timestamp === undefined || timestamp < 0) {
|
||||
this.completeness.exclude("timeline", "invalid_protocol_value");
|
||||
this.#hasUnknownEventTime = true;
|
||||
return { keep: true, timestamp: null };
|
||||
}
|
||||
if (this.#armTimestamp === undefined || timestamp <= this.#armTimestamp)
|
||||
return { keep: false, timestamp };
|
||||
return { keep: true, timestamp };
|
||||
}
|
||||
|
||||
#lifecycle(params: UnknownRecord): void {
|
||||
if (cdpStringValue(params.frameId) !== this.mainFrameId) return;
|
||||
const timestamp = numberValue(params.timestamp);
|
||||
if (
|
||||
timestamp === undefined ||
|
||||
timestamp < 0 ||
|
||||
this.#armTimestamp === undefined
|
||||
) {
|
||||
this.completeness.exclude("timeline", "invalid_protocol_value");
|
||||
return;
|
||||
}
|
||||
if (timestamp <= this.#armTimestamp) return;
|
||||
this.#add({
|
||||
type: "lifecycle",
|
||||
params,
|
||||
@@ -336,11 +493,27 @@ class TimelineCapture {
|
||||
} = options;
|
||||
const destination =
|
||||
rawUrl == null ? null : scopedUrl(rawUrl, this.allowedOrigins);
|
||||
const observedTimestamp = numberValue(params.timestamp);
|
||||
const timestamp =
|
||||
options.timestamp === undefined ? observedTimestamp : options.timestamp;
|
||||
if (
|
||||
timestamp === undefined ||
|
||||
timestamp === null ||
|
||||
(timestamp !== null && timestamp < 0)
|
||||
) {
|
||||
this.#hasUnknownEventTime = true;
|
||||
}
|
||||
if (timestamp !== null && timestamp !== undefined && timestamp < 0) {
|
||||
this.completeness.exclude("timeline", "invalid_protocol_value");
|
||||
}
|
||||
this.#sequence += 1;
|
||||
this.timeline.push({
|
||||
sequence: this.#sequence,
|
||||
type,
|
||||
timestamp: Math.max(0, numberValue(params.timestamp) ?? 0),
|
||||
timestamp:
|
||||
timestamp === undefined || (timestamp !== null && timestamp < 0)
|
||||
? null
|
||||
: timestamp,
|
||||
frame_id: boundedId(params.frameId),
|
||||
loader_id: boundedId(params.loaderId),
|
||||
request_id: requestId ?? null,
|
||||
@@ -351,6 +524,19 @@ class TimelineCapture {
|
||||
}
|
||||
}
|
||||
|
||||
const performanceTimestamp = (value: unknown): number => {
|
||||
const metrics = recordsValue(recordValue(value)?.metrics).filter(
|
||||
({ name }) => name === "Timestamp",
|
||||
);
|
||||
const timestamp = numberValue(metrics[0]?.value);
|
||||
if (metrics.length !== 1 || timestamp === undefined || timestamp < 0)
|
||||
throw new BrowserObservationError("observe_web_session", "protocol_error", {
|
||||
detail:
|
||||
"Performance.getMetrics returned no valid monotonic Timestamp metric.",
|
||||
});
|
||||
return timestamp;
|
||||
};
|
||||
|
||||
const waitForWindow = async (
|
||||
durationMs: number,
|
||||
capture: TimelineCapture,
|
||||
@@ -423,6 +609,19 @@ const frameId = (result: unknown): string | undefined =>
|
||||
recordValue(recordValue(recordValue(result)?.frameTree)?.frame)?.id,
|
||||
);
|
||||
|
||||
const hasNoComparableProducerTimestamp = (method: string): boolean =>
|
||||
method === "Page.frameRequestedNavigation" ||
|
||||
method === "Page.frameNavigated" ||
|
||||
method === "Page.navigatedWithinDocument" ||
|
||||
method === "Inspector.targetCrashed" ||
|
||||
method === "Target.detachedFromTarget";
|
||||
|
||||
const isTimelineEvent = (method: string): boolean =>
|
||||
hasNoComparableProducerTimestamp(method) ||
|
||||
method === "Network.requestWillBeSent" ||
|
||||
method === "Network.loadingFailed" ||
|
||||
method === "Page.lifecycleEvent";
|
||||
|
||||
const scopedUrl = (
|
||||
value: string | undefined,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
|
||||
@@ -30,7 +30,13 @@ const timelineEventSchema = z.object({
|
||||
"lifecycle",
|
||||
"target_terminated",
|
||||
]),
|
||||
timestamp: z.number().min(0),
|
||||
timestamp: z
|
||||
.number()
|
||||
.min(0)
|
||||
.nullable()
|
||||
.describe(
|
||||
"CDP monotonic seconds when supplied; null when the event has no valid comparable producer timestamp.",
|
||||
),
|
||||
frame_id: z.string().nullable(),
|
||||
loader_id: z.string().nullable(),
|
||||
request_id: z.string().nullable(),
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { expect, onTestFinished } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { webObservationSessionSchema } from "../../src/domain/browserSession.js";
|
||||
import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
|
||||
import { startFakeCdpBrowser } from "../fixtures/fakeCdpBrowser.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
"filters replayed lifecycle milestones before the observation window through %s",
|
||||
async (surface, { cli }) => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
eventsBeforeReply: [
|
||||
"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: (command, origin) => {
|
||||
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",
|
||||
},
|
||||
},
|
||||
];
|
||||
if (command.method === "Page.setLifecycleEventsEnabled")
|
||||
return [
|
||||
{
|
||||
method: "Page.lifecycleEvent",
|
||||
sessionId: command.sessionId,
|
||||
params: {
|
||||
frameId: "frame-main",
|
||||
loaderId: "loader-before-window-replayed-after-arm",
|
||||
name: "load",
|
||||
timestamp: 19,
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "Page.lifecycleEvent",
|
||||
sessionId: command.sessionId,
|
||||
params: {
|
||||
frameId: "frame-main",
|
||||
loaderId: "loader-current-window",
|
||||
name: "load",
|
||||
timestamp: 21,
|
||||
},
|
||||
},
|
||||
];
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
onTestFinished(() => browser.close());
|
||||
|
||||
let evidence: unknown;
|
||||
if (surface === "CLI") {
|
||||
const response = await cli.run({
|
||||
arguments: [
|
||||
"observe-web-session",
|
||||
browser.endpoint,
|
||||
"allowed-page",
|
||||
"--allowed-origins",
|
||||
browser.allowedOrigin,
|
||||
"--observation-ms",
|
||||
"5",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(response.exitCode, response.stderr).toBe(0);
|
||||
evidence = response.json;
|
||||
} else {
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const response = await call("observe_web_session", {
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 5,
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
evidence = response.structuredContent;
|
||||
}
|
||||
|
||||
const contract = toolContract("observe_web_session");
|
||||
const sourceEvidence = contract.outputSchema.parse(evidence);
|
||||
const normalized = z
|
||||
.object({ normalized_result: webObservationSessionSchema })
|
||||
.parse(sourceEvidence).normalized_result;
|
||||
expect(normalized.timeline).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "lifecycle",
|
||||
loader_id: "loader-current-window",
|
||||
timestamp: 21,
|
||||
}),
|
||||
);
|
||||
expect(normalized.timeline).not.toContainEqual(
|
||||
expect.objectContaining({ loader_id: "loader-before-window" }),
|
||||
);
|
||||
expect(normalized.timeline).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
loader_id: "loader-before-window-replayed-after-arm",
|
||||
}),
|
||||
);
|
||||
expect(normalized.timeline).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "same_document_navigation",
|
||||
url: `${browser.allowedOrigin}#during-clock-response`,
|
||||
timestamp: null,
|
||||
}),
|
||||
);
|
||||
expect(normalized.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.",
|
||||
);
|
||||
expect(browser.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
method: "Performance.enable",
|
||||
params: { timeDomain: "timeTicks" },
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -14,15 +14,7 @@ import {
|
||||
startFakeCdpBrowser,
|
||||
type FakeCdpBrowser,
|
||||
} from "../../fixtures/fakeCdpBrowser.js";
|
||||
import { trackBrowser } from "./cdpBrowserProvider.support.js";
|
||||
|
||||
const waitFor = async (ready: () => boolean, description: string) => {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (!ready()) {
|
||||
if (Date.now() > deadline) throw new Error(description);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
};
|
||||
import { trackBrowser, waitFor } from "./cdpBrowserProvider.support.js";
|
||||
|
||||
describe("CdpBrowserProvider: cancellation during cleanup", () => {
|
||||
it.each([
|
||||
@@ -378,7 +370,11 @@ describe("CdpBrowserProvider: session termination and scope changes", () => {
|
||||
expect(result.value.timeline.at(-1)).toMatchObject({
|
||||
type: "target_terminated",
|
||||
detail: "target_terminated",
|
||||
timestamp: null,
|
||||
});
|
||||
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("ends a direct page session when its transport disconnects", async () => {
|
||||
|
||||
@@ -11,3 +11,11 @@ afterEach(async () => {
|
||||
export const trackBrowser = (browser: FakeCdpBrowser): void => {
|
||||
browsers.push(browser);
|
||||
};
|
||||
|
||||
export const waitFor = async (ready: () => boolean, description: string) => {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (!ready()) {
|
||||
if (Date.now() > deadline) throw new Error(description);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
};
|
||||
@@ -4,7 +4,10 @@ import { createServer as createNetServer, type Socket } from "node:net";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CdpConnection } from "../../../src/browser/CdpConnection.js";
|
||||
import {
|
||||
CdpConnection,
|
||||
type CdpTransportEvent,
|
||||
} from "../../../src/browser/CdpConnection.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
@@ -125,6 +128,42 @@ describe("CDP connection response correlation", () => {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the original event payload byte count before parsing", async () => {
|
||||
const browser = await startFakeCdpBrowser();
|
||||
browsers.push(browser);
|
||||
const connection = await CdpConnection.connect(
|
||||
browser.browserWebSocketUrl,
|
||||
"inspect_web_page",
|
||||
);
|
||||
try {
|
||||
const eventMessage = String.raw` { "method" : "Fixture.event", "params" : {"text":"escaped quote: \" slash: \\ unicode: \u96ea"}, "sessionId" : "session-1", "extra" : "counted from source bytes" } `;
|
||||
let deliveredEvents = 0;
|
||||
const eventPromise = new Promise<CdpTransportEvent>((resolve) => {
|
||||
connection.onEvent((event) => {
|
||||
deliveredEvents += 1;
|
||||
if (event.method === "Fixture.event") resolve(event);
|
||||
});
|
||||
});
|
||||
browser.emitRawMessage(eventMessage);
|
||||
const event = await eventPromise;
|
||||
|
||||
expect(event.wireBytes).toBe(Buffer.byteLength(eventMessage, "utf8"));
|
||||
expect(event.params).toEqual({
|
||||
text: 'escaped quote: " slash: \\ unicode: 雪',
|
||||
});
|
||||
expect(deliveredEvents).toBe(1);
|
||||
|
||||
const protocolFailure = new Promise((resolve) =>
|
||||
connection.onProtocolFailure(resolve),
|
||||
);
|
||||
browser.emitRawMessage("{not-json");
|
||||
await protocolFailure;
|
||||
expect(deliveredEvents).toBe(1);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("CDP command cancellation causes", () => {
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
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",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
||||
import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js";
|
||||
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
|
||||
import { trackBrowser } from "./cdpBrowserProvider.support.js";
|
||||
|
||||
it("fails closed on an untimestamped cross-origin main-frame commit during clock sampling", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
eventsBeforeReply: ["Performance.getMetrics"],
|
||||
commandResult: (command) =>
|
||||
command.method === "Performance.getMetrics"
|
||||
? { metrics: [{ name: "Timestamp", value: 20 }] }
|
||||
: undefined,
|
||||
commandEvents: (command) => {
|
||||
if (command.method !== "Performance.getMetrics") return undefined;
|
||||
return [
|
||||
{
|
||||
method: "Page.frameNavigated",
|
||||
sessionId: command.sessionId,
|
||||
params: {
|
||||
frame: {
|
||||
id: "frame-main",
|
||||
loaderId: "outside-loader",
|
||||
url: "https://outside.invalid/path",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "Network.requestWillBeSent",
|
||||
sessionId: command.sessionId,
|
||||
params: {
|
||||
requestId: "later-in-window-request",
|
||||
timestamp: 21,
|
||||
type: "Document",
|
||||
frameId: "frame-main",
|
||||
redirectResponse: { url: `${browser.allowedOrigin}/prior` },
|
||||
request: { url: `${browser.allowedOrigin}/later` },
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
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(result.value.window.end_reason).toBe("target_left_scope");
|
||||
expect(result.value.target.final_url).toBeNull();
|
||||
expect(result.value.timeline).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "navigation_committed",
|
||||
loader_id: "outside-loader",
|
||||
timestamp: null,
|
||||
destination_scope: "outside_policy",
|
||||
url: null,
|
||||
}),
|
||||
);
|
||||
expect(result.value.timeline).not.toContainEqual(
|
||||
expect.objectContaining({ request_id: "later-in-window-request" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed on an untimestamped out-of-scope main-frame request during clock sampling", 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: "Page.frameRequestedNavigation",
|
||||
sessionId: command.sessionId,
|
||||
params: {
|
||||
frameId: "frame-main",
|
||||
url: "https://outside.invalid/path",
|
||||
reason: "scriptInitiated",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "Network.requestWillBeSent",
|
||||
sessionId: command.sessionId,
|
||||
params: {
|
||||
requestId: "later-in-window-request",
|
||||
timestamp: 21,
|
||||
type: "Document",
|
||||
frameId: "frame-main",
|
||||
redirectResponse: { url: `${origin}/prior` },
|
||||
request: { url: `${origin}/later` },
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
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(result.value.window.end_reason).toBe("target_left_scope");
|
||||
expect(result.value.timeline).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "navigation_requested",
|
||||
timestamp: null,
|
||||
destination_scope: "outside_policy",
|
||||
url: null,
|
||||
}),
|
||||
);
|
||||
expect(result.value.timeline).not.toContainEqual(
|
||||
expect.objectContaining({ request_id: "later-in-window-request" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed on an untimestamped out-of-scope redirect during clock sampling", 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.requestWillBeSent",
|
||||
sessionId: command.sessionId,
|
||||
params: {
|
||||
requestId: "unknown-time-outside-redirect",
|
||||
type: "Document",
|
||||
frameId: "frame-main",
|
||||
redirectResponse: { url: `${origin}/prior` },
|
||||
request: { url: "https://outside.invalid/redirected" },
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "Network.requestWillBeSent",
|
||||
sessionId: command.sessionId,
|
||||
params: {
|
||||
requestId: "later-in-window-request",
|
||||
timestamp: 21,
|
||||
type: "Document",
|
||||
frameId: "frame-main",
|
||||
redirectResponse: { url: `${origin}/prior` },
|
||||
request: { url: `${origin}/later` },
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
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(result.value.window.end_reason).toBe("target_left_scope");
|
||||
expect(result.value.target.final_url).toBeNull();
|
||||
expect(result.value.timeline).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "redirect",
|
||||
request_id: "unknown-time-outside-redirect",
|
||||
timestamp: null,
|
||||
destination_scope: "outside_policy",
|
||||
url: null,
|
||||
}),
|
||||
);
|
||||
expect(result.value.timeline).not.toContainEqual(
|
||||
expect.objectContaining({ request_id: "later-in-window-request" }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"Inspector.targetCrashed",
|
||||
{
|
||||
method: "Inspector.targetCrashed",
|
||||
sessionId: "session-1",
|
||||
params: {},
|
||||
},
|
||||
],
|
||||
[
|
||||
"Target.detachedFromTarget",
|
||||
{
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: "session-1", targetId: "allowed-page" },
|
||||
},
|
||||
],
|
||||
] as const)(
|
||||
"ends on buffered untimestamped %s before collecting later events",
|
||||
async (_method, terminalEvent) => {
|
||||
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 [
|
||||
terminalEvent,
|
||||
{
|
||||
method: "Network.requestWillBeSent",
|
||||
sessionId: command.sessionId,
|
||||
params: {
|
||||
requestId: "after-terminal-event",
|
||||
timestamp: 21,
|
||||
type: "Document",
|
||||
frameId: "frame-main",
|
||||
redirectResponse: { url: `${origin}/prior` },
|
||||
request: { url: `${origin}/later` },
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
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(result.value.window.end_reason).toBe("target_terminated");
|
||||
expect(result.value.timeline).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "target_terminated",
|
||||
timestamp: null,
|
||||
}),
|
||||
);
|
||||
expect(result.value.timeline).not.toContainEqual(
|
||||
expect.objectContaining({ request_id: "after-terminal-event" }),
|
||||
);
|
||||
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("represents absent timeline timestamps as unknown and preserves an observed zero", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
eventsBeforeReply: ["Page.setLifecycleEventsEnabled"],
|
||||
commandEvents: (command, origin) => {
|
||||
if (command.method !== "Page.setLifecycleEventsEnabled") return undefined;
|
||||
return [
|
||||
{
|
||||
method: "Page.navigatedWithinDocument",
|
||||
sessionId: command.sessionId,
|
||||
params: {
|
||||
frameId: "frame-main",
|
||||
url: `${origin}/without-clock-value`,
|
||||
navigationType: "historyApi",
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "Network.loadingFailed",
|
||||
sessionId: command.sessionId,
|
||||
params: {
|
||||
requestId: "actual-zero-clock-value",
|
||||
frameId: "frame-main",
|
||||
errorText: "net::ERR_CONNECTION_REFUSED",
|
||||
timestamp: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
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(result.value.timeline).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "same_document_navigation",
|
||||
url: `${browser.allowedOrigin}/without-clock-value`,
|
||||
timestamp: null,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "load_failed",
|
||||
request_id: "actual-zero-clock-value",
|
||||
timestamp: 0,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
+4
@@ -49,6 +49,8 @@ export const resultFor = (
|
||||
case "Page.enable":
|
||||
case "Page.disable":
|
||||
case "Page.setLifecycleEventsEnabled":
|
||||
case "Performance.enable":
|
||||
case "Performance.disable":
|
||||
case "Network.enable":
|
||||
case "Network.disable":
|
||||
case "Debugger.disable":
|
||||
@@ -58,6 +60,8 @@ export const resultFor = (
|
||||
case "DOM.enable":
|
||||
case "DOM.disable":
|
||||
return {};
|
||||
case "Performance.getMetrics":
|
||||
return { metrics: [{ name: "Timestamp", value: 0 }] };
|
||||
case "Debugger.enable":
|
||||
return { debuggerId: "fake-debugger" };
|
||||
case "Target.detachFromTarget":
|
||||
|
||||
+3
-12
@@ -1,4 +1,5 @@
|
||||
import type { JsonValue } from "../../src/domain/jsonValue.js";
|
||||
import type { CdpEvent } from "../../src/browser/CdpConnection.js";
|
||||
export interface FakeCdpCommand {
|
||||
readonly id: number;
|
||||
readonly method: string;
|
||||
@@ -17,11 +18,7 @@ export interface FakeCdpBrowser {
|
||||
openedConnectionCount(): number;
|
||||
/** Deliver actual malformed wire data without bypassing the production transport parser. */
|
||||
emitRawMessage(message: string): void;
|
||||
emitEvent(event: {
|
||||
readonly method: string;
|
||||
readonly params: unknown;
|
||||
readonly sessionId?: string;
|
||||
}): void;
|
||||
emitEvent(event: CdpEvent): void;
|
||||
readonly httpRequests: readonly {
|
||||
readonly url: string;
|
||||
readonly authorization: string | undefined;
|
||||
@@ -48,13 +45,7 @@ export interface FakeOptions {
|
||||
readonly commandEvents?: (
|
||||
command: FakeCdpCommand,
|
||||
origin: string,
|
||||
) =>
|
||||
| readonly {
|
||||
readonly method: string;
|
||||
readonly params: unknown;
|
||||
readonly sessionId?: string;
|
||||
}[]
|
||||
| undefined;
|
||||
) => readonly CdpEvent[] | undefined;
|
||||
readonly malformedDiscovery?: boolean;
|
||||
readonly oversizedDiscovery?: boolean;
|
||||
readonly invalidBrowserWebSocket?: boolean;
|
||||
|
||||
Reference in new issue
Block a user