| 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 |