1import {
2 DEFAULT_CLIENT_SETTINGS,
3 type DesktopCaptureConfigPreview,
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
10vi.mock("react", async (original) => {
11 const actual = await original<typeof import("react")>();
12 const { reactHookHarness } = await import("../../test/reactHookHarness");
13 return {
14 ...actual,
15 useCallback: reactHookHarness.useCallback,
16 useRef: reactHookHarness.useRef,
17 useEffect: () => undefined,
18 useMemo: reactHookHarness.useMemo,
19 useState: reactHookHarness.useState,
20 };
21});
22vi.mock("react/compiler-runtime", async () => {
23 const { reactHookHarness } = await import("../../test/reactHookHarness");
24 return { c: reactHookHarness.useMemoCache };
25});
26const bridge = vi.hoisted(() => ({
27 previewSnapShotConfig: vi.fn(),
28 applySnapShotConfig: vi.fn(),
29 setSnapShotShortcutSuppressed: vi.fn(),
30}));
31vi.mock("../../lib/desktopSnapShot", () => ({ getDesktopSnapShotBridge: () => bridge }));
32vi.mock("../../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) }));
33vi.mock("../../hooks/useCopyToClipboard", () => ({
34 useCopyToClipboard: () => ({ copyToClipboard: vi.fn(), isCopied: false }),
35}));
36vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } }));
37import { CaptureShortcutConfig } from "./CaptureShortcutConfig";
38import { toastManager } from "../ui/toast";
39
40const preview: DesktopCaptureConfigPreview = {
41 id: "approved-snapshot",
42 path: "/config/niri/config.kdl",
43 resolvedPath: "/config/niri/config.kdl",
44 before: "binds {\n}\n",
45 after: 'binds {\n Ctrl+Shift+2 { spawn "gdbus"; }\n}\n',
46 shortcut: "Ctrl+Shift+2",
47 operation: "install",
48};
49const complete = vi.fn<() => Promise<void>>();
50function render(desktop: "niri" | "hyprland" = "niri") {
51 const state: DesktopSnapShotState = {
52 mode: "portal",
53 linuxBackend: desktop,
54 shortcut: DEFAULT_CLIENT_SETTINGS.snapShotShortcut,
55 shortcutRegistered: false,
56 shortcutActionRegistered: true,
57 shortcutMessage: null,
58 message: null,
59 shortcutConfigPath: preview.path,
60 shortcutBinding: "manual binding",
61 };
62 hooks.beginRender();
63 return CaptureShortcutConfig({ state, onComplete: complete });
64}
65function button(tree: ReturnType<typeof render>, label: string) {
66 const node = visitElements(
67 tree,
68 (element) => element.props.children === label && typeof element.props.onClick === "function",
69 );
70 if (!node) throw new Error(`Missing button: ${label}`);
71 return node.props as { onClick: () => void; disabled: boolean };
72}
73function shortcutInput(tree: ReturnType<typeof render>) {
74 const node = visitElements(tree, (element) => "data-keybinding-capture" in element.props);
75 if (!node) throw new Error("Missing shortcut recorder");
76 return node.props as {
77 "aria-label": string;
78 onClick: () => void;
79 onKeyDown: (event: object) => void;
80 onBlur: () => void;
81 };
82}
83async function recordKeys(desktop: "niri" | "hyprland", modifiers: object = {}) {
84 shortcutInput(render(desktop)).onClick();
85 await finish(bridge.setSnapShotShortcutSuppressed.mock.results.at(-1)!.value);
86 shortcutInput(render(desktop)).onKeyDown({
87 key: "y",
88 code: "KeyY",
89 ctrlKey: true,
90 altKey: true,
91 shiftKey: false,
92 metaKey: false,
93 repeat: false,
94 preventDefault: vi.fn(),
95 stopPropagation: vi.fn(),
96 ...modifiers,
97 });
98}
99async function finish(promise: Promise<unknown>) {
100 await promise.catch(() => undefined);
101 await Promise.resolve();
102}
103beforeEach(() => {
104 hooks.reset();
105 vi.stubGlobal("navigator", { platform: "Linux" });
106 bridge.setSnapShotShortcutSuppressed.mockReset().mockResolvedValue(undefined);
107 bridge.previewSnapShotConfig.mockReset().mockResolvedValue(preview);
108 bridge.applySnapShotConfig
109 .mockReset()
110 .mockResolvedValue({ backupPath: "/config/backup", warning: null });
111 complete.mockReset().mockResolvedValue(undefined);
112 vi.mocked(toastManager.add).mockClear();
113});
114afterEach(() => vi.unstubAllGlobals());
115
116it.each(["niri", "hyprland"] as const)(
117 "shows the fallback before review and requires separate read and write approval on %s",
118 async (desktop) => {
119 let tree = render(desktop);
120 expect(shortcutInput(tree)["aria-label"]).toBe(
121 "Record snapshot shortcut, currently Ctrl+Shift+2",
122 );
123 expect(bridge.previewSnapShotConfig).not.toHaveBeenCalled();
124 expect(bridge.applySnapShotConfig).not.toHaveBeenCalled();
125 expect(visitElements(tree, (element) => element.type === "details")).not.toBeNull();
126 button(tree, "Review changes").onClick();
127 await finish(bridge.previewSnapShotConfig.mock.results[0]!.value);
128 tree = render(desktop);
129 expect(shortcutInput(tree)["aria-label"]).toBe(
130 "Record snapshot shortcut, currently Ctrl+Shift+2",
131 );
132 expect(bridge.applySnapShotConfig).not.toHaveBeenCalled();
133 const diff = visitElements(tree, (element) => "fileDiff" in element.props);
134 expect(diff).not.toBeNull();
135 expect(diff?.props.fileDiff).toMatchObject({ name: preview.path });
136 button(tree, "Save shortcut").onClick();
137 await finish(bridge.applySnapShotConfig.mock.results[0]!.value);
138 expect(bridge.applySnapShotConfig).toHaveBeenCalledExactlyOnceWith(preview.id);
139 expect(complete).toHaveBeenCalledOnce();
140 expect(toastManager.add).toHaveBeenCalledWith(expect.objectContaining({ type: "success" }));
141 },
142);
143it.each(["niri", "hyprland"] as const)(
144 "lets users choose %s keys without opening Advanced, then approve the matching diff",
145 async (desktop) => {
146 const tree = render(desktop);
147 const advanced = visitElements(tree, (element) => element.type === "details");
148 expect(
149 visitElements(advanced, (element) => "data-keybinding-capture" in element.props),
150 ).toBeNull();
151 await recordKeys(desktop);
152 expect(bridge.setSnapShotShortcutSuppressed).toHaveBeenNthCalledWith(1, true);
153 expect(bridge.setSnapShotShortcutSuppressed).toHaveBeenLastCalledWith(false);
154 expect(bridge.previewSnapShotConfig).not.toHaveBeenCalled();
155 const custom = {
156 ...preview,
157 id: "custom-keys",
158 shortcut: "Ctrl+Alt+Y",
159 after: preview.after.replace("Ctrl+Shift+2", "Ctrl+Alt+Y"),
160 };
161 bridge.previewSnapShotConfig.mockResolvedValue(custom);
162 button(render(desktop), "Review changes").onClick();
163 await finish(bridge.previewSnapShotConfig.mock.results[0]!.value);
164 expect(bridge.previewSnapShotConfig).toHaveBeenCalledExactlyOnceWith({
165 operation: "install",
166 chooseFile: false,
167 shortcut: "ctrl+alt+y",
168 });
169 expect(bridge.applySnapShotConfig).not.toHaveBeenCalled();
170 button(render(desktop), "Save shortcut").onClick();
171 await finish(bridge.applySnapShotConfig.mock.results[0]!.value);
172 expect(bridge.applySnapShotConfig).toHaveBeenCalledExactlyOnceWith(custom.id);
173 expect(toastManager.add).toHaveBeenCalledWith(
174 expect.objectContaining({ description: "Use Ctrl+Alt+Y from another app." }),
175 );
176 },
177);
178it("uses the existing config keys when no replacement was chosen", async () => {
179 bridge.previewSnapShotConfig.mockResolvedValue({ ...preview, shortcut: "Super+F8" });
180 button(render(), "Review changes").onClick();
181 await finish(bridge.previewSnapShotConfig.mock.results[0]!.value);
182 expect(bridge.previewSnapShotConfig).toHaveBeenCalledExactlyOnceWith({
183 operation: "install",
184 chooseFile: false,
185 });
186 expect(shortcutInput(render())["aria-label"]).toContain("F8");
187});
188it("requires a new diff after changing keys during review", async () => {
189 button(render(), "Review changes").onClick();
190 await finish(bridge.previewSnapShotConfig.mock.results[0]!.value);
191 await recordKeys("niri", { key: "F8", code: "F8", ctrlKey: false, altKey: false, metaKey: true });
192 expect(visitElements(render(), (element) => "fileDiff" in element.props)).toBeNull();
193 expect(bridge.applySnapShotConfig).not.toHaveBeenCalled();
194 const replacement = { ...preview, id: "replacement", shortcut: "Super+F8" };
195 bridge.previewSnapShotConfig.mockResolvedValue(replacement);
196 button(render(), "Review changes").onClick();
197 await finish(bridge.previewSnapShotConfig.mock.results[1]!.value);
198 expect(bridge.previewSnapShotConfig).toHaveBeenLastCalledWith({
199 operation: "install",
200 chooseFile: false,
201 shortcut: "meta+f8",
202 });
203 button(render(), "Save shortcut").onClick();
204 await finish(bridge.applySnapShotConfig.mock.results[0]!.value);
205 expect(bridge.applySnapShotConfig).toHaveBeenCalledExactlyOnceWith(replacement.id);
206});
207it.each(["Escape", "blur"])(
208 "keeps the reviewed diff when recording is cancelled with %s",
209 async (cancel) => {
210 button(render(), "Review changes").onClick();
211 await finish(bridge.previewSnapShotConfig.mock.results[0]!.value);
212 shortcutInput(render()).onClick();
213 await finish(bridge.setSnapShotShortcutSuppressed.mock.results.at(-1)!.value);
214 expect(button(render(), "Save shortcut").disabled).toBe(true);
215 if (cancel === "Escape")
216 shortcutInput(render()).onKeyDown({
217 key: "Escape",
218 preventDefault: vi.fn(),
219 stopPropagation: vi.fn(),
220 });
221 else shortcutInput(render()).onBlur();
222 expect(bridge.applySnapShotConfig).not.toHaveBeenCalled();
223 expect(button(render(), "Save shortcut").disabled).toBe(false);
224 expect(bridge.previewSnapShotConfig).toHaveBeenCalledOnce();
225 button(render(), "Save shortcut").onClick();
226 await finish(bridge.applySnapShotConfig.mock.results[0]!.value);
227 expect(bridge.applySnapShotConfig).toHaveBeenCalledExactlyOnceWith(preview.id);
228 },
229);
230it("cancelling a reviewed diff does not write or finish setup", async () => {
231 button(render(), "Review changes").onClick();
232 await finish(bridge.previewSnapShotConfig.mock.results[0]!.value);
233 button(render(), "Cancel").onClick();
234 expect(shortcutInput(render())["aria-label"]).toBe(
235 "Record snapshot shortcut, currently Ctrl+Shift+2",
236 );
237 expect(button(render(), "Review changes")).toBeDefined();
238 expect(bridge.applySnapShotConfig).not.toHaveBeenCalled();
239 expect(complete).not.toHaveBeenCalled();
240});
241it("shows reading feedback until a proposal arrives", async () => {
242 let resolvePreview!: (value: DesktopCaptureConfigPreview) => void;
243 const pending = new Promise<DesktopCaptureConfigPreview>((resolve) => {
244 resolvePreview = resolve;
245 });
246 bridge.previewSnapShotConfig.mockReturnValue(pending);
247 button(render(), "Review changes").onClick();
248 expect(button(render(), "Preparing changes…").disabled).toBe(true);
249 expect(bridge.applySnapShotConfig).not.toHaveBeenCalled();
250 resolvePreview(preview);
251 await finish(pending);
252 expect(button(render(), "Save shortcut").disabled).toBe(false);
253});
254it("keeps read failures actionable with technical details in Advanced, then permits retry", async () => {
255 bridge.previewSnapShotConfig.mockRejectedValueOnce(new Error("EACCES: /config/niri/config.kdl"));
256 button(render(), "Review changes").onClick();
257 await finish(bridge.previewSnapShotConfig.mock.results[0]!.value);
258 const failed = render();
259 expect(visitElements(failed, (element) => element.props.role === "alert")?.props.children).toBe(
260 "Couldn't prepare the changes. Check Advanced for help.",
261 );
262 const advanced = visitElements(failed, (element) => element.type === "details");
263 expect(
264 visitElements(
265 advanced,
266 (element) => element.props.children === "EACCES: /config/niri/config.kdl",
267 ),
268 ).not.toBeNull();
269 expect(bridge.applySnapShotConfig).not.toHaveBeenCalled();
270 button(failed, "Review changes").onClick();
271 await finish(bridge.previewSnapShotConfig.mock.results[1]!.value);
272 expect(visitElements(render(), (element) => element.props.role === "alert")).toBeNull();
273 expect(button(render(), "Save shortcut").disabled).toBe(false);
274 expect(bridge.applySnapShotConfig).not.toHaveBeenCalled();
275});
276it("withdraws a stale diff and requires another read instead of retrying its write", async () => {
277 button(render(), "Review changes").onClick();
278 await finish(bridge.previewSnapShotConfig.mock.results[0]!.value);
279 bridge.applySnapShotConfig.mockRejectedValue(
280 new Error("Your config changed since this preview."),
281 );
282 button(render(), "Save shortcut").onClick();
283 await finish(bridge.applySnapShotConfig.mock.results[0]!.value);
284 const tree = render();
285 expect(
286 visitElements(tree, (element) => element.props.role === "alert")?.props.children,
287 ).toContain("Review the changes and try again");
288 const advanced = visitElements(tree, (element) => element.type === "details");
289 expect(
290 visitElements(
291 advanced,
292 (element) => element.props.children === "Your config changed since this preview.",
293 ),
294 ).not.toBeNull();
295 expect(button(tree, "Review changes")).toBeDefined();
296 expect(visitElements(tree, (element) => "fileDiff" in element.props)).toBeNull();
297 expect(complete).not.toHaveBeenCalled();
298});
299it("does not finish or claim success when the desktop could not reload", async () => {
300 button(render(), "Review changes").onClick();
301 await finish(bridge.previewSnapShotConfig.mock.results[0]!.value);
302 bridge.applySnapShotConfig.mockResolvedValue({
303 backupPath: "/config/backup",
304 warning: "Config saved, but reload failed.",
305 });
306 button(render(), "Save shortcut").onClick();
307 await finish(bridge.applySnapShotConfig.mock.results[0]!.value);
308 expect(
309 visitElements(render(), (element) => element.props.role === "status")?.props.children,
310 ).toBe("Saved, but the shortcut needs attention. Check Advanced for help.");
311 const advanced = visitElements(render(), (element) => element.type === "details");
312 expect(
313 visitElements(
314 advanced,
315 (element) => element.props.children === "Config saved, but reload failed.",
316 ),
317 ).not.toBeNull();
318 expect(complete).not.toHaveBeenCalled();
319 expect(toastManager.add).not.toHaveBeenCalled();
320});