fix(browser): retain truthful cleanup facts and document request ownership

This commit is contained in:
morluto
2026-10-24 03:18:04 +08:00
parent 77ed32fabc
commit f01f56156e
16 changed files with 540 additions and 57 deletions
+6 -1
View File
@@ -387,7 +387,12 @@ 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.
Cancellation and clock-request failures retain their own outcomes. Session
`load_failed` rows require a previously observed main-frame `Document` request;
subresources, child-frame requests and failures without admitted request ownership
are excluded. Redirects reuse their request identity, and completed/failed requests
release it. Active request identity metadata uses the existing 8 MiB retained-event
allowance; exhaustion fails the observation explicitly instead of guessing scope.
## Data minimization
+1 -1
View File
@@ -34,7 +34,7 @@ Same-document navigation such as `history.pushState()` remains valid when the fr
Target detachment ends the window with `session_detached`, even when the browser's root connection stays open. An `Inspector.targetCrashed` event ends it with `target_terminated`. A lost transport records `transport_disconnected` in browser observation; execution capture returns the typed transport failure. These outcomes retain the selected target and the observed lifecycle fact.
When an ended session cannot acknowledge cleanup, the operation returns a failure with the failed commands and original typed producer reason. A coverage-stop failure also retains the observed window's end reason in its diagnostics; cleanup remains unconfirmed until the owning session can resolve it.
When an ended session cannot acknowledge cleanup, the operation returns a failure with the failed commands and original typed producer reason. Completed capture facts retained with that failure report cleanup as `unconfirmed`; only the session cleanup owner can mark a successful result `confirmed`. Such retained Evidence preserves coverage, sources, target and listener facts without becoming a successful operation. A caller cancellation remains primary when an incomplete retained snapshot cannot be promoted to completed Evidence. A coverage-stop failure also retains the observed window's end reason in its diagnostics; cleanup remains unconfirmed until the owning session can resolve it.
Child frames, workers, service workers, WebAssembly, chronological execution order and framework-specific causal interpretation are outside this operation. Script ownership comes from frame/context metadata, never URL equality. Repeated URLs and synthetic names therefore retain separate identities.
+40
View File
@@ -282,3 +282,43 @@ it("drops a non-JSON execution partial and keeps timeout primary", async () => {
);
expect(projectAnalysisError(error).category).toBe("timeout");
});
it.each(["observe", "inspect"] as const)(
"keeps %s cancellation primary when completed-shaped partial cannot be promoted within origin scope",
async (operation) => {
const partial =
operation === "observe" ? webExecutionFixture() : webListenersFixture();
const reason = { stop: "retained fact projection" };
const failure = new AnalysisCancelledError(
operation === "observe"
? "observe_web_execution"
: "inspect_web_event_listeners",
{ cause: reason, partialObservation: partial },
);
const service = new WebRuntimeService({
identity: runtimeIdentity,
observeExecution: async () => err(failure),
inspectEventListeners: async () => err(failure),
});
const input = {
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "selected-page",
allowed_origins: ["https://selected.example"],
};
const result =
operation === "observe"
? await service.observe({ ...input, observation_ms: 5 })
: await service.inspect({ ...input, selector: "#selected" });
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
cause: reason,
partialObservation: partial,
},
});
if (result.ok) throw new Error("Expected original cancellation");
expect(result.error).toBe(failure);
expect(result.error.partialObservation).not.toHaveProperty("evidence_id");
},
);
+24 -25
View File
@@ -13,10 +13,12 @@ import { err, ok, type Result } from "../domain/result.js";
import {
observeWebExecutionInputSchema,
webExecutionSchema,
webExecutionObservationSchema,
} from "../domain/webExecution.js";
import {
inspectWebEventListenersInputSchema,
webEventListenersSchema,
webEventListenersObservationSchema,
} from "../domain/webEventListeners.js";
import {
completeProviderEvidence,
@@ -49,7 +51,7 @@ export class WebRuntimeService {
(partial) => {
if (!("instrumentation" in partial) || !("window" in partial))
return undefined;
const verified = webExecutionSchema.safeParse(partial);
const verified = webExecutionObservationSchema.safeParse(partial);
if (!verified.success) return undefined;
return this.evidence(operation, parsed.value, verified.data);
},
@@ -57,16 +59,6 @@ export class WebRuntimeService {
const verified = webExecutionSchema.safeParse(result.value);
if (!verified.success)
return err(new AnalysisOutputError(operation, verified.error.message));
if (
verified.data.target.target_id !== parsed.value.target_id ||
verified.data.window.requested_ms !== parsed.value.observation_ms
)
return err(
new AnalysisOutputError(
operation,
"Provider changed the selected target or requested window.",
),
);
return this.evidence(operation, parsed.value, verified.data, options);
}
@@ -95,7 +87,8 @@ export class WebRuntimeService {
(partial) => {
if (!("selected_node" in partial) || !("listeners" in partial))
return undefined;
const verified = webEventListenersSchema.safeParse(partial);
const verified =
webEventListenersObservationSchema.safeParse(partial);
if (!verified.success) return undefined;
return this.evidence(operation, parsed.value, verified.data);
},
@@ -103,16 +96,6 @@ export class WebRuntimeService {
const verified = webEventListenersSchema.safeParse(result.value);
if (!verified.success)
return err(new AnalysisOutputError(operation, verified.error.message));
if (
verified.data.target.target_id !== parsed.value.target_id ||
verified.data.selected_node.selector !== parsed.value.selector
)
return err(
new AnalysisOutputError(
operation,
"Provider changed the selected target or node selector.",
),
);
return this.evidence(operation, parsed.value, verified.data, options);
}
@@ -133,7 +116,6 @@ export class WebRuntimeService {
error.partialObservation = evidence.value;
return err(error);
}
if (error instanceof AnalysisCancelledError) return evidence;
}
}
return err(keepIncompleteObservation(error, operation, nonJsonReason));
@@ -145,10 +127,27 @@ export class WebRuntimeService {
| z.infer<typeof observeWebExecutionInputSchema>
| z.infer<typeof inspectWebEventListenersInputSchema>,
result:
| z.infer<typeof webExecutionSchema>
| z.infer<typeof webEventListenersSchema>,
| z.infer<typeof webExecutionObservationSchema>
| z.infer<typeof webEventListenersObservationSchema>,
options?: ExecutionOptions,
): Result<Evidence, AnalysisError> {
if (
result.target.target_id !== input.target_id ||
("observation_ms" in input &&
(!("window" in result) ||
result.window.requested_ms !== input.observation_ms)) ||
("selector" in input &&
(!("selected_node" in result) ||
result.selected_node.selector !== input.selector))
)
return err(
new AnalysisOutputError(
operation,
"observation_ms" in input
? "Provider changed the selected target or requested window."
: "Provider changed the selected target or node selector.",
),
);
if (
input.allowed_origins.length > 0 &&
!input.allowed_origins.includes(result.target.origin)
+82 -4
View File
@@ -113,6 +113,7 @@ export const observeCdpSession = async (
);
capture.assertPreArmBufferWithinLimit();
capture.arm(armTimestamp);
capture.assertRequestOwnershipWithinLimit();
await context.connection.send(
"Page.setLifecycleEventsEnabled",
{ enabled: true },
@@ -132,8 +133,10 @@ export const observeCdpSession = async (
context.connection,
context.signal,
);
capture.assertRequestOwnershipWithinLimit();
if (endReason === "window_elapsed")
endReason = await captureFinalUrl(context, capture, allowedOrigins);
capture.assertRequestOwnershipWithinLimit();
await context.progress?.report({
phase: "browser_observation",
completed: 2,
@@ -188,6 +191,12 @@ class TimelineCapture {
#eventsBeforeArmBytes = 0;
#preArmBufferExceeded = false;
#hasUnknownEventTime = false;
readonly #documentRequests = new Map<
string,
{ frameId: string; loaderId: string | undefined }
>();
#documentRequestBytes = 0;
#requestOwnershipFailure: BrowserObservationError | undefined;
readonly #listeners = new Set<(reason: EndReason) => void>();
constructor(
@@ -235,11 +244,15 @@ class TimelineCapture {
this.#sameDocument(event, params, contextOnly);
break;
case "Network.requestWillBeSent":
this.#requestStarted(params);
this.#redirect(params, bufferedBeforeArm);
break;
case "Network.loadingFailed":
this.#loadingFailed(params, bufferedBeforeArm);
break;
case "Network.loadingFinished":
this.#forgetRequest(cdpStringValue(params.requestId));
break;
case "Page.lifecycleEvent":
this.#lifecycle(params);
break;
@@ -508,19 +521,83 @@ class TimelineCapture {
return true;
}
/** Request ingress owns frame/type attribution; loadingFailed has no frameId. */
#requestStarted(params: UnknownRecord): void {
const id = cdpStringValue(params.requestId);
const frameId = cdpStringValue(params.frameId);
if (id === undefined) {
if (
frameId === this.mainFrameId &&
cdpStringValue(params.type) === "Document"
)
this.completeness.exclude("timeline", "invalid_protocol_value");
return;
}
this.#forgetRequest(id);
if (
frameId !== this.mainFrameId ||
cdpStringValue(params.type) !== "Document"
)
return;
const loaderId = cdpStringValue(params.loaderId);
const bytes =
Buffer.byteLength(id) +
Buffer.byteLength(frameId) +
Buffer.byteLength(loaderId ?? "");
if (
bytes >
WEB_RUNTIME_LIMITS.retainedEventBytes - this.#documentRequestBytes
) {
this.#requestOwnershipFailure = new BrowserObservationError(
"observe_web_session",
"payload_limit",
{
detail: `Active document request identities exceeded the ${WEB_RUNTIME_LIMITS.retainedEventBytes}-byte retained event allowance.`,
},
);
this.end("window_elapsed");
return;
}
this.#documentRequests.set(id, { frameId, loaderId });
this.#documentRequestBytes += bytes;
}
#forgetRequest(id: string | undefined) {
if (id === undefined) return undefined;
const request = this.#documentRequests.get(id);
if (request !== undefined) {
this.#documentRequests.delete(id);
this.#documentRequestBytes -=
Buffer.byteLength(id) +
Buffer.byteLength(request.frameId) +
Buffer.byteLength(request.loaderId ?? "");
}
return request;
}
assertRequestOwnershipWithinLimit(): void {
if (this.#requestOwnershipFailure !== undefined)
throw this.#requestOwnershipFailure;
}
#loadingFailed(params: UnknownRecord, bufferedBeforeArm: boolean): void {
const frame = cdpStringValue(params.frameId);
if (frame !== undefined && frame !== this.mainFrameId) return;
const requestId = cdpStringValue(params.requestId);
const request = this.#forgetRequest(requestId);
if (request === undefined) return;
const eventTime = this.#networkEventTime(params, bufferedBeforeArm);
if (!eventTime.keep) return;
this.#add({
type: "load_failed",
params,
params: {
...params,
frameId: request.frameId,
loaderId: request.loaderId,
},
...(eventTime.timestamp === undefined
? {}
: { timestamp: eventTime.timestamp }),
rawUrl: null,
requestId: cdpStringValue(params.requestId) ?? null,
requestId: requestId ?? null,
detail: safeNetworkError(params.errorText),
});
}
@@ -706,6 +783,7 @@ const isTimelineEvent = (method: string): boolean =>
hasNoComparableProducerTimestamp(method) ||
method === "Network.requestWillBeSent" ||
method === "Network.loadingFailed" ||
method === "Network.loadingFinished" ||
method === "Page.lifecycleEvent";
const scopedUrl = (
+3 -3
View File
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { z } from "zod";
import type {
InspectWebEventListenersInput,
WebEventListeners,
WebEventListenersObservation,
} from "../../domain/webEventListeners.js";
import type { CdpRuntimeSources } from "./CdpRuntimeSources.js";
import { runtimeListenersSchema } from "./CdpRuntimeProtocol.js";
@@ -13,7 +13,7 @@ export const captureWebEventListeners = async (
input: InspectWebEventListenersInput,
sources: CdpRuntimeSources,
group: string,
): Promise<WebEventListeners> => {
): Promise<WebEventListenersObservation> => {
const session = sources.session;
session.beginCompletion();
const unsubscribe = session.transport.connection.onEvent((event) =>
@@ -92,7 +92,7 @@ export const captureWebEventListeners = async (
execution: "unknown",
})),
sources: retained,
cleanup: "confirmed",
cleanup: "unconfirmed",
limitations: [
...(session.discovery.limitations ?? []),
"Inspects listeners registered directly on the first native CSS match in the selected main document. Delegated ancestor listeners, descendants, closed shadow roots and framework registries are not enumerated.",
+3 -2
View File
@@ -8,6 +8,7 @@ import {
import type {
ObserveWebExecutionInput,
WebExecution,
WebExecutionObservation,
} from "../../domain/webExecution.js";
import { CdpExecutionCollection } from "./CdpExecutionCollection.js";
import { preciseCoverageSchema } from "./CdpRuntimeProtocol.js";
@@ -18,7 +19,7 @@ import type { CdpRuntimeSources } from "./CdpRuntimeSources.js";
export const captureWebExecution = async (
input: ObserveWebExecutionInput,
sources: CdpRuntimeSources,
): Promise<WebExecution> => {
): Promise<WebExecutionObservation> => {
const session = sources.session;
const collection = new CdpExecutionCollection(sources);
const { window, requests } = collection;
@@ -150,7 +151,7 @@ export const captureWebExecution = async (
disables_optimized_execution: true,
takes_one_resetting_sample: sample !== undefined,
executes_selected_code: false,
cleanup: "confirmed",
cleanup: "unconfirmed",
page_ownership: "external",
},
limitations: [
+19 -9
View File
@@ -14,12 +14,12 @@ import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { err, ok, type Result } from "../../domain/result.js";
import {
webExecutionSchema,
webExecutionObservationSchema,
type ObserveWebExecutionInput,
type WebExecution,
} from "../../domain/webExecution.js";
import {
webEventListenersSchema,
webEventListenersObservationSchema,
type InspectWebEventListenersInput,
type WebEventListeners,
} from "../../domain/webEventListeners.js";
@@ -55,8 +55,15 @@ export class CdpWebRuntimeProvider implements WebRuntimePort {
input,
"observe_web_execution",
options,
webExecutionSchema,
webExecutionObservationSchema,
(sources) => captureWebExecution(input, sources),
(observation) => ({
...observation,
instrumentation: {
...observation.instrumentation,
cleanup: "confirmed" as const,
},
}),
);
}
/** Inspect one selected node's registered callback locations. */
@@ -69,20 +76,22 @@ export class CdpWebRuntimeProvider implements WebRuntimePort {
input,
"inspect_web_event_listeners",
options,
webEventListenersSchema,
webEventListenersObservationSchema,
(sources) => captureWebEventListeners(input, sources, group),
(observation) => ({ ...observation, cleanup: "confirmed" as const }),
group,
);
}
private async run<T extends AnalysisPartialObservation>(
private async run<T extends AnalysisPartialObservation, Completed extends T>(
input: ObserveWebExecutionInput | InspectWebEventListenersInput,
operation: BrowserObservationOperation,
options: ExecutionOptions,
schema: z.ZodType<T>,
capture: (sources: CdpRuntimeSources) => Promise<T>,
confirmCleanup: (observation: T) => Completed,
group?: string,
): Promise<Result<T, AnalysisError>> {
): Promise<Result<Completed, AnalysisError>> {
let session: CdpRuntimeSession | undefined;
let result: Result<T, AnalysisError>;
try {
@@ -125,13 +134,14 @@ export class CdpWebRuntimeProvider implements WebRuntimePort {
: analysisErrorWithCleanupFailure(result.error, failure, operation),
);
}
if (result.ok && options.signal?.aborted === true)
const completed = result.ok ? ok(confirmCleanup(result.value)) : result;
if (completed.ok && options.signal?.aborted === true)
return err(
new AnalysisCancelledError(operation, {
cause: options.signal.reason,
partialObservation: result.value,
partialObservation: completed.value,
}),
);
return result;
return completed;
}
}
+4 -4
View File
@@ -246,8 +246,8 @@ import type {
} from "./native/recordedCrash.js";
import type { WebPageInspection } from "./browserObservationSchemas.js";
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
import type { WebExecution } from "./webExecution.js";
import type { WebEventListeners } from "./webEventListeners.js";
import type { WebExecutionObservation } from "./webExecution.js";
import type { WebEventListenersObservation } from "./webEventListeners.js";
import type { NativeLoadImagePartialObservation } from "./native/nativeLoadImage.js";
import type { NativeAotSnapshotObservation } from "./native/nativeAotSnapshotObservation.js";
@@ -273,8 +273,8 @@ export type AnalysisPartialObservation =
| BrowserScenarioPartialObservation
| WebPageInspection
| WebBundleAnalysis
| WebExecution
| WebEventListeners
| WebExecutionObservation
| WebEventListenersObservation
| JavaScriptRuntimeObservation
| ArtifactInventoryPartialObservation
| ArtifactExtractionPartialObservation
+10 -2
View File
@@ -18,7 +18,7 @@ export type InspectWebEventListenersInput = z.infer<
>;
/** A listener declaration and its source location do not establish that it ran. */
export const webEventListenersSchema = z.object({
export const webEventListenersObservationSchema = z.object({
browser: browserVersionSchema,
target: webRuntimeTargetSchema,
inspected_at: z.iso.datetime(),
@@ -38,7 +38,15 @@ export const webEventListenersSchema = z.object({
}),
),
sources: z.array(webRuntimeSourceSchema),
cleanup: z.literal("confirmed"),
cleanup: z.enum(["confirmed", "unconfirmed"]),
limitations: z.array(z.string()),
});
/** Successful operations require the cleanup owner's acknowledgement. */
export const webEventListenersSchema =
webEventListenersObservationSchema.extend({
cleanup: z.literal("confirmed"),
});
export type WebEventListenersObservation = z.infer<
typeof webEventListenersObservationSchema
>;
export type WebEventListeners = z.infer<typeof webEventListenersSchema>;
+11 -2
View File
@@ -29,7 +29,7 @@ const functionSchema = z.object({
});
/** Precise V8 ranges retain their nested structure and counts without summing them. */
export const webExecutionSchema = z.object({
export const webExecutionObservationSchema = z.object({
browser: browserVersionSchema,
target: webRuntimeTargetSchema,
window: z.object({
@@ -86,9 +86,18 @@ export const webExecutionSchema = z.object({
disables_optimized_execution: z.literal(true),
takes_one_resetting_sample: z.boolean(),
executes_selected_code: z.literal(false),
cleanup: z.literal("confirmed"),
cleanup: z.enum(["confirmed", "unconfirmed"]),
page_ownership: z.literal("external"),
}),
limitations: z.array(z.string()),
});
/** Successful operations require the cleanup owner's acknowledgement. */
export const webExecutionSchema = webExecutionObservationSchema.extend({
instrumentation: webExecutionObservationSchema.shape.instrumentation.extend({
cleanup: z.literal("confirmed"),
}),
});
export type WebExecutionObservation = z.infer<
typeof webExecutionObservationSchema
>;
export type WebExecution = z.infer<typeof webExecutionSchema>;
@@ -0,0 +1,223 @@
import { expect, it, onTestFinished } from "vitest";
import { WebRuntimeService } from "../../../src/application/WebRuntimeService.js";
import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js";
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import {
webExecutionSchema,
webExecutionObservationSchema,
} from "../../../src/domain/webExecution.js";
import {
webEventListenersSchema,
webEventListenersObservationSchema,
} from "../../../src/domain/webEventListeners.js";
import { startRuntimeBrowser } from "../../fixtures/webRuntime.js";
import { waitFor } from "./cdpBrowserProvider.support.js";
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
it.each(["observe", "inspect"] as const)(
"retains truthful %s facts after rejected cleanup and preserves the successful control",
async (operation) => {
let deny = false;
const browser = await startRuntimeBrowser({
commandError: (command) =>
deny && command.method === "Page.disable"
? { code: -32000, message: "injected cleanup rejection" }
: undefined,
});
const provider = new CdpWebRuntimeProvider();
onTestFinished(async () => {
await provider.close();
await browser.close();
});
const service = new WebRuntimeService(provider);
const input = { cdp_endpoint: browser.endpoint, target_id: "allowed-page" };
const run = () =>
operation === "observe"
? service.observe({ ...input, observation_ms: 5 })
: service.inspect({ ...input, selector: "#missing" });
const healthy = await run();
if (!healthy.ok) throw healthy.error;
const cleanup = (
value: ReturnType<typeof parseEvidence>["normalized_result"],
) =>
operation === "observe"
? webExecutionObservationSchema.parse(value).instrumentation.cleanup
: webEventListenersObservationSchema.parse(value).cleanup;
expect(cleanup(healthy.value.normalized_result)).toBe("confirmed");
deny = true;
const failed = await run();
if (failed.ok) throw new Error("Rejected cleanup cannot be successful");
expect(failed.error.cleanupIncomplete).toBe(true);
const partial = parseEvidence(failed.error.partialObservation);
expect(cleanup(partial.normalized_result)).toBe("unconfirmed");
expect(
(operation === "observe"
? webExecutionSchema
: webEventListenersSchema
).safeParse(partial.normalized_result).success,
).toBe(false);
expect(partial.normalized_result).toMatchObject(
operation === "observe"
? {
target: { target_id: "allowed-page" },
coverage: { state: "captured" },
sources: expect.any(Array),
}
: {
target: { target_id: "allowed-page" },
selected_node: { match: "not_found" },
listeners: [],
},
);
expect(JSON.stringify(failed.error.diagnostics)).toContain("Page.disable");
expect(browser.commands.map(({ method }) => method)).toContain(
"Target.detachFromTarget",
);
expect(browser.commands.map(({ method }) => method)).not.toContain(
"Browser.close",
);
expect(browser.commands.map(({ method }) => method)).not.toContain(
"Target.closeTarget",
);
},
);
it("classifies failures by their admitted document request and retires completed identities", async () => {
const browser = await startFakeCdpBrowser();
const provider = new CdpBrowserProvider();
onTestFinished(async () => {
await provider.close();
await browser.close();
});
const result = await provider.observeSession(
observeWebSessionInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 20,
}),
{
progress: {
report: async (update) => {
if (update.completed !== 1) return;
const request = (
id: string | number,
type: string,
frameId: string,
redirect = false,
) =>
browser.emitEvent({
method: "Network.requestWillBeSent",
sessionId: "session-1",
params: {
requestId: id,
type,
frameId,
loaderId: "observed-loader",
request: {
url: `${browser.allowedOrigin}/destination`,
method: "GET",
},
timestamp: 100,
...(redirect ? { redirectResponse: { status: 302 } } : {}),
},
});
const fail = (id: string, type: string) =>
browser.emitEvent({
method: "Network.loadingFailed",
sessionId: "session-1",
params: {
requestId: id,
type,
timestamp: 101,
errorText: "net::ERR_CONNECTION_REFUSED",
},
});
request("image", "Image", "frame-main");
fail("image", "Image");
request("child", "Document", "child-frame");
fail("child", "Document");
fail("unobserved", "Document");
request(42, "Document", "frame-main");
request("complete", "Document", "frame-main");
browser.emitEvent({
method: "Network.loadingFinished",
sessionId: "session-1",
params: { requestId: "complete", timestamp: 101 },
});
fail("complete", "Document");
request("reused", "Document", "frame-main");
request("reused", "Image", "frame-main");
fail("reused", "Image");
request("navigation", "Document", "frame-main", true);
fail("navigation", "Document");
fail("navigation", "Document");
},
},
},
);
if (!result.ok) throw result.error;
expect(
result.value.timeline.map(({ type, request_id }) => [type, request_id]),
).toEqual([
["redirect", "navigation"],
["load_failed", "navigation"],
]);
expect(result.value.completeness.excluded).toContainEqual({
section: "timeline",
reason: "invalid_protocol_value",
count: 1,
});
expect(result.value.timeline[1]).toMatchObject({
frame_id: "frame-main",
loader_id: "observed-loader",
detail: "net::ERR_CONNECTION_REFUSED",
});
});
it.each([false, true])(
"keeps cleanup facts truthful when cancellation arrives during finalization (rejected=$0)",
async (reject) => {
const controller = new AbortController();
const reason = { stop: "during final cleanup" };
const browser = await startRuntimeBrowser({
commandError: (command) => {
if (command.method !== "Page.disable") return undefined;
controller.abort(reason);
return reject
? { code: -32000, message: "injected cleanup rejection after abort" }
: undefined;
},
});
const provider = new CdpWebRuntimeProvider();
onTestFinished(async () => {
await provider.close();
await browser.close();
});
const result = await new WebRuntimeService(provider).observe(
{
cdp_endpoint: browser.endpoint,
target_id: "allowed-page",
observation_ms: 5,
},
{ signal: controller.signal },
);
if (result.ok)
throw new Error("Finalization cancellation must be retained");
const primary = result.error.cleanupPrimary ?? result.error;
expect(primary).toMatchObject({ _tag: "AnalysisCancelledError" });
expect(primary.cause).toBe(reason);
expect(result.error.cleanupIncomplete).toBe(reject);
const partial = parseEvidence(result.error.partialObservation);
expect(
webExecutionObservationSchema.parse(partial.normalized_result)
.instrumentation.cleanup,
).toBe(reject ? "unconfirmed" : "confirmed");
await waitFor(
() => browser.activeConnectionCount() === 0,
"Runtime transport did not close after finalization",
);
},
);
@@ -73,23 +73,45 @@ const clockResponseEvents: NonNullable<FakeOptions["commandEvents"]> = (
request: { url: `${origin}/after-cutoff` },
},
},
{
method: "Network.requestWillBeSent",
sessionId: command.sessionId,
params: {
requestId: "failure-before-window",
type: "Document",
frameId: "frame-main",
timestamp: 18,
request: { url: `${origin}/document`, method: "GET" },
},
},
{
method: "Network.loadingFailed",
sessionId: command.sessionId,
params: {
requestId: "failure-before-window",
timestamp: 19,
frameId: "frame-main",
type: "Document",
errorText: "net::ERR_CONNECTION_REFUSED",
},
},
{
method: "Network.requestWillBeSent",
sessionId: command.sessionId,
params: {
requestId: "failure-window",
type: "Document",
frameId: "frame-main",
timestamp: 21,
request: { url: `${origin}/document`, method: "GET" },
},
},
{
method: "Network.loadingFailed",
sessionId: command.sessionId,
params: {
requestId: "failure-window",
timestamp: 22,
frameId: "frame-main",
type: "Document",
errorText: "net::ERR_CONNECTION_REFUSED",
},
},
@@ -345,12 +345,23 @@ it("represents absent timeline timestamps as unknown and preserves an observed z
navigationType: "historyApi",
},
},
{
method: "Network.requestWillBeSent",
sessionId: command.sessionId,
params: {
requestId: "actual-zero-clock-value",
type: "Document",
frameId: "frame-main",
timestamp: 0,
request: { url: `${origin}/document`, method: "GET" },
},
},
{
method: "Network.loadingFailed",
sessionId: command.sessionId,
params: {
requestId: "actual-zero-clock-value",
frameId: "frame-main",
type: "Document",
errorText: "net::ERR_CONNECTION_REFUSED",
timestamp: 0,
},
@@ -0,0 +1,69 @@
import { expect, it, onTestFinished, vi } from "vitest";
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js";
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
// Scale only the existing retained-event allowance to exercise refusal without
// allocating megabytes. The real HTTP/WebSocket owner and producer parser run.
vi.mock("../../../src/domain/webRuntime.js", async (importOriginal) => {
const domain =
await importOriginal<typeof import("../../../src/domain/webRuntime.js")>();
return {
...domain,
WEB_RUNTIME_LIMITS: {
...domain.WEB_RUNTIME_LIMITS,
retainedEventBytes: 128,
},
};
});
it("refuses active document identity retention before reporting a complete session", async () => {
const browser = await startFakeCdpBrowser({ commandEvents: () => [] });
const provider = new CdpBrowserProvider();
onTestFinished(async () => {
await provider.close();
await browser.close();
});
const result = await provider.observeSession(
observeWebSessionInputSchema.parse({
cdp_endpoint: browser.endpoint,
target_id: "allowed-page",
allowed_origins: [browser.allowedOrigin],
observation_ms: 1_000,
}),
{
signal: AbortSignal.timeout(2_000),
progress: {
report: async (update) => {
if (update.completed === 1)
browser.emitEvent({
method: "Network.requestWillBeSent",
sessionId: "session-1",
params: {
requestId: "r".repeat(128),
type: "Document",
frameId: "frame-main",
loaderId: "loader",
timestamp: 100,
request: {
url: `${browser.allowedOrigin}/page`,
method: "GET",
},
},
});
},
},
},
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "BrowserObservationError",
reason: "payload_limit",
detail: expect.stringContaining("Active document request identities"),
},
});
expect(browser.commands.map(({ method }) => method)).toContain(
"Target.detachFromTarget",
);
});
+9 -1
View File
@@ -279,10 +279,18 @@ const emitSessionTimeline = (
redirectResponse: { status: 302 },
timestamp: 13,
});
event(socket, "Network.loadingFailed", command.sessionId, {
event(socket, "Network.requestWillBeSent", command.sessionId, {
requestId: "failed-request",
type: "Document",
frameId: "frame-main",
loaderId: "loader-reload",
request: { url: allowed, method: "GET" },
initiator: { type: "other" },
timestamp: 13.5,
});
event(socket, "Network.loadingFailed", command.sessionId, {
requestId: "failed-request",
type: "Document",
errorText: "net::ERR_CONNECTION_REFUSED",
timestamp: 14,
});