mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(browser): retain truthful cleanup facts and document request ownership
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user