mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(browser): retain precise observation termination facts
Distinguish CDP session detachment, observed crashes, and transport loss. Preserve the observed window alongside the original typed coverage-stop failure and unconfirmed cleanup. Validated with 40 focused boundary tests, check:ci, docs:check, and the real Chrome public CLI/MCP verifier on macOS. Browser timing comparison remains incomplete because unchanged-main fixture readiness was intermittent. Fixes #5756
This commit is contained in:
+3
-1
@@ -32,7 +32,9 @@ A document replacement ends the window; final coverage then remains unavailable.
|
||||
|
||||
Same-document navigation such as `history.pushState()` remains valid when the frame and known loader identity stay stable and the current origin is allowed. Unknown loader identity retains a conservative URL check; newly reported or replaced loaders are rejected. Malformed producer events end an armed window immediately and trigger cleanup, independently of the selected duration.
|
||||
|
||||
Target detachment ends the window even when the browser's root connection stays open. When the closed session cannot acknowledge cleanup, the operation returns a failure with the selected target, failed commands and original producer reason; it does not claim successful cleanup.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { CdpConnection } from "../../dist/browser/CdpConnection.js";
|
||||
import { mcpTextValue } from "./mcp-verifier-results.mjs";
|
||||
import { mcpTextValue, requireMcpToolError } from "./mcp-verifier-results.mjs";
|
||||
import { closeVerifierResources } from "./verifier-cleanup.mjs";
|
||||
|
||||
/** Close a separately owned fixture page after actual MCP arming and inspect native cleanup diagnostics. */
|
||||
@@ -60,17 +60,26 @@ export async function verifyRuntimeTargetTermination(client, input, origin) {
|
||||
"Detached instrumentation cannot claim confirmed cleanup",
|
||||
);
|
||||
const failure = mcpTextValue(response);
|
||||
const projected = requireMcpToolError(response);
|
||||
assert.match(failure, /cleanup.*confirmed/u);
|
||||
assert.ok(
|
||||
failure.includes(targetId),
|
||||
"Failure must retain the selected target identity",
|
||||
);
|
||||
assert.match(failure, /Profiler\.stopPreciseCoverage/u);
|
||||
assert.equal(projected.code, "cleanup_incomplete");
|
||||
assert.equal(
|
||||
projected.details?.diagnostics?.primary_error?.details?.diagnostics
|
||||
?.observed_window?.end_reason,
|
||||
"session_detached",
|
||||
"Target.closeTarget detaches this CDP session; it does not prove the target crashed",
|
||||
);
|
||||
return {
|
||||
target_id: targetId,
|
||||
page_closed_after_arming: true,
|
||||
failure_inline: true,
|
||||
cleanup: "unconfirmed_reported",
|
||||
observed_window_end_reason: "session_detached",
|
||||
};
|
||||
} catch (cause) {
|
||||
primaryError = cause;
|
||||
|
||||
@@ -69,7 +69,7 @@ export const observeCdpSession = async (
|
||||
const capture = new TimelineCapture(allowedOrigins, mainFrameId, initialUrl);
|
||||
let removeListener = (): void => {};
|
||||
const removeDisconnectListener = context.connection.onDisconnect(() =>
|
||||
capture.targetTerminated(),
|
||||
capture.transportDisconnected(),
|
||||
);
|
||||
try {
|
||||
await context.connection.send(
|
||||
@@ -236,9 +236,11 @@ class TimelineCapture {
|
||||
this.#lifecycle(params);
|
||||
break;
|
||||
case "Inspector.targetCrashed":
|
||||
case "Target.detachedFromTarget":
|
||||
this.targetTerminated(params);
|
||||
break;
|
||||
case "Target.detachedFromTarget":
|
||||
this.sessionDetached(params);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +308,32 @@ class TimelineCapture {
|
||||
this.end("target_terminated");
|
||||
}
|
||||
|
||||
sessionDetached(params: UnknownRecord = {}): void {
|
||||
if (this.#endReason !== undefined) return;
|
||||
this.#add({
|
||||
type: "session_detached",
|
||||
params,
|
||||
timestamp: null,
|
||||
rawUrl: null,
|
||||
requestId: null,
|
||||
detail: "session_detached",
|
||||
});
|
||||
this.end("session_detached");
|
||||
}
|
||||
|
||||
transportDisconnected(): void {
|
||||
if (this.#endReason !== undefined) return;
|
||||
this.#add({
|
||||
type: "transport_disconnected",
|
||||
params: {},
|
||||
timestamp: null,
|
||||
rawUrl: null,
|
||||
requestId: null,
|
||||
detail: "transport_disconnected",
|
||||
});
|
||||
this.end("transport_disconnected");
|
||||
}
|
||||
|
||||
setFinalUrl(rawUrl: string): void {
|
||||
if (this.#scopeExcluded) return;
|
||||
const destination = scopedUrl(rawUrl, this.allowedOrigins);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { BrowserObservationError } from "../../domain/browserObservationError.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
@@ -37,9 +38,18 @@ export const captureWebExecution = async (
|
||||
sources.check();
|
||||
window.check();
|
||||
if (window.reason !== undefined)
|
||||
throw new BrowserObservationError(session.operation, "target_changed", {
|
||||
detail: `Selected target ${session.target.target_id} ended with ${window.reason} before execution observation could be armed.`,
|
||||
});
|
||||
throw new BrowserObservationError(
|
||||
session.operation,
|
||||
window.reason === "session_detached"
|
||||
? "target_session_detached"
|
||||
: "target_changed",
|
||||
{
|
||||
detail: executionWindowEndDetail(
|
||||
session.target.target_id,
|
||||
window.reason,
|
||||
),
|
||||
},
|
||||
);
|
||||
const waiting = window.start(input.observation_ms);
|
||||
// Cancellation may arrive while asynchronous progress is being delivered.
|
||||
void waiting.catch(() => undefined);
|
||||
@@ -64,7 +74,7 @@ export const captureWebExecution = async (
|
||||
raw = await session.command("Profiler.takePreciseCoverage");
|
||||
}
|
||||
collection.freezeMetadata();
|
||||
await session.command("Profiler.stopPreciseCoverage");
|
||||
await stopPreciseCoverage(session, reason);
|
||||
session.preciseCoverageMayBeActive = false;
|
||||
const requestItems = requests.result();
|
||||
const parsed =
|
||||
@@ -161,3 +171,36 @@ export const captureWebExecution = async (
|
||||
collection.close();
|
||||
}
|
||||
};
|
||||
|
||||
const stopPreciseCoverage = async (
|
||||
session: CdpRuntimeSources["session"],
|
||||
reason: WebExecution["window"]["end_reason"],
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await session.command("Profiler.stopPreciseCoverage");
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
(reason !== "target_terminated" && reason !== "session_detached") ||
|
||||
!(cause instanceof AnalysisError)
|
||||
)
|
||||
throw cause;
|
||||
cause.retainDiagnostics({
|
||||
observed_window: {
|
||||
target_id: session.target.target_id,
|
||||
end_reason: reason,
|
||||
},
|
||||
});
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const executionWindowEndDetail = (
|
||||
targetId: string,
|
||||
reason: WebExecution["window"]["end_reason"],
|
||||
): string => {
|
||||
if (reason === "session_detached")
|
||||
return `The debugging session for selected target ${targetId} detached before execution observation could be armed; the target may still exist.`;
|
||||
if (reason === "target_terminated")
|
||||
return `Selected target ${targetId} crashed before execution observation could be armed.`;
|
||||
return `Selected target ${targetId} changed documents before execution observation could be armed.`;
|
||||
};
|
||||
|
||||
@@ -40,8 +40,8 @@ export class CdpExecutionCollection {
|
||||
}
|
||||
this.window.ingest(event);
|
||||
}),
|
||||
session.transport.connection.onDisconnect(() =>
|
||||
this.window.end("target_terminated"),
|
||||
session.transport.connection.onDisconnect((error) =>
|
||||
this.window.fail(error),
|
||||
),
|
||||
session.transport.connection.onProtocolFailure((error) =>
|
||||
this.window.fail(error),
|
||||
|
||||
@@ -25,10 +25,11 @@ export class CdpExecutionWindow {
|
||||
|
||||
/** Collection ends on a main-document replacement, never follows source URLs into another context. */
|
||||
ingest(event: CdpEvent): void {
|
||||
if (
|
||||
event.method === "Target.detachedFromTarget" ||
|
||||
event.method === "Inspector.targetCrashed"
|
||||
) {
|
||||
if (event.method === "Target.detachedFromTarget") {
|
||||
this.end("session_detached");
|
||||
return;
|
||||
}
|
||||
if (event.method === "Inspector.targetCrashed") {
|
||||
this.end("target_terminated");
|
||||
return;
|
||||
}
|
||||
@@ -101,7 +102,7 @@ export class CdpExecutionWindow {
|
||||
});
|
||||
}
|
||||
|
||||
/** Transport loss ends the window; instrumentation cleanup still requires confirmation. */
|
||||
/** A producer lifecycle fact ends the window; instrumentation cleanup still requires confirmation. */
|
||||
end(reason: EndReason): void {
|
||||
if (this.reason !== undefined || this.#failure !== undefined) return;
|
||||
this.reason = reason;
|
||||
|
||||
@@ -143,17 +143,21 @@ export abstract class AnalysisError extends Error {
|
||||
this.#ownedCleanupResources = [...resources];
|
||||
}
|
||||
|
||||
/** Retain a later lifecycle failure without replacing the original typed error. */
|
||||
/** Retain diagnostic facts without replacing the original typed error. */
|
||||
retainDiagnostics(diagnostics: Readonly<Record<string, JsonValue>>): this {
|
||||
Object.assign(this, {
|
||||
diagnostics: structuredClone({ ...this.diagnostics, ...diagnostics }),
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
retainSecondaryFailure(
|
||||
failure: AnalysisError,
|
||||
diagnostics?: Readonly<Record<string, JsonValue>>,
|
||||
): this {
|
||||
// Secondary resources remain owned by their cleanup lifecycle; preserve
|
||||
// the primary error's live cleanup status and original output.
|
||||
if (diagnostics !== undefined)
|
||||
Object.assign(this, {
|
||||
diagnostics: structuredClone({ ...this.diagnostics, ...diagnostics }),
|
||||
});
|
||||
if (diagnostics !== undefined) this.retainDiagnostics(diagnostics);
|
||||
this.#secondaryFailures.push(failure);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,7 @@ const browserErrorCategory = (
|
||||
reason === "target_not_allowed" ||
|
||||
reason === "service_worker_blocked" ||
|
||||
reason === "target_changed" ||
|
||||
reason === "target_session_detached" ||
|
||||
reason === "endpoint_unreachable" ||
|
||||
reason === "disconnected"
|
||||
)
|
||||
|
||||
@@ -180,6 +180,7 @@ const BROWSER_ERROR_CODES: Readonly<
|
||||
target_not_allowed: "target_unavailable",
|
||||
service_worker_blocked: "target_unavailable",
|
||||
target_changed: "target_unavailable",
|
||||
target_session_detached: "target_unavailable",
|
||||
protocol_error: "unreadable_output",
|
||||
disconnected: "provider_unavailable",
|
||||
payload_limit: "truncated",
|
||||
|
||||
@@ -51,6 +51,8 @@ const observationFailureMessage = (
|
||||
return `${operation} could not reach the selected loopback debugging endpoint. Verify that the target process is running with debugging enabled and that the endpoint's port matches it, then retry.`;
|
||||
if (reason === "target_not_found")
|
||||
return `${operation} could not find the selected target at the debugging endpoint. Refresh target discovery for that endpoint and retry with a returned target ID.`;
|
||||
if (reason === "target_session_detached")
|
||||
return `${operation} lost its debugging session while the target may still exist. Refresh target discovery and attach to the selected target again.`;
|
||||
if (reason === "service_worker_blocked")
|
||||
return `${operation} refused the selected page because it is controlled by or has a registered service worker while the service-worker policy is block. Set environment.service_workers to allow to permit observation, or remove the service worker registration through the browser.`;
|
||||
if (reason === "disconnected")
|
||||
|
||||
@@ -26,6 +26,7 @@ export type BrowserObservationFailureReason =
|
||||
| "target_not_allowed"
|
||||
| "service_worker_blocked"
|
||||
| "target_changed"
|
||||
| "target_session_detached"
|
||||
| "protocol_error"
|
||||
| "disconnected"
|
||||
| "payload_limit"
|
||||
|
||||
@@ -29,6 +29,8 @@ const timelineEventSchema = z.object({
|
||||
"load_failed",
|
||||
"lifecycle",
|
||||
"target_terminated",
|
||||
"session_detached",
|
||||
"transport_disconnected",
|
||||
]),
|
||||
timestamp: z
|
||||
.number()
|
||||
@@ -63,6 +65,8 @@ export const webObservationSessionSchema = z.object({
|
||||
"window_elapsed",
|
||||
"target_left_scope",
|
||||
"target_terminated",
|
||||
"session_detached",
|
||||
"transport_disconnected",
|
||||
]),
|
||||
}),
|
||||
timeline: z.array(timelineEventSchema),
|
||||
|
||||
@@ -40,6 +40,7 @@ export const webExecutionSchema = z.object({
|
||||
"window_elapsed",
|
||||
"document_changed",
|
||||
"target_terminated",
|
||||
"session_detached",
|
||||
]),
|
||||
producer_started_seconds: z.number().min(0),
|
||||
producer_sampled_seconds: z.number().min(0).nullable(),
|
||||
|
||||
@@ -366,10 +366,10 @@ describe("CdpBrowserProvider: session termination and scope changes", () => {
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.window.end_reason).toBe("target_terminated");
|
||||
expect(result.value.window.end_reason).toBe("session_detached");
|
||||
expect(result.value.timeline.at(-1)).toMatchObject({
|
||||
type: "target_terminated",
|
||||
detail: "target_terminated",
|
||||
type: "session_detached",
|
||||
detail: "session_detached",
|
||||
timestamp: null,
|
||||
});
|
||||
expect(result.value.limitations).toContain(
|
||||
@@ -377,6 +377,28 @@ describe("CdpBrowserProvider: session termination and scope changes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports target termination only for an observed target crash", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
sessionTimeline: "target_crashed",
|
||||
});
|
||||
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_000,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.window.end_reason).toBe("target_terminated");
|
||||
expect(result.value.timeline.at(-1)).toMatchObject({
|
||||
type: "target_terminated",
|
||||
detail: "target_terminated",
|
||||
});
|
||||
});
|
||||
|
||||
it("ends a direct page session when its transport disconnects", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
pageScopedVersionWebSocket: true,
|
||||
@@ -394,10 +416,10 @@ describe("CdpBrowserProvider: session termination and scope changes", () => {
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.window.end_reason).toBe("target_terminated");
|
||||
expect(result.value.window.end_reason).toBe("transport_disconnected");
|
||||
expect(result.value.timeline.at(-1)).toMatchObject({
|
||||
type: "target_terminated",
|
||||
detail: "target_terminated",
|
||||
type: "transport_disconnected",
|
||||
detail: "transport_disconnected",
|
||||
});
|
||||
const methods = browser.commands.map(({ method }) => method);
|
||||
expect(methods).not.toContain("Target.attachToTarget");
|
||||
|
||||
@@ -268,6 +268,8 @@ it("fails closed on an untimestamped out-of-scope redirect during clock sampling
|
||||
it.each([
|
||||
[
|
||||
"Inspector.targetCrashed",
|
||||
"target_terminated",
|
||||
"target_terminated",
|
||||
{
|
||||
method: "Inspector.targetCrashed",
|
||||
sessionId: "session-1",
|
||||
@@ -276,6 +278,8 @@ it.each([
|
||||
],
|
||||
[
|
||||
"Target.detachedFromTarget",
|
||||
"session_detached",
|
||||
"session_detached",
|
||||
{
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: "session-1", targetId: "allowed-page" },
|
||||
@@ -283,7 +287,7 @@ it.each([
|
||||
],
|
||||
] as const)(
|
||||
"ends on buffered untimestamped %s before collecting later events",
|
||||
async (_method, terminalEvent) => {
|
||||
async (_method, endReason, eventType, terminalEvent) => {
|
||||
const { observation } = await observeWithFake({
|
||||
eventsBeforeReply: ["Performance.getMetrics"],
|
||||
commandResult: (command) =>
|
||||
@@ -310,10 +314,10 @@ it.each([
|
||||
},
|
||||
});
|
||||
|
||||
expect(observation.window.end_reason).toBe("target_terminated");
|
||||
expect(observation.window.end_reason).toBe(endReason);
|
||||
expect(observation.timeline).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "target_terminated",
|
||||
type: eventType,
|
||||
timestamp: null,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js";
|
||||
import type { CdpEvent } from "../../../src/browser/CdpConnection.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js";
|
||||
import { startRuntimeBrowser } from "../../fixtures/webRuntime.js";
|
||||
|
||||
describe("runtime flat-session lifecycle", () => {
|
||||
it("rejects a producer detachment between counter startup and local arming", async () => {
|
||||
it("rejects session detachment between counter startup and local arming", async () => {
|
||||
let emitEvent: ((event: CdpEvent) => void) | undefined;
|
||||
const browser = await startRuntimeBrowser({
|
||||
commandResult: (command) => {
|
||||
@@ -37,14 +38,223 @@ describe("runtime flat-session lifecycle", () => {
|
||||
},
|
||||
);
|
||||
if (result.ok)
|
||||
throw new Error("A terminated target cannot arm an execution window");
|
||||
expect(result.error).toMatchObject({ reason: "target_changed" });
|
||||
throw new Error("A detached session cannot arm an execution window");
|
||||
expect(result.error).toMatchObject({ reason: "target_session_detached" });
|
||||
expect(reportedArmed).toBe(false);
|
||||
});
|
||||
|
||||
it("ends immediately on its root detachment without confusing another session", async () => {
|
||||
it.each([
|
||||
{
|
||||
case: "matching detach after an unrelated session detaches",
|
||||
events: [
|
||||
{
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: "another-session" },
|
||||
},
|
||||
{
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: "session-1" },
|
||||
},
|
||||
] as CdpEvent[],
|
||||
observationMs: 2_147_483_647,
|
||||
endReason: "session_detached",
|
||||
},
|
||||
{
|
||||
case: "detach for another flat session",
|
||||
events: [
|
||||
{
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: "another-session" },
|
||||
},
|
||||
] as CdpEvent[],
|
||||
observationMs: 30,
|
||||
endReason: "window_elapsed",
|
||||
},
|
||||
{
|
||||
case: "the observed crash event",
|
||||
events: [
|
||||
{
|
||||
method: "Inspector.targetCrashed",
|
||||
sessionId: "session-1",
|
||||
params: {},
|
||||
},
|
||||
] as CdpEvent[],
|
||||
observationMs: 2_147_483_647,
|
||||
endReason: "target_terminated",
|
||||
},
|
||||
])("preserves lifecycle identity for $case", async (scenario) => {
|
||||
const browser = await startRuntimeBrowser();
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpWebRuntimeProvider().observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
observation_ms: scenario.observationMs,
|
||||
}),
|
||||
{
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
progress: {
|
||||
report: async (update) => {
|
||||
if (update.completed === 1)
|
||||
for (const event of scenario.events) browser.emitEvent(event);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.window.end_reason).toBe(scenario.endReason);
|
||||
if (scenario.endReason !== "window_elapsed") {
|
||||
expect(result.value.coverage.state).toBe("unavailable");
|
||||
expect(result.value.instrumentation.takes_one_resetting_sample).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime stop lifecycle", () => {
|
||||
it.each(["cleanup failure", "caller cancellation"] as const)(
|
||||
"retains explicit session detachment during precise-coverage stop with %s",
|
||||
async (outcome) => {
|
||||
const controller = new AbortController();
|
||||
const reason = { caller: "stop precise coverage" };
|
||||
const browser = await startRuntimeBrowser(
|
||||
outcome === "cleanup failure"
|
||||
? {
|
||||
commandError: (command) =>
|
||||
command.method === "Profiler.stopPreciseCoverage"
|
||||
? { code: -32_000, message: "Target session is detached" }
|
||||
: undefined,
|
||||
}
|
||||
: {
|
||||
commandResult: (command) => {
|
||||
if (command.method === "Profiler.stopPreciseCoverage")
|
||||
controller.abort(reason);
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
);
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpWebRuntimeProvider().observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 2_147_483_647,
|
||||
}),
|
||||
{
|
||||
signal:
|
||||
outcome === "cleanup failure"
|
||||
? AbortSignal.timeout(2_000)
|
||||
: controller.signal,
|
||||
progress: {
|
||||
report: async (update) => {
|
||||
if (update.completed === 1)
|
||||
browser.emitEvent({
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: "session-1" },
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (outcome === "caller cancellation") {
|
||||
if (result.ok) throw new Error("The aborted stop command must fail");
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "AnalysisCancelledError",
|
||||
cause: reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.ok)
|
||||
throw new Error("Unconfirmed cleanup must remain a failure");
|
||||
|
||||
const projection = projectAnalysisError(result.error);
|
||||
expect(projection.code).toBe("cleanup_incomplete");
|
||||
expect(projection.details?.diagnostics).toMatchObject({
|
||||
primary_error: {
|
||||
message: expect.stringContaining("Target session is detached"),
|
||||
details: {
|
||||
diagnostics: {
|
||||
observed_window: {
|
||||
target_id: "allowed-page",
|
||||
end_reason: "session_detached",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cleanup_error: {
|
||||
details: {
|
||||
diagnostics: {
|
||||
cleanup_failures: expect.arrayContaining([
|
||||
expect.stringContaining("Profiler.stopPreciseCoverage"),
|
||||
]),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
browser.commands.filter(
|
||||
({ method }) => method === "Profiler.stopPreciseCoverage",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(result.error.cleanupPrimary).toMatchObject({
|
||||
_tag: "BrowserObservationError",
|
||||
reason: "protocol_error",
|
||||
diagnostics: {
|
||||
observed_window: {
|
||||
target_id: "allowed-page",
|
||||
end_reason: "session_detached",
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("runtime disconnect ownership", () => {
|
||||
it("preserves a pending stop-command transport failure after session detachment", async () => {
|
||||
const browser = await startRuntimeBrowser({
|
||||
hangOnMethod: "Profiler.stopPreciseCoverage",
|
||||
});
|
||||
let closed = false;
|
||||
onTestFinished(async () => {
|
||||
if (!closed) await browser.close();
|
||||
});
|
||||
const observed = new CdpWebRuntimeProvider().observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 1,
|
||||
}),
|
||||
{ signal: AbortSignal.timeout(2_000) },
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
browser.commands.some(
|
||||
({ method }) => method === "Profiler.stopPreciseCoverage",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
browser.emitEvent({
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: "session-1" },
|
||||
});
|
||||
await browser.close();
|
||||
closed = true;
|
||||
const result = await observed;
|
||||
if (result.ok) throw new Error("The pending stop request must fail");
|
||||
expect(result.error.cleanupPrimary).toMatchObject({
|
||||
_tag: "BrowserObservationError",
|
||||
reason: "disconnected",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer target termination from transport disconnect", async () => {
|
||||
const browser = await startRuntimeBrowser();
|
||||
let closed = false;
|
||||
onTestFinished(async () => {
|
||||
if (!closed) await browser.close();
|
||||
});
|
||||
const result = await new CdpWebRuntimeProvider().observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
@@ -52,52 +262,55 @@ describe("runtime flat-session lifecycle", () => {
|
||||
observation_ms: 2_147_483_647,
|
||||
}),
|
||||
{
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
progress: {
|
||||
report: async (update) => {
|
||||
if (update.completed !== 1) return;
|
||||
browser.emitEvent({
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: "another-session" },
|
||||
});
|
||||
browser.emitEvent({
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: "session-1" },
|
||||
});
|
||||
if (update.completed === 1) {
|
||||
await browser.close();
|
||||
closed = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.window.end_reason).toBe("target_terminated");
|
||||
expect(result.value.coverage.state).toBe("unavailable");
|
||||
expect(result.value.instrumentation.takes_one_resetting_sample).toBe(false);
|
||||
if (result.ok) throw new Error("A disconnected transport is not a result");
|
||||
expect(result.error.cleanupPrimary).toMatchObject({
|
||||
_tag: "BrowserObservationError",
|
||||
reason: "disconnected",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores root detachment for a different flat session", async () => {
|
||||
const browser = await startRuntimeBrowser();
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpWebRuntimeProvider().observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 30,
|
||||
}),
|
||||
{
|
||||
progress: {
|
||||
report: async (update) => {
|
||||
if (update.completed === 1)
|
||||
browser.emitEvent({
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: "another-session" },
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.window.end_reason).toBe("window_elapsed");
|
||||
});
|
||||
describe("runtime cleanup failure ordering", () => {
|
||||
it.each([
|
||||
["Profiler.takePreciseCoverage", "Coverage sample failed"],
|
||||
["Profiler.stopPreciseCoverage", "Coverage stop failed"],
|
||||
] as const)(
|
||||
"keeps the %s protocol failure primary when transport later closes",
|
||||
async (method, message) => {
|
||||
const browser = await startRuntimeBrowser({
|
||||
commandError: (command) =>
|
||||
command.method === method ? { code: -32_000, message } : undefined,
|
||||
closeAfterMethod: method,
|
||||
});
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpWebRuntimeProvider().observeExecution(
|
||||
observeWebExecutionInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 1,
|
||||
}),
|
||||
{ signal: AbortSignal.timeout(2_000) },
|
||||
);
|
||||
if (result.ok) throw new Error("The failed CDP command must fail");
|
||||
const primary = result.error.cleanupPrimary;
|
||||
expect(primary).toMatchObject({
|
||||
_tag: "BrowserObservationError",
|
||||
reason: "protocol_error",
|
||||
});
|
||||
if (method === "Profiler.takePreciseCoverage")
|
||||
expect(primary?.secondaryFailures).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("coverage URL representation", () => {
|
||||
|
||||
Vendored
+2
@@ -60,6 +60,8 @@ export const startFakeCdpBrowser = async (
|
||||
const commandError = options.commandError?.(command);
|
||||
if (commandError !== undefined) {
|
||||
sendCommandReply(socket, command, { error: commandError });
|
||||
if (options.closeAfterMethod === command.method)
|
||||
setImmediate(() => socket.close());
|
||||
return;
|
||||
}
|
||||
if (options.closeOnMethod === command.method) {
|
||||
|
||||
+2
@@ -305,6 +305,8 @@ const emitSessionTimeline = (
|
||||
sessionId: command.sessionId,
|
||||
targetId: "allowed-page",
|
||||
});
|
||||
if (options.sessionTimeline === "target_crashed")
|
||||
event(socket, "Inspector.targetCrashed", command.sessionId, {});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+2
-1
@@ -92,7 +92,8 @@ export interface FakeOptions {
|
||||
readonly sessionTimeline?:
|
||||
| "same_origin"
|
||||
| "outside_policy"
|
||||
| "target_detached";
|
||||
| "target_detached"
|
||||
| "target_crashed";
|
||||
readonly sessionTimelineEventCount?: number;
|
||||
readonly sessionRedirectResourceType?: "Document" | "Fetch" | "Script";
|
||||
readonly closeAfterMethod?: string;
|
||||
|
||||
Reference in New Issue
Block a user