1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6
7import * as Electron from "electron";
8import { vi } from "vite-plus/test";
9
10vi.mock("electron", () => ({
11 webContents: { getAllWebContents: vi.fn(() => []) },
12}));
13
14import * as ElectronApp from "../electron/ElectronApp.ts";
15import * as ElectronDialog from "../electron/ElectronDialog.ts";
16import * as ElectronMenu from "../electron/ElectronMenu.ts";
17import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts";
18import * as DesktopConfig from "../app/DesktopConfig.ts";
19import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
20import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
21import * as DesktopWindow from "./DesktopWindow.ts";
22
23const environmentInput = {
24 dirname: "/repo/apps/desktop/dist-electron",
25 homeDirectory: "/Users/alice",
26 platform: "linux",
27 processArch: "arm64",
28 appVersion: "1.2.3",
29 appPath: "/repo",
30 isPackaged: false,
31 resourcesPath: "/repo/resources",
32 runningUnderArm64Translation: false,
33} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
34
35const layerElectronApp = Layer.succeed(ElectronApp.ElectronApp, {
36 metadata: Effect.die("unexpected metadata read"),
37 name: Effect.succeed("T3 Code"),
38 systemLocale: Effect.succeed("en-US"),
39 preferredSystemLanguages: Effect.succeed(["en-US"]),
40 whenReady: Effect.void,
41 requestSingleInstanceLock: Effect.succeed(true),
42 quit: Effect.void,
43 exit: () => Effect.void,
44 relaunch: () => Effect.void,
45 setPath: () => Effect.void,
46 setName: () => Effect.void,
47 setAboutPanelOptions: () => Effect.void,
48 setAppUserModelId: () => Effect.void,
49 getAppMetrics: Effect.succeed([]),
50 setAsDefaultProtocolClient: () => Effect.succeed(true),
51 setDesktopName: () => Effect.void,
52 setDockIcon: () => Effect.void,
53 appendCommandLineSwitch: () => Effect.void,
54 onBeforeQuitForUpdate: () => Effect.void,
55 removeCommandLineSwitch: () => Effect.void,
56 on: () => Effect.void,
57} satisfies ElectronApp.ElectronApp["Service"]);
58
59const layerElectronDialog = Layer.succeed(ElectronDialog.ElectronDialog, {
60 pickFolder: () => Effect.succeedNone,
61 pickFiles: () => Effect.succeed([]),
62 showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }),
63 showErrorBox: () => Effect.void,
64} satisfies ElectronDialog.ElectronDialog["Service"]);
65
66const layerDesktopUpdates = Layer.succeed(DesktopUpdates.DesktopUpdates, {
67 getState: Effect.die("unexpected getState"),
68 isActionActive: Effect.succeed(false),
69 isInstallActive: Effect.succeed(false),
70 subscribe: Effect.die("unexpected subscribe"),
71 emitState: Effect.void,
72 disabledReason: Effect.succeedNone,
73 configure: Effect.void,
74 setChannel: () => Effect.die("unexpected setChannel"),
75 check: () => Effect.die("unexpected check"),
76 download: Effect.die("unexpected download"),
77 install: Effect.die("unexpected install"),
78 installPrepared: () => Effect.die("unexpected installPrepared"),
79} satisfies DesktopUpdates.DesktopUpdates["Service"]);
80
81const layerDesktopWindow = (selectedAction: Deferred.Deferred<string>) =>
82 Layer.succeed(DesktopWindow.DesktopWindow, {
83 createMain: Effect.die("unexpected createMain"),
84 ensureMain: Effect.die("unexpected ensureMain"),
85 revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"),
86 activate: Effect.void,
87 createMainIfBackendReady: Effect.void,
88 showConnectingSplash: Effect.void,
89 handleBackendReady: () => Effect.void,
90 handleBackendNotReady: Effect.void,
91 flushMainWindowBounds: Effect.void,
92 prepareCaptureReveal: Effect.void,
93 dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid),
94 dispatchSnapShotEvent: () => Effect.void,
95 zoomMain: (direction) =>
96 Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid),
97 runMainContentsCommand: (command) =>
98 Deferred.succeed(selectedAction, `main-${command}`).pipe(Effect.asVoid),
99 syncAppearance: Effect.void,
100 } satisfies DesktopWindow.DesktopWindow["Service"]);
101
102const layerElectronMenu = (
103 applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>,
104) =>
105 Layer.succeed(ElectronMenu.ElectronMenu, {
106 setApplicationMenu: (template) =>
107 Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid),
108 popupTemplate: () => Effect.void,
109 showContextMenu: () => Effect.succeedNone,
110 } satisfies ElectronMenu.ElectronMenu["Service"]);
111
112const configureMenu = (
113 selectedAction: Deferred.Deferred<string>,
114 applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>,
115 environment: Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> = {},
116) =>
117 Effect.gen(function* () {
118 const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
119 yield* menu.configure;
120 }).pipe(
121 Effect.provide(
122 DesktopApplicationMenu.layer.pipe(
123 Layer.provideMerge(layerElectronMenu(applicationMenuTemplate)),
124 Layer.provideMerge(layerDesktopWindow(selectedAction)),
125 Layer.provideMerge(layerDesktopUpdates),
126 Layer.provideMerge(layerElectronDialog),
127 Layer.provideMerge(layerElectronApp),
128 Layer.provideMerge(
129 DesktopEnvironment.layer({ ...environmentInput, ...environment }).pipe(
130 Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))),
131 ),
132 ),
133 ),
134 ),
135 );
136
137describe("DesktopApplicationMenu", () => {
138 it.effect("leaves the accelerator to Chromium instead of injecting a paste", () =>
139 Effect.gen(function* () {
140 const selectedAction = yield* Deferred.make<string>();
141 const applicationMenuTemplate =
142 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
143
144 yield* configureMenu(selectedAction, applicationMenuTemplate);
145
146 const template = yield* Deferred.await(applicationMenuTemplate);
147 const editMenu = template.find((item) => item.label === "Edit");
148 if (!Array.isArray(editMenu?.submenu)) {
149 throw new Error("Expected Edit menu submenu to be an array.");
150 }
151 const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text");
152 if (typeof pasteAsTextItem?.click !== "function") {
153 throw new Error("Expected Paste as Text menu item to have a click handler.");
154 }
155
156 pasteAsTextItem.click(
157 {} as Electron.MenuItem,
158 {} as Electron.BrowserWindow,
159 {
160 triggeredByAccelerator: true,
161 } as unknown as KeyboardEvent,
162 );
163 assert.isFalse(yield* Deferred.isDone(selectedAction));
164 }),
165 );
166
167 it.effect("registers macOS command-plus once and routes it through main-window zoom", () =>
168 Effect.gen(function* () {
169 const selectedAction = yield* Deferred.make<string>();
170 const applicationMenuTemplate =
171 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
172
173 yield* configureMenu(selectedAction, applicationMenuTemplate, { platform: "darwin" });
174
175 const template = yield* Deferred.await(applicationMenuTemplate);
176 const viewMenu = template.find((item) => item.label === "View");
177 assert.isDefined(viewMenu);
178 if (!Array.isArray(viewMenu.submenu)) {
179 throw new Error("Expected View menu submenu to be an array.");
180 }
181
182 assert.isUndefined(
183 viewMenu.submenu.find((item) =>
184 ["zoom", "reload", "devtools"].some((role) => item.role?.toLowerCase().includes(role)),
185 ),
186 );
187
188 const shiftedEqual = viewMenu.submenu.filter(
189 (item) => item.accelerator === "CmdOrCtrl+Shift+=",
190 );
191 assert.lengthOf(shiftedEqual, 1);
192 assert.lengthOf(
193 viewMenu.submenu.filter((item) => item.accelerator === "CmdOrCtrl+numadd"),
194 1,
195 );
196 const zoomIn = shiftedEqual[0];
197 assert.isDefined(zoomIn);
198 assert.isFalse(zoomIn.visible);
199 if (typeof zoomIn.click !== "function") {
200 throw new Error("Expected Zoom In menu item to have a click handler.");
201 }
202
203 zoomIn.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent);
204 assert.equal(yield* Deferred.await(selectedAction), "zoom-in");
205 }),
206 );
207
208 it.effect("never binds Cmd/Ctrl+W to closing the window", () =>
209 Effect.gen(function* () {
210 for (const platform of ["darwin", "linux", "win32"] as const) {
211 const applicationMenuTemplate =
212 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
213 yield* configureMenu(yield* Deferred.make<string>(), applicationMenuTemplate, {
214 platform,
215 });
216
217 const items: Electron.MenuItemConstructorOptions[] = [];
218 const collect = (template: readonly Electron.MenuItemConstructorOptions[]) => {
219 for (const item of template) {
220 items.push(item);
221 if (Array.isArray(item.submenu)) collect(item.submenu);
222 }
223 };
224 collect(yield* Deferred.await(applicationMenuTemplate));
225
226 assert.isUndefined(
227 items.find((item) => item.role === "close"),
228 platform,
229 );
230 // Electron's default windowMenu submenu includes a close role.
231 assert.isUndefined(
232 items.find((item) => item.role === "windowMenu" && item.submenu === undefined),
233 platform,
234 );
235 const closeTools = items.find((item) => item.accelerator === "CmdOrCtrl+W");
236 assert.equal(closeTools?.label, "Close Developer Tools", platform);
237 assert.isFunction(closeTools?.click);
238 const appContents = {
239 isDestroyed: () => false,
240 isDevToolsFocused: () => false,
241 closeDevTools: vi.fn(),
242 };
243 const inspectedContents = {
244 ...appContents,
245 isDevToolsFocused: () => true,
246 closeDevTools: vi.fn(),
247 };
248 using allContents = vi
249 .spyOn(Electron.webContents, "getAllWebContents")
250 .mockReturnValue([
251 appContents as unknown as Electron.WebContents,
252 inspectedContents as unknown as Electron.WebContents,
253 ]);
254 closeTools?.click?.({} as Electron.MenuItem, undefined, {} as KeyboardEvent);
255 assert.equal(inspectedContents.closeDevTools.mock.calls.length, 1);
256 assert.equal(appContents.closeDevTools.mock.calls.length, 0);
257
258 allContents.mockReturnValue([appContents as unknown as Electron.WebContents]);
259 closeTools?.click?.({} as Electron.MenuItem, undefined, {} as KeyboardEvent);
260 assert.equal(appContents.closeDevTools.mock.calls.length, 0);
261 }
262 }),
263 );
264
265 it.effect("reloads the main window even while a browser page has focus", () =>
266 Effect.gen(function* () {
267 const selectedAction = yield* Deferred.make<string>();
268 const applicationMenuTemplate =
269 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
270
271 yield* configureMenu(selectedAction, applicationMenuTemplate);
272
273 const template = yield* Deferred.await(applicationMenuTemplate);
274 const viewMenu = template.find((item) => item.label === "View");
275 if (!Array.isArray(viewMenu?.submenu)) {
276 throw new Error("Expected View menu submenu to be an array.");
277 }
278 const reload = viewMenu.submenu.find((item) => item.label === "Reload");
279 assert.equal(reload?.accelerator, "CmdOrCtrl+R");
280 if (typeof reload?.click !== "function") {
281 throw new Error("Expected Reload menu item to have a click handler.");
282 }
283
284 reload.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent);
285 assert.equal(yield* Deferred.await(selectedAction), "main-reload");
286 }),
287 );
288});