| 1 | import type { ActResult } from "./actions.js"; |
| 2 | import type { GuestDebugger, GuestPage } from "./guestView.js"; |
| 3 | import type { LocatedRef } from "./refResolver.js"; |
| 4 | import { REGISTRY_KEY } from "./snapshot.js"; |
| 5 | |
| 6 | const OBJECT_GROUP = "reasonix-upload"; |
| 7 | |
| 8 | // The executable source is fixed. Snapshot metadata crosses the CDP boundary |
| 9 | // only as call arguments, never as JavaScript source text. |
| 10 | const FIND_UPLOAD_NODE = `function (key, docId, snapshotId, ref) { |
| 11 | const registry = globalThis[key]; |
| 12 | if (!registry || registry.docId !== docId || registry.snapshotId !== snapshotId) return null; |
| 13 | const element = registry.refs.get(ref); |
| 14 | return element && element.isConnected && element.tagName === 'INPUT' && element.type === 'file' ? element : null; |
| 15 | }`; |
| 16 | |
| 17 | interface ExecutionContext { |
| 18 | id: number; |
| 19 | auxData?: { isDefault?: boolean; frameId?: string }; |
| 20 | } |
| 21 | |
| 22 | function objectIdOf(result: unknown): string | null { |
| 23 | const objectId = (result as { result?: { objectId?: unknown } } | null)?.result?.objectId; |
| 24 | return typeof objectId === "string" ? objectId : null; |
| 25 | } |
| 26 | |
| 27 | // Runtime.enable replays executionContextCreated for every live context |
| 28 | // before its own reply, so a listener registered around it sees them all. |
| 29 | async function executionContexts(dbg: GuestDebugger): Promise<ExecutionContext[]> { |
| 30 | const contexts: ExecutionContext[] = []; |
| 31 | const listener = (_event: unknown, method: string, params: unknown) => { |
| 32 | if (method !== "Runtime.executionContextCreated") return; |
| 33 | const context = (params as { context?: ExecutionContext } | null)?.context; |
| 34 | if (context && typeof context.id === "number") contexts.push(context); |
| 35 | }; |
| 36 | dbg.on("message", listener); |
| 37 | try { |
| 38 | await dbg.sendCommand("Runtime.enable"); |
| 39 | } finally { |
| 40 | dbg.removeListener("message", listener); |
| 41 | } |
| 42 | return contexts; |
| 43 | } |
| 44 | |
| 45 | // Find the original registry node in its execution context, including the |
| 46 | // main frame's isolated world. A CSS path would silently select a replacement |
| 47 | // input after a rerender or a navigation. |
| 48 | async function findObjectId(dbg: GuestDebugger, located: LocatedRef): Promise<string | null> { |
| 49 | const args = [REGISTRY_KEY, located.binding.docId, located.snapshotId, located.ref].map((value) => ({ value })); |
| 50 | try { |
| 51 | for (const context of await executionContexts(dbg)) { |
| 52 | if (located.isMainFrame && context.auxData?.isDefault) continue; |
| 53 | try { |
| 54 | const objectId = objectIdOf(await dbg.sendCommand("Runtime.callFunctionOn", { functionDeclaration: FIND_UPLOAD_NODE, executionContextId: context.id, arguments: args, objectGroup: OBJECT_GROUP })); |
| 55 | if (objectId) return objectId; |
| 56 | } catch { |
| 57 | // A context that vanished mid-walk is simply not the one we want. |
| 58 | } |
| 59 | } |
| 60 | } finally { |
| 61 | await dbg.sendCommand("Runtime.disable").catch(() => undefined); |
| 62 | } |
| 63 | return null; |
| 64 | } |
| 65 | |
| 66 | export async function uploadFiles(page: GuestPage, located: LocatedRef, files: string[], verify: () => void, dispatch: () => void): Promise<ActResult> { |
| 67 | if (located.tag !== "input" || located.type !== "file") return { executed: false, reason: "element is not a file input" }; |
| 68 | const dbg = page.debugger; |
| 69 | const attached = dbg.isAttached(); |
| 70 | if (!attached) dbg.attach("1.3"); |
| 71 | try { |
| 72 | const objectId = await findObjectId(dbg, located); |
| 73 | if (!objectId) return { executed: false, reason: "file input could not be located in the page" }; |
| 74 | verify(); |
| 75 | dispatch(); |
| 76 | await dbg.sendCommand("DOM.setFileInputFiles", { objectId, files }); |
| 77 | await dbg.sendCommand("Runtime.releaseObjectGroup", { objectGroup: OBJECT_GROUP }).catch(() => undefined); |
| 78 | return { executed: true }; |
| 79 | } finally { |
| 80 | if (!attached && dbg.isAttached()) dbg.detach(); |
| 81 | } |
| 82 | } |
| 83 |