返回 DeepSeek-Reasonix
frameRuntime.test.ts
根目录 / desktop / electron / src / main / browser / frameRuntime.test.ts
1 import assert from "node:assert/strict";
2 import { test } from "node:test";
3 import { FakeFrame, FakePage } from "./fakeGuestViews.js";
4 import { FrameRuntime, runChildFrame, withFrameOperationSignal } from "./frameRuntime.js";
5 import { uploadFiles } from "./upload.js";
6 import type { LocatedRef } from "./refResolver.js";
7 import { DEBUGGER_IDLE_MS, disposeDebugger } from "./debuggerLease.js";
8
9 function fixture() {
10 const page = new FakePage(1);
11 page.debugger.attached = true;
12 const parent = new FakeFrame(2, "https://parent.test", () => undefined);
13 const child = new FakeFrame(3, "https://child.test", () => undefined);
14 parent.parent = page.mainFrame; page.mainFrame.children.push(parent);
15 child.parent = parent; parent.children.push(child);
16 const sessions = new Set<string>();
17 page.debugger.respond = (method, raw) => {
18 const params = raw as Record<string, unknown>;
19 if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "root" } } };
20 if (method === "Page.createIsolatedWorld") return { executionContextId: params.frameId === "root" ? 1 : params.frameId === "parent" ? 2 : 3 };
21 if (method === "Runtime.evaluate") return params.returnByValue ? { result: { value: "observed" } } : { result: { objectId: `owner-${params.contextId}` } };
22 if (method === "DOM.describeNode") return { node: { frameId: params.objectId === "owner-1" ? "parent" : "child", backendNodeId: params.objectId === "owner-1" ? 11 : 22 } };
23 if (method === "Target.getTargets") return { targetInfos: [{ targetId: "parent", type: "iframe" }, { targetId: "child", type: "iframe" }] };
24 if (method === "Target.attachToTarget") { const sessionId = `session-${params.targetId}`; sessions.add(sessionId); return { sessionId }; }
25 if (method === "Target.detachFromTarget") sessions.delete(params.sessionId as string);
26 return {};
27 };
28 return { page, parent, child, sessions };
29 }
30
31 test("operations own contexts while adjacent calls reuse the page's target sessions", async t => {
32 t.mock.timers.enable({ apis: ["setTimeout"] });
33 const f = fixture(), runtime = new FrameRuntime(f.page);
34 assert.equal(await runtime.run(f.child, "read"), "observed");
35 assert.equal(await runtime.run(f.parent, "read"), "observed");
36 assert.equal(f.page.debugger.commands.filter(c => c.method === "Page.createIsolatedWorld").length, 3);
37 assert.equal(f.page.debugger.commands.filter(c => c.method === "Target.detachFromTarget").length, 0, "parents stay available until the operation ends");
38 await runtime.close(); await runtime.close();
39 assert.equal(f.sessions.size, 2, "targets stay attached across successful adjacent calls");
40 assert.equal(await runChildFrame(f.page, f.child, "read"), "observed");
41 assert.equal(f.page.debugger.commands.filter(c => c.method === "Target.attachToTarget").length, 2);
42 assert.equal(f.page.debugger.commands.filter(c => c.method === "Page.createIsolatedWorld").length, 6, "contexts remain operation-scoped");
43 t.mock.timers.tick(DEBUGGER_IDLE_MS);
44 assert.deepEqual([...f.sessions], []);
45 assert.equal(f.page.debugger.isAttached(), true, "external root attachment is preserved");
46 });
47
48 test("child cleanup failure does not skip releasing the parent session", async () => {
49 const f = fixture(), respond = f.page.debugger.respond;
50 f.page.debugger.respond = (method, raw) => {
51 if (method === "Target.detachFromTarget" && (raw as { sessionId: string }).sessionId === "session-child") throw new Error("target already gone");
52 return respond(method, raw);
53 };
54 assert.equal(await runChildFrame(f.page, f.child, "read"), "observed");
55 disposeDebugger(f.page.debugger);
56 assert.equal(f.sessions.has("session-parent"), false);
57 assert.equal(f.page.debugger.commands.filter(c => c.method === "Target.detachFromTarget").length, 2);
58 });
59
60 test("a failed attachment re-resolves only the retained owner, once, before evaluation", async () => {
61 const f = fixture(), respond = f.page.debugger.respond;
62 let failed = false;
63 f.page.debugger.respond = (method, raw) => {
64 if (method === "Target.attachToTarget" && !failed) { failed = true; throw new Error("old target vanished"); }
65 if (method === "DOM.describeNode" && failed) return { node: { frameId: "new-parent", backendNodeId: 11 } };
66 return respond(method, raw);
67 };
68 assert.equal(await runChildFrame(f.page, f.parent, "read"), "observed");
69 const attachments = f.page.debugger.commands.filter(c => c.method === "Target.attachToTarget").map(c => (c.params as { targetId: string }).targetId);
70 assert.deepEqual(attachments, ["parent", "new-parent"]);
71 const descriptions = f.page.debugger.commands.filter(c => c.method === "DOM.describeNode").map(c => c.params);
72 assert.deepEqual(descriptions, [{ objectId: "owner-1" }, { objectId: "owner-1" }]);
73 disposeDebugger(f.page.debugger);
74 assert.deepEqual([...f.sessions], []);
75 });
76
77 test("a changed owner or detached native frame never refreshes into another document", async () => {
78 for (const change of ["owner", "detached"]) {
79 const f = fixture(), respond = f.page.debugger.respond;
80 let failed = false;
81 f.page.debugger.respond = (method, raw) => {
82 if (method === "Target.attachToTarget") { failed = true; if (change === "detached") f.parent.detached = true; throw new Error("old target vanished"); }
83 if (method === "DOM.describeNode" && failed) return { node: { frameId: "replacement", backendNodeId: 999 } };
84 return respond(method, raw);
85 };
86 await assert.rejects(runChildFrame(f.page, f.parent, "write-must-not-run"));
87 assert.equal(f.page.debugger.commands.filter(c => c.method === "Target.attachToTarget").length, 1);
88 assert.equal(f.page.debugger.commands.some(c => c.method === "Runtime.evaluate" && (c.params as { expression: string }).expression === "write-must-not-run"), false);
89 }
90 });
91
92 test("connection release waits for operation object cleanup without detaching adjacent sessions", async t => {
93 t.mock.timers.enable({ apis: ["setTimeout"] });
94 const f = fixture(), respond = f.page.debugger.respond;
95 let entered!: () => void, finish!: () => void;
96 const cleaning = new Promise<void>(resolve => { entered = resolve; });
97 f.page.debugger.respond = (method, raw) => {
98 if (method === "Runtime.releaseObjectGroup") { entered(); return new Promise<void>(resolve => { finish = resolve; }); }
99 return respond(method, raw);
100 };
101 const runtime = new FrameRuntime(f.page);
102 await runtime.run(f.child, "read");
103 const closing = runtime.close();
104 await cleaning;
105 assert.equal(f.sessions.has("session-parent"), true);
106 assert.equal(f.sessions.has("session-child"), true);
107 t.mock.timers.tick(DEBUGGER_IDLE_MS);
108 assert.equal(f.sessions.size, 2, "pending object cleanup keeps the connection alive");
109 // Complete the parent object release, then allow the root release to settle.
110 f.page.debugger.respond = respond;
111 finish(); await closing;
112 await new Promise<void>(resolve => setImmediate(resolve));
113 t.mock.timers.tick(DEBUGGER_IDLE_MS);
114 assert.deepEqual([...f.sessions], []);
115 });
116
117 test("deadline rejects an attach and its late result is cleaned without evaluation", async t => {
118 t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
119 const f = fixture(), respond = f.page.debugger.respond;
120 let entered!: () => void, attach!: (value: unknown) => void;
121 const attaching = new Promise<void>(resolve => { entered = resolve; });
122 f.page.debugger.respond = (method, raw) => {
123 if (method === "Target.attachToTarget") { entered(); return new Promise(resolve => { attach = resolve; }); }
124 return respond(method, raw);
125 };
126 const running = runChildFrame(f.page, f.parent, "must-not-evaluate");
127 const rejected = assert.rejects(running, /deadline exceeded/);
128 await attaching;
129 t.mock.timers.tick(1000);
130 await rejected;
131 f.sessions.add("late"); attach({ sessionId: "late" });
132 await new Promise<void>(resolve => setImmediate(resolve));
133 assert.deepEqual([...f.sessions], []);
134 assert.equal(f.page.debugger.commands.some(c => c.method === "Runtime.evaluate" && (c.params as { expression: string }).expression === "must-not-evaluate"), false);
135 });
136
137 test("request cancellation stops frame preparation and consumes a late attachment", async () => {
138 const f = fixture(), respond = f.page.debugger.respond, controller = new AbortController();
139 let entered!: () => void, attach!: (value: unknown) => void;
140 const attaching = new Promise<void>(resolve => { entered = resolve; });
141 f.page.debugger.respond = (method, raw) => {
142 if (method === "Target.attachToTarget") { entered(); return new Promise(resolve => { attach = resolve; }); }
143 return respond(method, raw);
144 };
145 const running = withFrameOperationSignal(controller.signal, () => runChildFrame(f.page, f.parent, "must-not-evaluate"));
146 const rejected = assert.rejects(running, /cancelled|aborted/i);
147 await attaching; controller.abort(); await rejected;
148 f.sessions.add("late"); attach({ sessionId: "late" });
149 await new Promise<void>(resolve => setImmediate(resolve));
150 assert.deepEqual([...f.sessions], []);
151 assert.equal(f.page.debugger.commands.some(c => c.method === "Runtime.evaluate" && (c.params as { expression: string }).expression === "must-not-evaluate"), false);
152 });
153
154 function uploadFixture() {
155 const f = fixture();
156 const located: LocatedRef = { frame: f.child, binding: { prefix: "f2", frameTreeNodeId: 3, docId: "document" }, isMainFrame: false, ref: "f2e1", snapshotId: "snapshot", tag: "input", type: "file", path: "" };
157 return { ...f, located };
158 }
159
160 test("child upload fences late lookup replies after cancellation, deadline, or frame departure", async t => {
161 for (const change of ["cancel", "deadline", "navigate", "detach"] as const) await t.test(change, async t => {
162 t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
163 const f = uploadFixture(), respond = f.page.debugger.respond, controller = new AbortController();
164 let entered!: () => void, finish!: (value: unknown) => void, dispatched = false;
165 const looking = new Promise<void>(resolve => { entered = resolve; });
166 f.page.debugger.respond = (method, raw) => {
167 if (method === "Runtime.callFunctionOn") { entered(); return new Promise(resolve => { finish = resolve; }); }
168 return respond(method, raw);
169 };
170 const running = withFrameOperationSignal(controller.signal, () => uploadFiles(f.page, f.located, ["/tmp/file"], () => {}, () => { dispatched = true; }));
171 const rejected = assert.rejects(running, /cancelled|aborted|deadline exceeded|frame navigated/i);
172 await looking;
173 if (change === "cancel") controller.abort();
174 if (change === "deadline") t.mock.timers.tick(1000);
175 if (change === "navigate") f.child.url = "https://replacement.test";
176 if (change === "detach") f.child.detached = true;
177 finish({ result: { objectId: "file-input" } });
178 await rejected;
179 assert.equal(dispatched, false);
180 assert.equal(f.page.debugger.commands.some(c => c.method === "DOM.setFileInputFiles"), false);
181 assert.deepEqual([...f.sessions], []);
182 });
183 });
184
185 test("child upload deadline releases its sessions while a dispatched write has no receipt", async t => {
186 t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
187 const f = uploadFixture(), respond = f.page.debugger.respond;
188 let entered!: () => void, finish!: (value: unknown) => void, dispatches = 0;
189 const writing = new Promise<void>(resolve => { entered = resolve; });
190 f.page.debugger.respond = (method, raw) => {
191 if (method === "Runtime.callFunctionOn") return { result: { objectId: "file-input" } };
192 if (method === "DOM.setFileInputFiles") { entered(); return new Promise(resolve => { finish = resolve; }); }
193 return respond(method, raw);
194 };
195 const running = uploadFiles(f.page, f.located, ["/tmp/file"], () => {}, () => { dispatches++; });
196 const outcome = running.then(() => "completed", error => String(error));
197 await writing;
198 t.mock.timers.tick(1000);
199 const beforeReply = await Promise.race([outcome, new Promise<string>(resolve => setImmediate(() => resolve("pending")))]);
200 const sessionsBeforeReply = [...f.sessions];
201 // Always settle the underlying command, including on a failing regression.
202 finish({}); await outcome;
203 assert.match(beforeReply, /deadline exceeded at DOM.setFileInputFiles/);
204 assert.deepEqual(sessionsBeforeReply, [], "owned child sessions are released before the late reply");
205 assert.equal(dispatches, 1, "the caller must preserve an unknown write outcome, never replay it");
206 assert.equal(f.page.debugger.commands.filter(c => c.method === "DOM.setFileInputFiles").length, 1);
207 assert.deepEqual([...f.sessions], []);
208 });
209
209 lines TYPESCRIPT