返回 DeepSeek-Reasonix
desktopHostStub.ts
根目录 / desktop / frontend / src / __tests__ / desktopHostStub.ts
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
226 lines TYPESCRIPT