1import {
2 DEFAULT_CLIENT_SETTINGS,
3 type ClientSettingsPatch,
4 type DesktopSnapShotState,
5} from "@t3tools/contracts";
6import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
7import { reactHookHarness as hooks } from "../../test/reactHookHarness";
8import { visitElements } from "../../test/reactElementTree";
9
10const effects = vi.hoisted(() => [] as (() => void)[]);
11vi.mock("react", async (original) => {
12 const actual = await original<typeof import("react")>();
13 const { reactHookHarness } = await import("../../test/reactHookHarness");
14 return {
15 ...actual,
16 useCallback: reactHookHarness.useCallback,
17 useMemo: reactHookHarness.useMemo,
18 useRef: reactHookHarness.useRef,
19 useState: reactHookHarness.useState,
20 useEffect: (effect: () => void) => effects.push(effect),
21 };
22});
23vi.mock("react/compiler-runtime", async () => {
24 const { reactHookHarness } = await import("../../test/reactHookHarness");
25 return { c: reactHookHarness.useMemoCache };
26});
27vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] }));
28vi.mock("../../state/environments", () => ({
29 usePrimaryEnvironmentId: () => null,
30 useEnvironments: () => [],
31}));
32vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} }));
33vi.mock("./SettingsScopeContext", () => ({ useOptionalSettingsScope: () => null }));
34vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null }));
35const bridge = vi.hoisted(() => ({
36 getSnapShotState: vi.fn<() => Promise<DesktopSnapShotState>>(),
37 setSnapShotShortcutSuppressed: vi.fn(),
38 checkSnapShotShortcut: vi.fn(),
39 previewSnapShotConfig: vi.fn(),
40 applySnapShotConfig: vi.fn(),
41 setupSnapShot: vi.fn(),
42 requestSnapShotPermissions: vi.fn().mockResolvedValue(undefined),
43 onMenuAction: vi.fn(),
44 onSnapShotEvent: vi.fn(() => () => undefined),
45}));
46const toastManager = vi.hoisted(() => ({ add: vi.fn() }));
47vi.mock("../ui/toast", () => ({ toastManager }));
48vi.mock("../../lib/desktopSnapShot", () => ({ getDesktopSnapShotBridge: () => bridge }));
49const settingsStore = vi.hoisted(() => ({
50 current: {} as typeof DEFAULT_CLIENT_SETTINGS,
51 update: vi.fn<(patch: ClientSettingsPatch) => Promise<void>>(),
52}));
53vi.mock("../../hooks/useSettings", () => ({
54 useClientSettings: () => settingsStore.current,
55 useUpdateClientSettings: () => settingsStore.update,
56}));
57
58import { SnapShotSettings } from "./SnapShotSettings";
59import { SnapShotSetupDialog } from "./SnapShotSetupDialog";
60import { CaptureShortcutConfig } from "./CaptureShortcutConfig";
61import { SnapShotShortcutKeys } from "../desktop/SnapShotShortcutKeys";
62
63let state: DesktopSnapShotState;
64function render() {
65 hooks.beginRender();
66 return SnapShotSettings();
67}
68function renderWithEffects() {
69 effects.length = 0;
70 const tree = render();
71 for (const effect of effects.splice(0)) effect();
72 return tree;
73}
74function wizard(tree: ReturnType<typeof render>) {
75 return visitElements(tree, (element) => element.type === SnapShotSetupDialog);
76}
77function button(tree: ReturnType<typeof render>, label: string) {
78 const node = visitElements(
79 tree,
80 (element) => element.props.children === label && typeof element.props.onClick === "function",
81 );
82 if (!node) throw new Error(`Missing button: ${label}`);
83 return node.props as { onClick: () => void };
84}
85async function finish(promise: Promise<unknown>) {
86 await promise;
87 await Promise.resolve();
88}
89async function mount() {
90 render();
91 for (const effect of effects.splice(0)) effect();
92 await finish(bridge.getSnapShotState.mock.results[0]!.value);
93 return render();
94}
95beforeEach(() => {
96 hooks.reset();
97 effects.length = 0;
98 vi.clearAllMocks();
99 vi.stubGlobal("navigator", { platform: "Linux" });
100 vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() });
101 settingsStore.current = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true };
102 state = {
103 mode: "portal",
104 linuxBackend: "hyprland",
105 shortcut: DEFAULT_CLIENT_SETTINGS.snapShotShortcut,
106 shortcutRegistered: false,
107 shortcutActionRegistered: true,
108 shortcutMessage: null,
109 message: null,
110 hyprlandHelper: { status: "ready", message: "Ready" },
111 };
112 bridge.getSnapShotState.mockImplementation(async () => state);
113 bridge.setSnapShotShortcutSuppressed.mockResolvedValue(undefined);
114 bridge.checkSnapShotShortcut.mockResolvedValue({ available: true, message: null });
115 bridge.setupSnapShot.mockReset().mockResolvedValue(undefined);
116 settingsStore.update.mockImplementation(async (patch) => {
117 settingsStore.current = { ...settingsStore.current, ...patch };
118 state = { ...state, shortcut: settingsStore.current.snapShotShortcut };
119 });
120});
121afterEach(() => vi.unstubAllGlobals());
122
123it.each(["niri", "hyprland"] as const)(
124 "reopens %s setup at Shortcut without reading config or expanding Settings",
125 async (desktop) => {
126 state = { ...state, linuxBackend: desktop };
127 const tree = await mount();
128 expect(wizard(tree)).toBeNull();
129 button(tree, "Change shortcut").onClick();
130 await finish(bridge.getSnapShotState.mock.results[1]!.value);
131 const opened = render();
132 expect(visitElements(opened, (element) => element.type === CaptureShortcutConfig)).toBeNull();
133 const dialog = wizard(opened);
134 expect(dialog?.props.initialStep).toBe("shortcut");
135 expect(bridge.previewSnapShotConfig).not.toHaveBeenCalled();
136 expect(bridge.applySnapShotConfig).not.toHaveBeenCalled();
137 await (dialog!.props.onClose as (completed: boolean) => Promise<void>)(false);
138 expect(wizard(render())).toBeNull();
139 expect(settingsStore.update).not.toHaveBeenCalled();
140 expect(settingsStore.current.snapShotEnabled).toBe(true);
141 },
142);
143it("returns to Access if the Hyprland helper needs attention before changing keys", async () => {
144 const tree = await mount();
145 state = { ...state, hyprlandHelper: { status: "not-installed", message: "Install helper" } };
146 button(tree, "Change shortcut").onClick();
147 await finish(bridge.getSnapShotState.mock.results[1]!.value);
148 expect(wizard(render())?.props.initialStep).toBe("access");
149 expect(bridge.previewSnapShotConfig).not.toHaveBeenCalled();
150});
151it.each(["direct", "gnome-extension", "kde"] as const)(
152 "keeps %s shortcut recording and saving inline",
153 async (backend) => {
154 state = {
155 ...state,
156 mode: backend === "direct" ? "direct" : "portal",
157 linuxBackend: backend === "direct" ? undefined : backend,
158 shortcutRegistered: true,
159 shortcutLabel: backend === "direct" ? undefined : "Press <Shift><Control>2",
160 shortcutMessage: backend === "direct" ? null : "Desktop shortcut: Press <Shift><Control>2",
161 };
162 const tree = await mount();
163 const recorder = (node: ReturnType<typeof render>) => {
164 const control = visitElements(node, (element) => "data-keybinding-capture" in element.props);
165 if (!control) throw new Error("Missing inline shortcut recorder");
166 return control.props;
167 };
168 if (backend !== "direct") {
169 expect(recorder(tree)["aria-label"]).toBe("Record snapshot shortcut, currently Ctrl+Shift+2");
170 const keycaps = visitElements(tree, (element) => element.type === SnapShotShortcutKeys);
171 expect(keycaps?.props.shortcut).toMatchObject({ key: "2", ctrlKey: true, shiftKey: true });
172 expect(
173 visitElements(tree, (element) => element.props.id === "snap-shot-shortcut")?.props.status,
174 ).toBeNull();
175 }
176 (recorder(tree).onClick as () => void)();
177 await finish(bridge.setSnapShotShortcutSuppressed.mock.results.at(-1)!.value);
178 (recorder(render()).onKeyDown as (event: object) => void)({
179 key: "y",
180 code: "KeyY",
181 ctrlKey: true,
182 altKey: true,
183 shiftKey: false,
184 metaKey: false,
185 repeat: false,
186 preventDefault: vi.fn(),
187 stopPropagation: vi.fn(),
188 });
189 await finish(bridge.checkSnapShotShortcut.mock.results[0]!.value);
190 expect(recorder(render()).size).toBe("xs");
191 expect(recorder(render())["aria-label"]).toBe("Record snapshot shortcut, currently Ctrl+Alt+Y");
192 button(render(), "Save").onClick();
193 await finish(settingsStore.update.mock.results[0]!.value);
194 expect(settingsStore.update).toHaveBeenCalledWith({
195 snapShotShortcut: expect.objectContaining({ key: "y", modKey: true, altKey: true }),
196 });
197 expect(wizard(render())).toBeNull();
198 expect(bridge.previewSnapShotConfig).not.toHaveBeenCalled();
199 },
200);
201
202it("turns capture on directly on Windows without opening setup", async () => {
203 settingsStore.current = { ...settingsStore.current, snapShotEnabled: false };
204 state = { ...state, mode: "direct", linuxBackend: undefined, windows: true };
205 const tree = await mount();
206 const toggle = visitElements(
207 tree,
208 (element) => element.props["aria-label"] === "Enable snapshots",
209 );
210 if (!toggle) throw new Error("Missing capture toggle");
211 (toggle.props.onCheckedChange as (checked: boolean) => void)(true);
212 await finish(settingsStore.update.mock.results[0]!.value);
213 expect(settingsStore.update).toHaveBeenCalledWith({ snapShotEnabled: true });
214 expect(wizard(renderWithEffects())).toBeNull();
215 expect(
216 visitElements(renderWithEffects(), (element) => element.props.children === "Manage capture"),
217 ).toBeNull();
218});
219
220it("keeps the approved desktop shortcut when recording is cancelled in setup", async () => {
221 state = {
222 ...state,
223 linuxBackend: "gnome-extension",
224 gnomeExtension: { status: "enabled", message: "Ready" },
225 shortcutRegistered: true,
226 shortcutLabel: "Press <Control><Alt>8",
227 };
228 const tree = await mount();
229 button(tree, "Manage capture").onClick();
230 await finish(bridge.getSnapShotState.mock.results[1]!.value);
231 const shortcut = () => {
232 const input = visitElements(
233 wizard(render()),
234 (element) => "data-keybinding-capture" in element.props,
235 );
236 if (!input) throw new Error("Missing setup shortcut recorder");
237 return input.props;
238 };
239 expect(shortcut()["aria-label"]).toBe("Record snapshot shortcut, currently Ctrl+Alt+8");
240 (shortcut().onClick as () => void)();
241 await finish(bridge.setSnapShotShortcutSuppressed.mock.results.at(-1)!.value);
242 expect(shortcut().children).toBe("Press shortcut…");
243 (shortcut().onKeyDown as (event: object) => void)({
244 key: "Escape",
245 preventDefault: vi.fn(),
246 stopPropagation: vi.fn(),
247 });
248 expect(shortcut()["aria-label"]).toBe("Record snapshot shortcut, currently Ctrl+Alt+8");
249 expect(settingsStore.update).not.toHaveBeenCalled();
250 expect(bridge.checkSnapShotShortcut).not.toHaveBeenCalled();
251});
252
253function usePortalShortcut(shortcutCanRetry: boolean) {
254 const shortcut = {
255 key: "2",
256 modKey: false,
257 ctrlKey: true,
258 altKey: false,
259 shiftKey: true,
260 metaKey: false,
261 };
262 settingsStore.current = { ...settingsStore.current, snapShotShortcut: shortcut };
263 state = {
264 ...state,
265 linuxBackend: "gnome-extension",
266 gnomeExtension: { status: "enabled", message: "Ready" },
267 shortcut,
268 linuxFeedbackAvailable: true,
269 shortcutRegistered: true,
270 shortcutCanRetry,
271 };
272}
273
274it("keeps older portal shortcuts editable without offering unsupported permissions", async () => {
275 usePortalShortcut(false);
276 const tree = await mount();
277 expect(() => button(tree, "Shortcut permissions")).toThrow("Missing button");
278 expect(bridge.setupSnapShot).not.toHaveBeenCalled();
279 const recorder = visitElements(tree, (element) => "data-keybinding-capture" in element.props);
280 (recorder!.props.onClick as () => void)();
281 await finish(bridge.setSnapShotShortcutSuppressed.mock.results.at(-1)!.value);
282 expect(
283 visitElements(render(), (element) => "data-keybinding-capture" in element.props)?.props
284 .children,
285 ).toBe("Press shortcut…");
286});
287
288it("shows permission errors once in a toast and allows retrying", async () => {
289 usePortalShortcut(true);
290 bridge.setupSnapShot.mockRejectedValueOnce(new Error("The desktop service disconnected."));
291 button(await mount(), "Shortcut permissions").onClick();
292 await finish(bridge.setupSnapShot.mock.results[0]!.value.catch(() => undefined));
293 const tree = renderWithEffects();
294 expect(toastManager.add).toHaveBeenCalledExactlyOnceWith({
295 type: "error",
296 title: "Couldn't open shortcut permissions",
297 description: "The desktop service disconnected.",
298 });
299 expect(visitElements(tree, (element) => element.props.role === "alert")).toBeNull();
300 expect(state.shortcutRegistered).toBe(true);
301 button(renderWithEffects(), "Shortcut permissions").onClick();
302 await finish(bridge.setupSnapShot.mock.results[1]!.value);
303 renderWithEffects();
304 expect(bridge.setupSnapShot).toHaveBeenCalledTimes(2);
305 expect(toastManager.add).toHaveBeenCalledTimes(1);
306});
307
308it("keeps a failed preference unchanged and reports the save error in a toast", async () => {
309 usePortalShortcut(true);
310 const flash = (tree: ReturnType<typeof render>) => {
311 const control = visitElements(
312 tree,
313 (element) => element.props["aria-label"] === "Flash captured window",
314 );
315 if (!control) throw new Error("Missing flash control");
316 return control.props as { onCheckedChange: (checked: boolean) => void; checked: boolean };
317 };
318 settingsStore.update.mockRejectedValueOnce(new Error("The settings file is read-only."));
319 flash(await mount()).onCheckedChange(false);
320 await finish(settingsStore.update.mock.results[0]!.value.catch(() => undefined));
321 expect(flash(renderWithEffects()).checked).toBe(true);
322 expect(toastManager.add).toHaveBeenCalledExactlyOnceWith({
323 type: "error",
324 title: "Couldn't save capture settings",
325 description: "The settings file is read-only.",
326 });
327 flash(renderWithEffects()).onCheckedChange(false);
328 await finish(settingsStore.update.mock.results[1]!.value);
329 expect(flash(renderWithEffects()).checked).toBe(false);
330 expect(toastManager.add).toHaveBeenCalledTimes(1);
331});
332
333it("keeps setup errors in the wizard and does not toast them after closing it", async () => {
334 usePortalShortcut(true);
335 button(await mount(), "Manage capture").onClick();
336 await finish(bridge.getSnapShotState.mock.results[1]!.value);
337 bridge.setupSnapShot.mockRejectedValueOnce(new Error("The desktop service disconnected."));
338 const action = wizard(render())!.props.onAction as (action: "retry-shortcut") => Promise<void>;
339 await action("retry-shortcut");
340 const dialog = wizard(renderWithEffects())!;
341 expect(dialog.props.error).toBe("The desktop service disconnected.");
342 expect(toastManager.add).not.toHaveBeenCalled();
343 await (dialog.props.onClose as (completed: boolean) => Promise<void>)(false);
344 expect(wizard(renderWithEffects())).toBeNull();
345 expect(toastManager.add).not.toHaveBeenCalled();
346});
347
348it.each([false, true])(
349 "resumes macOS permission setup after restart and clears it on close, completed=%s",
350 async (completed) => {
351 const { readSnapShotSetupResume } = await import("../../lib/snapShotSetupResume");
352 const values = new Map<string, string>();
353 Object.assign(window, {
354 localStorage: {
355 getItem: (key: string) => values.get(key) ?? null,
356 setItem: (key: string, value: string) => values.set(key, value),
357 removeItem: (key: string) => values.delete(key),
358 },
359 });
360 settingsStore.current = { ...settingsStore.current, snapShotEnabled: false };
361 state = {
362 mode: "direct",
363 shortcut: DEFAULT_CLIENT_SETTINGS.snapShotShortcut,
364 shortcutRegistered: false,
365 shortcutMessage: null,
366 message: null,
367 macPermissions: { screenRecording: false, accessibility: false },
368 };
369 const tree = await mount();
370 const toggle = visitElements(
371 tree,
372 (element) => element.props["aria-label"] === "Enable snapshots",
373 );
374 (toggle!.props.onCheckedChange as (checked: boolean) => void)(true);
375 await finish(bridge.getSnapShotState.mock.results[1]!.value);
376 bridge.setupSnapShot.mockImplementationOnce(async () => {
377 expect(readSnapShotSetupResume()).toEqual({ wasEnabled: false });
378 });
379 await (wizard(render())!.props.onAction as (action: string) => Promise<void>)(
380 "allow-screen-recording",
381 );
382
383 hooks.reset();
384 effects.length = 0;
385 bridge.getSnapShotState.mockClear();
386 state = { ...state, macPermissions: { screenRecording: true, accessibility: true } };
387 const resumed = wizard(await mount());
388 expect(resumed).not.toBeNull();
389 expect(resumed!.props.initialStep).toBe("access");
390 expect(resumed!.props.wasEnabled).toBe(false);
391 await (resumed!.props.onClose as (completed: boolean) => Promise<void>)(completed);
392 expect(wizard(render())).toBeNull();
393 expect(readSnapShotSetupResume()).toBeNull();
394 },
395);
396
397it("requires a successful macOS test capture before enabling and allows retry", async () => {
398 settingsStore.current = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: false };
399 state = {
400 mode: "direct",
401 shortcut: DEFAULT_CLIENT_SETTINGS.snapShotShortcut,
402 shortcutRegistered: false,
403 shortcutMessage: null,
404 message: null,
405 macPermissions: { screenRecording: true, accessibility: true },
406 };
407 const tree = await mount();
408 const toggle = visitElements(
409 tree,
410 (element) => element.props["aria-label"] === "Enable snapshots",
411 );
412 (toggle!.props.onCheckedChange as (checked: boolean) => void)(true);
413 await finish(bridge.getSnapShotState.mock.results[1]!.value);
414 bridge.setupSnapShot.mockRejectedValueOnce(new Error("Capture was denied"));
415 const enable = () => (wizard(render())!.props.onEnable as () => Promise<boolean>)();
416 expect(await enable()).toBe(false);
417 expect(wizard(render())!.props.error).toBe("Capture was denied");
418 expect(settingsStore.current.snapShotEnabled).toBe(false);
419 expect(await enable()).toBe(true);
420 expect(bridge.setupSnapShot.mock.calls).toEqual([["test-mac-capture"], ["test-mac-capture"]]);
421 expect(settingsStore.current.snapShotEnabled).toBe(true);
422});