| 1 | // installDesktopHostStub installs a fake Electron preload host on |
| 2 | // window.reasonixDesktop so tests exercise the real desktopHost() path instead |
| 3 | // of the browser mock. Commands is a plain method table: the stub routes |
| 4 | // host.invoke through it, and mutating the table between calls is observed |
| 5 | // immediately (mirroring how the retired window.go seam behaved). |
| 6 | import type { AppBindings } from "../lib/bridge"; |
| 7 | import { makeMockSessionReaderBindings, publishMockTranscriptEvent, setMockTranscriptMetadata } from "../lib/sessionReaderBridge"; |
| 8 | import type { NativePerformanceActions, ProcessDiagnosticsSnapshot } from "../lib/processDiagnostics"; |
| 9 | import type { DesktopBrowserHost } from "../lib/browserHost"; |
| 10 | import type { BrowserControlApi, BrowserControlState, ChromeImportOutcome, ReasonixDesktopHost, ServiceState } from "../lib/desktopHost"; |
| 11 | |
| 12 | export interface DesktopHostStubOptions { |
| 13 | performance?: NativePerformanceActions; |
| 14 | processDiagnostics?: () => Promise<ProcessDiagnosticsSnapshot | null>; |
| 15 | /** Maps a dropped File to its native path, mirroring the preload. */ |
| 16 | getPathForFile?: (file: File) => string; |
| 17 | /** Records native clipboard writes; clipboardWriteResult gates success. */ |
| 18 | clipboardWrites?: string[]; |
| 19 | clipboardWriteResult?: () => boolean; |
| 20 | /** Value returned by native clipboard reads. */ |
| 21 | clipboardReadText?: string; |
| 22 | /** Records native openExternal calls. */ |
| 23 | externalOpens?: string[]; |
| 24 | /** State the browser-control page starts from. */ |
| 25 | browserControl?: BrowserControlState; |
| 26 | /** Records browser-control calls in order, e.g. "setEnabled:false". */ |
| 27 | browserControlCalls?: string[]; |
| 28 | /** Outcome of the Chrome sign-in-state import. */ |
| 29 | chromeImportOutcome?: ChromeImportOutcome; |
| 30 | /** Overrides native window calls owned by the Electron shell. */ |
| 31 | window?: Partial<ReasonixDesktopHost["native"]["window"]>; |
| 32 | } |
| 33 | |
| 34 | function browserControlStub(options: DesktopHostStubOptions): BrowserControlApi { |
| 35 | let state: BrowserControlState = options.browserControl ?? { |
| 36 | controlEnabled: true, |
| 37 | ignoreCertificateErrors: false, |
| 38 | writable: true, |
| 39 | warning: null, |
| 40 | }; |
| 41 | const record = (call: string) => options.browserControlCalls?.push(call); |
| 42 | return { |
| 43 | get: () => Promise.resolve(state), |
| 44 | setEnabled: (enabled) => { |
| 45 | record(`setEnabled:${enabled}`); |
| 46 | state = { ...state, controlEnabled: enabled }; |
| 47 | return Promise.resolve(state); |
| 48 | }, |
| 49 | setIgnoreCertificateErrors: (enabled) => { |
| 50 | record(`setIgnoreCertificateErrors:${enabled}`); |
| 51 | state = { ...state, ignoreCertificateErrors: enabled }; |
| 52 | return Promise.resolve(state); |
| 53 | }, |
| 54 | clearCache: () => { |
| 55 | record("clearCache"); |
| 56 | return Promise.resolve(); |
| 57 | }, |
| 58 | clearAllData: () => { |
| 59 | record("clearAllData"); |
| 60 | return Promise.resolve(); |
| 61 | }, |
| 62 | importChromeLogin: () => { |
| 63 | record("importChromeLogin"); |
| 64 | return Promise.resolve(options.chromeImportOutcome ?? { ok: true, profile: "Default", cookies: 12, skipped: 0 }); |
| 65 | }, |
| 66 | }; |
| 67 | } |
| 68 | |
| 69 | export interface DesktopHostStub { |
| 70 | /** The live command table; mutate it to change behavior mid-test. */ |
| 71 | readonly commands: Record<string, unknown>; |
| 72 | /** Registered event handlers by name; emit() fans a payload out to them. */ |
| 73 | events: Map<string, Set<(...data: unknown[]) => void>>; |
| 74 | emit(name: string, ...data: unknown[]): void; |
| 75 | emitServiceState(state: ServiceState): void; |
| 76 | /** Swaps the whole command table (mirrors re-injecting the bindings). */ |
| 77 | replaceCommands(next: object): void; |
| 78 | uninstall(): void; |
| 79 | } |
| 80 | |
| 81 | export function installDesktopHostStub(commands: object, options: DesktopHostStubOptions = {}): DesktopHostStub { |
| 82 | const ref = { current: commands as Record<string, unknown> }; |
| 83 | const readerFallback = () => typeof ref.current.SessionOpenForTab === "function" || typeof ref.current.TranscriptSnapshotForTab === "function" ? {} : makeMockSessionReaderBindings(); |
| 84 | const events = new Map<string, Set<(...data: unknown[]) => void>>(); |
| 85 | let serviceState: ServiceState = { phase: "ready", generation: "test-service" }; |
| 86 | const serviceListeners = new Set<(state: ServiceState) => void>(); |
| 87 | const host: ReasonixDesktopHost = { |
| 88 | kind: "electron", |
| 89 | contract: { |
| 90 | protocolVersion: 1, |
| 91 | digest: "sha256:test", |
| 92 | // Live view: tests mutating the command table between calls must be seen. |
| 93 | get commands() { |
| 94 | return [...new Set([...Object.keys(ref.current), ...Object.keys(readerFallback())])] |
| 95 | .filter((name) => typeof ref.current[name] === "function" || name in readerFallback()); |
| 96 | }, |
| 97 | }, |
| 98 | platform: { os: "darwin", arch: "arm64", versions: {} }, |
| 99 | invoke: (method, args) => { |
| 100 | const fn = ref.current[method] ?? (readerFallback() as Record<string, unknown>)[method]; |
| 101 | if (typeof fn !== "function") return Promise.reject(new Error(`unstubbed desktop command ${method}`)); |
| 102 | return Promise.resolve((fn as (...a: unknown[]) => unknown).apply(ref.current, args)).then(result => { |
| 103 | if (method === "ListTabs" && Array.isArray(result)) for (const tab of result) setMockTranscriptMetadata(tab.id, tab); |
| 104 | if (method === "MetaForTab" && result) setMockTranscriptMetadata(String(args[0]), result); |
| 105 | return result; |
| 106 | }); |
| 107 | }, |
| 108 | on: (name, cb) => { |
| 109 | let set = events.get(name); |
| 110 | if (!set) { |
| 111 | set = new Set(); |
| 112 | events.set(name, set); |
| 113 | } |
| 114 | set.add(cb); |
| 115 | return () => set.delete(cb); |
| 116 | }, |
| 117 | native: { |
| 118 | ...options.performance, |
| 119 | ...(options.processDiagnostics ? { processDiagnostics: options.processDiagnostics } : {}), |
| 120 | openExternal: (url) => { |
| 121 | options.externalOpens?.push(url); |
| 122 | return Promise.resolve(); |
| 123 | }, |
| 124 | clipboard: { |
| 125 | writeText: (text) => { |
| 126 | if (options.clipboardWriteResult && !options.clipboardWriteResult()) return Promise.resolve(false); |
| 127 | options.clipboardWrites?.push(text); |
| 128 | return Promise.resolve(true); |
| 129 | }, |
| 130 | readText: () => Promise.resolve(options.clipboardReadText ?? ""), |
| 131 | }, |
| 132 | window: { |
| 133 | setTheme: () => {}, |
| 134 | setBackgroundColour: () => {}, |
| 135 | getBounds: () => Promise.resolve({ x: 0, y: 0, width: 1280, height: 800, maximised: false }), |
| 136 | isMaximised: () => Promise.resolve(false), |
| 137 | minimise: async () => {}, |
| 138 | toggleMaximise: async () => {}, |
| 139 | close: async () => {}, |
| 140 | // The Electron shell owns zoom natively; tests drive it through the |
| 141 | // same command table the bridge path uses, so tables without zoom |
| 142 | // commands keep the neutral default. |
| 143 | getAppZoom: async () => { |
| 144 | const fn = ref.current.GetDesktopZoomFactor as (() => Promise<number>) | undefined; |
| 145 | return typeof fn === "function" ? await fn() : 1; |
| 146 | }, |
| 147 | setAppZoom: async (factor: number) => { |
| 148 | const fn = ref.current.SetDesktopZoomFactor as ((factor: number) => Promise<number>) | undefined; |
| 149 | if (typeof fn === "function") await fn(factor); |
| 150 | return factor; |
| 151 | }, |
| 152 | resetAppZoom: async () => 1, |
| 153 | ...options.window, |
| 154 | }, |
| 155 | graphics: { |
| 156 | get: () => Promise.resolve({ hardwareAcceleration: true, startupEnabled: true, override: "none" as const, restartRequired: false, writable: true, warning: null }), |
| 157 | setHardwareAcceleration: async (enabled: boolean) => ({ hardwareAcceleration: enabled, startupEnabled: true, override: "none" as const, restartRequired: enabled !== true, writable: true, warning: null }), |
| 158 | }, |
| 159 | getPathForFile: options.getPathForFile ?? (() => ""), |
| 160 | browserControl: browserControlStub(options), |
| 161 | onServiceState: (cb) => { |
| 162 | serviceListeners.add(cb); |
| 163 | cb(serviceState); |
| 164 | return () => { serviceListeners.delete(cb); }; |
| 165 | }, |
| 166 | recordRendererDiagnostic: async () => {}, |
| 167 | }, |
| 168 | browser: undefined as unknown as DesktopBrowserHost, |
| 169 | }; |
| 170 | const previous = window.reasonixDesktop; |
| 171 | window.reasonixDesktop = host; |
| 172 | return { |
| 173 | get commands() { |
| 174 | return ref.current; |
| 175 | }, |
| 176 | events, |
| 177 | emitServiceState(state) { |
| 178 | serviceState = state; |
| 179 | for (const cb of [...serviceListeners]) cb(state); |
| 180 | }, |
| 181 | emit(name, ...data) { |
| 182 | if (name === "runtime:rebuilt" && data[0] && data[1]) setMockTranscriptMetadata(String(data[0]), { runtime: { epoch: String(data[1]) } }); |
| 183 | if (name === "agent:event" && data[0]) publishMockTranscriptEvent(data[0] as import("../lib/types").WireEvent); |
| 184 | const remote = /^remote-tab:(.+):event$/.exec(name); |
| 185 | if (remote && data[0]) { |
| 186 | const event = data[0] as import("../lib/types").WireEvent & { reasoning?: string }; |
| 187 | publishMockTranscriptEvent({ ...event, tabId: remote[1], text: event.kind === "reasoning" ? event.reasoning ?? event.text : event.text }); |
| 188 | } |
| 189 | for (const cb of [...(events.get(name) ?? [])]) cb(...data); |
| 190 | }, |
| 191 | replaceCommands(next) { |
| 192 | ref.current = next as Record<string, unknown>; |
| 193 | }, |
| 194 | uninstall() { |
| 195 | window.reasonixDesktop = previous; |
| 196 | }, |
| 197 | }; |
| 198 | } |
| 199 | |
| 200 | // AppStub casts a partial method table for installDesktopHostStub; keep the |
| 201 | // cast local to the tests. |
| 202 | export type AppStubTable = Partial<AppBindings> & Record<string, unknown>; |
| 203 | |
| 204 | // dispatchNativeFileDrop drives the Electron drop path (document-level |
| 205 | // dragover/drop listeners) the way Chromium does: a DOM event whose |
| 206 | // dataTransfer carries File objects the preload then maps to native paths. |
| 207 | export function dispatchNativeFileDrop(target: Element, files: File[]): void { |
| 208 | // Chromium-style items: webkitGetAsEntry returns a file entry, so the |
| 209 | // composer treats the drop as native (paths via the preload) instead of a |
| 210 | // pathless browser file drop. |
| 211 | const items = files.map((file) => ({ |
| 212 | kind: "file", |
| 213 | type: file.type, |
| 214 | getAsFile: () => file, |
| 215 | webkitGetAsEntry: () => ({ isFile: true, isDirectory: false, name: file.name, fullPath: "/" + file.name }), |
| 216 | })); |
| 217 | const EventCtor = target.ownerDocument?.defaultView?.Event ?? Event; |
| 218 | for (const type of ["dragover", "drop"]) { |
| 219 | const event = new EventCtor(type, { bubbles: true, cancelable: true }); |
| 220 | Object.defineProperty(event, "dataTransfer", { |
| 221 | value: { files, items, types: ["Files"], dropEffect: "none" }, |
| 222 | }); |
| 223 | target.dispatchEvent(event); |
| 224 | } |
| 225 | } |
| 226 |