fix(browser): preserve uncertain main-frame navigation identity

Own CDP frame applicability and identity at the parsed event boundary. Preserve malformed or incomplete main-frame facts, recognize replacement roots, and carry the same document identity through captures, screenshots, timelines, and execution windows.

Validation: full check:ci and docs:check; 763 browser and dispatch tests passed, 6 skipped. Independent source and fixture-lifecycle review passed. Local real-Chrome behavior was not exercised.

Closes #5665
This commit is contained in:
morluto committed 2026-10-21 07:16:47 +08:00
1 parent 9dee989a79
commit 0bd14ca16c
16 files changed
+962 -179

No files matched your search

@@ -63,3 +63,57 @@ it("settles caller cancellation and removes its local timer", async () => {
vi.useRealTimers();
}
});
it("keeps a root document transition distinct from a child frame event", async () => {
const root = new CdpExecutionWindow({
target: { frame_id: "selected-main" },
operation: "observe_web_execution",
options: {},
});
const rootResult = root.start(30_000);
root.ingest({
method: "Page.frameNavigated",
params: { frame: { id: "new-main", url: "https://example.test/" } },
});
await expect(rootResult).resolves.toBe("document_changed");
const child = new CdpExecutionWindow({
target: { frame_id: "selected-main" },
operation: "observe_web_execution",
options: {},
});
const childResult = child.start(30_000);
child.ingest({
method: "Page.frameNavigated",
params: {
frame: {
id: "child-frame",
parentId: "selected-main",
url: "https://example.test/child",
},
},
});
expect(child.active).toBe(true);
child.end("window_elapsed");
await expect(childResult).resolves.toBe("window_elapsed");
});
it("surfaces malformed frame identity as a protocol failure without guessing a transition", async () => {
const window = new CdpExecutionWindow({
target: { frame_id: "selected-main" },
operation: "observe_web_execution",
options: {},
});
const result = window.start(30_000);
window.ingest({
method: "Page.frameNavigated",
params: {
frame: { id: "child-frame", parentId: 7, url: "https://example.test/" },
},
});
await expect(result).rejects.toMatchObject({
_tag: "BrowserObservationError",
reason: "protocol_error",
});
expect(window.reason).toBeUndefined();
});
+25 -8
View File
@@ -3,6 +3,8 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { WebExecution } from "../../domain/webExecution.js";
import type { CdpEvent } from "../CdpConnection.js";
import type { CdpRuntimeSession } from "./CdpRuntimeSession.js";
import { BrowserObservationError } from "../../domain/browserObservationError.js";
import { cdpFrameIdentity } from "../CdpCaptureEventHelpers.js";
type EndReason = WebExecution["window"]["end_reason"];
/** One finite document-scoped window, with an actual armed point and event-driven termination. */
@@ -33,15 +35,30 @@ export class CdpExecutionWindow {
if (!this.active) return;
if (event.method === "Runtime.executionContextsCleared")
this.end("document_changed");
if (
event.method === "Page.frameNavigated" &&
typeof event.params === "object" &&
event.params !== null &&
"frame" in event.params
) {
const frame = event.params.frame;
if (typeof frame === "object" && frame !== null && !("parentId" in frame))
if (event.method === "Page.frameNavigated") {
const identity = cdpFrameIdentity(event, this.session.target.frame_id);
if (identity.identityIssue !== null || identity.parentIssue !== null) {
this.fail(
new BrowserObservationError(this.session.operation, "protocol_error"),
);
return;
}
if (identity.selection === "main") {
this.end("document_changed");
return;
}
if (identity.parentRelation === "child") return;
if (
identity.selection === "other" &&
identity.parentRelation === "root"
) {
this.end("document_changed");
return;
}
this.fail(
new BrowserObservationError(this.session.operation, "protocol_error"),
);
return;
}
if (
event.method === "Page.frameDetached" &&