1import { assert, describe, it } from "@effect/vitest";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import type * as Electron from "electron";
8import { beforeEach, vi } from "vite-plus/test";
9
10const { buildFromTemplateMock, createFromNamedImageMock, setApplicationMenuMock } = vi.hoisted(
11 () => ({
12 buildFromTemplateMock: vi.fn(),
13 createFromNamedImageMock: vi.fn(),
14 setApplicationMenuMock: vi.fn(),
15 }),
16);
17
18vi.mock("electron", () => ({
19 Menu: {
20 buildFromTemplate: buildFromTemplateMock,
21 setApplicationMenu: setApplicationMenuMock,
22 },
23 nativeImage: {
24 createFromNamedImage: createFromNamedImageMock,
25 },
26}));
27
28import * as ElectronMenu from "./ElectronMenu.ts";
29
30const layerTest = ElectronMenu.layer.pipe(
31 Layer.provide(Layer.succeed(HostProcess.Platform, "linux")),
32);
33
34const makeWindow = (zoomFactor = 1): Electron.BrowserWindow =>
35 ({
36 id: 7,
37 webContents: { getZoomFactor: () => zoomFactor },
38 }) as unknown as Electron.BrowserWindow;
39
40describe("ElectronMenu", () => {
41 beforeEach(() => {
42 buildFromTemplateMock.mockReset();
43 createFromNamedImageMock.mockReset();
44 setApplicationMenuMock.mockReset();
45 });
46
47 it.effect("returns none without building a menu when there are no valid items", () =>
48 Effect.gen(function* () {
49 const electronMenu = yield* ElectronMenu.ElectronMenu;
50 const selectedItemId = yield* electronMenu.showContextMenu({
51 window: {} as Electron.BrowserWindow,
52 items: [],
53 position: Option.none(),
54 });
55
56 assert.isTrue(Option.isNone(selectedItemId));
57 assert.equal(buildFromTemplateMock.mock.calls.length, 0);
58 }).pipe(Effect.provide(layerTest)),
59 );
60
61 it.effect("resolves with the clicked leaf item id", () =>
62 Effect.gen(function* () {
63 buildFromTemplateMock.mockImplementation(
64 (template: Electron.MenuItemConstructorOptions[]) => ({
65 popup: () => {
66 const firstItem = template[0];
67 assert.isDefined(firstItem);
68 const click = firstItem.click;
69 if (!click) {
70 throw new Error("Expected menu item to have a click handler.");
71 }
72 click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent);
73 },
74 }),
75 );
76
77 const electronMenu = yield* ElectronMenu.ElectronMenu;
78 const selectedItemId = yield* electronMenu.showContextMenu({
79 window: makeWindow(),
80 items: [{ id: "copy", label: "Copy" }],
81 position: Option.none(),
82 });
83
84 assert.equal(Option.getOrNull(selectedItemId), "copy");
85 }).pipe(Effect.provide(layerTest)),
86 );
87
88 it.effect("resolves with none when the menu closes without a click", () =>
89 Effect.gen(function* () {
90 let popupOptions: Electron.PopupOptions | undefined;
91 buildFromTemplateMock.mockImplementation(() => ({
92 popup: (options: Electron.PopupOptions) => {
93 popupOptions = options;
94 options.callback?.();
95 },
96 }));
97
98 const electronMenu = yield* ElectronMenu.ElectronMenu;
99 const selectedItemId = yield* electronMenu.showContextMenu({
100 window: makeWindow(2),
101 items: [
102 { id: "copy", label: "Copy" },
103 { id: "delete", label: "Delete", destructive: true, separatorBefore: true },
104 ],
105 position: Option.some({ x: 10.8, y: 20.2 }),
106 });
107
108 assert.isTrue(Option.isNone(selectedItemId));
109 assert.equal(popupOptions?.x, 21);
110 assert.equal(popupOptions?.y, 40);
111 assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], {
112 label: "Copy",
113 enabled: true,
114 click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,
115 });
116 assert.deepEqual(
117 buildFromTemplateMock.mock.calls[0]?.[0].map(
118 (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label,
119 ),
120 ["Copy", "separator", "Delete"],
121 );
122 }).pipe(Effect.provide(layerTest)),
123 );
124
125 it.effect("keeps a preceding non-destructive action in the destructive section", () =>
126 Effect.gen(function* () {
127 buildFromTemplateMock.mockImplementation(() => ({
128 popup: (options: Electron.PopupOptions) => options.callback?.(),
129 }));
130
131 const electronMenu = yield* ElectronMenu.ElectronMenu;
132 yield* electronMenu.showContextMenu({
133 window: makeWindow(),
134 items: [
135 { id: "copy", label: "Copy" },
136 { id: "archive", label: "Archive", separatorBefore: true },
137 { id: "delete", label: "Delete", destructive: true },
138 ],
139 position: Option.none(),
140 });
141
142 assert.deepEqual(
143 buildFromTemplateMock.mock.calls[0]?.[0].map(
144 (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label,
145 ),
146 ["Copy", "separator", "Archive", "Delete"],
147 );
148 }).pipe(Effect.provide(layerTest)),
149 );
150
151 it.effect("defers popupTemplate side effects until the returned Effect runs", () =>
152 Effect.gen(function* () {
153 const popupMock = vi.fn();
154 buildFromTemplateMock.mockImplementation(() => ({ popup: popupMock }));
155
156 const electronMenu = yield* ElectronMenu.ElectronMenu;
157 const frame = { routingId: 7 } as Electron.WebFrameMain;
158 const popup = electronMenu.popupTemplate({
159 window: {} as Electron.BrowserWindow,
160 template: [{ label: "Copy" }],
161 frame,
162 });
163
164 assert.equal(buildFromTemplateMock.mock.calls.length, 0);
165 assert.equal(popupMock.mock.calls.length, 0);
166
167 yield* popup;
168
169 assert.equal(buildFromTemplateMock.mock.calls.length, 1);
170 assert.equal(popupMock.mock.calls.length, 1);
171 assert.strictEqual(popupMock.mock.calls[0]?.[0].frame, frame);
172 }).pipe(Effect.provide(layerTest)),
173 );
174
175 it.effect("preserves application-menu failures as structured defects", () =>
176 Effect.gen(function* () {
177 const cause = new Error("application menu build failed");
178 buildFromTemplateMock.mockImplementationOnce(() => {
179 throw cause;
180 });
181
182 const electronMenu = yield* ElectronMenu.ElectronMenu;
183 const exit = yield* Effect.exit(
184 electronMenu.setApplicationMenu([{ label: "File" }, { label: "Edit" }]),
185 );
186
187 assert.equal(exit._tag, "Failure");
188 if (exit._tag === "Failure") {
189 const error = Cause.squash(exit.cause);
190 assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError);
191 assert.equal(error.operation, "set-application-menu");
192 assert.equal(error.platform, "linux");
193 assert.isNull(error.windowId);
194 assert.equal(error.itemCount, 2);
195 assert.strictEqual(error.cause, cause);
196 assert.notInclude(error.message, cause.message);
197 }
198 }).pipe(Effect.provide(layerTest)),
199 );
200
201 it.effect("preserves popup-template failures with window context", () =>
202 Effect.gen(function* () {
203 const cause = new Error("popup failed");
204 buildFromTemplateMock.mockReturnValueOnce({
205 popup: () => {
206 throw cause;
207 },
208 });
209
210 const electronMenu = yield* ElectronMenu.ElectronMenu;
211 const exit = yield* Effect.exit(
212 electronMenu.popupTemplate({
213 window: { id: 41 } as Electron.BrowserWindow,
214 template: [{ label: "Copy" }],
215 }),
216 );
217
218 assert.equal(exit._tag, "Failure");
219 if (exit._tag === "Failure") {
220 const error = Cause.squash(exit.cause);
221 assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError);
222 assert.equal(error.operation, "popup-template");
223 assert.equal(error.windowId, 41);
224 assert.equal(error.itemCount, 1);
225 assert.strictEqual(error.cause, cause);
226 }
227 }).pipe(Effect.provide(layerTest)),
228 );
229
230 it.effect("preserves context-menu failures with normalized item context", () =>
231 Effect.gen(function* () {
232 const cause = new Error("context menu build failed");
233 buildFromTemplateMock.mockImplementationOnce(() => {
234 throw cause;
235 });
236
237 const electronMenu = yield* ElectronMenu.ElectronMenu;
238 const exit = yield* Effect.exit(
239 electronMenu.showContextMenu({
240 window: { id: 42 } as Electron.BrowserWindow,
241 items: [{ id: "copy", label: "Copy" }],
242 position: Option.none(),
243 }),
244 );
245
246 assert.equal(exit._tag, "Failure");
247 if (exit._tag === "Failure") {
248 const error = Cause.squash(exit.cause);
249 assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError);
250 assert.equal(error.operation, "show-context-menu");
251 assert.equal(error.windowId, 42);
252 assert.equal(error.itemCount, 1);
253 assert.strictEqual(error.cause, cause);
254 }
255 }).pipe(Effect.provide(layerTest)),
256 );
257});