1import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts";
2import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
3import { reactHookHarness as hooks } from "../../test/reactHookHarness";
4import { useSnapShotShortcutRecorder } from "./useSnapShotShortcutRecorder";
5
6const effects = vi.hoisted(() => [] as (() => () => void)[]);
7vi.mock("react", async (original) => {
8 const actual = await original<typeof import("react")>();
9 const { reactHookHarness } = await import("../../test/reactHookHarness");
10 return {
11 ...actual,
12 useCallback: reactHookHarness.useCallback,
13 useRef: reactHookHarness.useRef,
14 useState: reactHookHarness.useState,
15 useEffect: (effect: () => () => void) => effects.push(effect),
16 };
17});
18vi.mock("react/compiler-runtime", async () => {
19 const { reactHookHarness } = await import("../../test/reactHookHarness");
20 return { c: reactHookHarness.useMemoCache };
21});
22const suppress = vi.hoisted(() => vi.fn<(_: boolean) => Promise<void>>());
23vi.mock("../../lib/desktopSnapShot", () => ({
24 getDesktopSnapShotBridge: () => ({ setSnapShotShortcutSuppressed: suppress }),
25}));
26
27const recorded = vi.fn();
28const error = vi.fn();
29function render(allowModifierPairs = false, shortcutLabel?: string) {
30 hooks.beginRender();
31 return useSnapShotShortcutRecorder({
32 shortcut: DEFAULT_CLIENT_SETTINGS.snapShotShortcut,
33 shortcutLabel,
34 allowModifierPairs,
35 onRecord: recorded,
36 onError: error,
37 });
38}
39function event(key: string, code = key, extra: object = {}) {
40 return {
41 key,
42 code,
43 ctrlKey: false,
44 altKey: false,
45 shiftKey: false,
46 metaKey: false,
47 repeat: false,
48 preventDefault: vi.fn(),
49 stopPropagation: vi.fn(),
50 ...extra,
51 };
52}
53async function start(allowModifierPairs = false) {
54 render(allowModifierPairs).input.props.onClick();
55 await suppress.mock.results.at(-1)!.value;
56 return render(allowModifierPairs);
57}
58beforeEach(() => {
59 hooks.reset();
60 effects.length = 0;
61 suppress.mockReset().mockResolvedValue(undefined);
62 recorded.mockReset();
63 error.mockReset();
64 vi.stubGlobal("navigator", { platform: "Linux" });
65});
66afterEach(() => vi.unstubAllGlobals());
67
68it("suppresses capture while recording and uses the physical key for shifted digits", async () => {
69 const recorder = await start();
70 expect(recorder.recording).toBe(true);
71 expect(suppress).toHaveBeenCalledExactlyOnceWith(true);
72 recorder.input.props.onKeyDown(event("@", "Digit2", { ctrlKey: true, shiftKey: true }));
73 expect(recorded).toHaveBeenCalledExactlyOnceWith({
74 key: "2",
75 modKey: true,
76 ctrlKey: false,
77 metaKey: false,
78 altKey: false,
79 shiftKey: true,
80 });
81 expect(suppress).toHaveBeenLastCalledWith(false);
82 expect(render().recording).toBe(false);
83});
84it("lets users replace an unrecognized desktop label without displaying guessed keys", async () => {
85 const label = "Use the shortcut assigned in desktop settings";
86 let recorder = render(false, label);
87 expect(recorder.input.props.children).toBe("Change shortcut");
88 expect(recorder.input.props["aria-label"]).toBe("Change snapshot shortcut");
89 recorder.input.props.onClick();
90 await suppress.mock.results.at(-1)!.value;
91 recorder = render(false, label);
92 expect(recorder.input.props.children).toBe("Press shortcut…");
93 recorder.input.props.onKeyDown(event("@", "Digit2", { ctrlKey: true, shiftKey: true }));
94 expect(recorded).toHaveBeenCalledWith(
95 expect.objectContaining({ key: "2", modKey: true, shiftKey: true }),
96 );
97 expect(suppress).toHaveBeenLastCalledWith(false);
98});
99it("records macOS Command without treating it as Control", async () => {
100 vi.stubGlobal("navigator", { platform: "MacIntel" });
101 (await start(true)).input.props.onKeyDown(event("k", "KeyK", { metaKey: true }));
102 expect(recorded).toHaveBeenCalledWith(expect.objectContaining({ key: "k", modKey: true }));
103});
104it.each(["Escape", "blur", "unmount"])("cancels on %s without selecting keys", async (cancel) => {
105 const recorder = await start();
106 if (cancel === "Escape") {
107 const escape = event("Escape");
108 recorder.input.props.onKeyDown(escape);
109 expect(escape.preventDefault).toHaveBeenCalled();
110 expect(escape.stopPropagation).toHaveBeenCalled();
111 } else if (cancel === "blur") recorder.input.props.onBlur();
112 else effects[0]!()();
113 expect(suppress).toHaveBeenLastCalledWith(false);
114 expect(recorded).not.toHaveBeenCalled();
115 if (cancel !== "unmount") expect(render().recording).toBe(false);
116});
117it("ignores late suppression acknowledgements after cancellation", async () => {
118 let resolve!: () => void;
119 const pending = new Promise<void>((done) => {
120 resolve = done;
121 });
122 suppress.mockImplementation((enabled) => (enabled ? pending : Promise.resolve()));
123 render().input.props.onClick();
124 render().input.props.onBlur();
125 resolve();
126 await pending;
127 expect(render().recording).toBe(false);
128 expect(suppress).toHaveBeenLastCalledWith(false);
129 expect(recorded).not.toHaveBeenCalled();
130});
131it("reports suppression failures instead of arming the recorder", async () => {
132 suppress.mockRejectedValue(new Error("Couldn't pause capture"));
133 render().input.props.onClick();
134 await suppress.mock.results[0]!.value.catch(() => undefined);
135 expect(render().recording).toBe(false);
136 expect(error).toHaveBeenCalledWith("Couldn't pause capture");
137});
138it("does not record Tab, held repeats, or modifier keys on their own", async () => {
139 const recorder = await start();
140 const tab = event("Tab");
141 recorder.input.props.onKeyDown(tab);
142 recorder.input.props.onKeyDown(event("y", "KeyY", { ctrlKey: true, repeat: true }));
143 recorder.input.props.onKeyDown(event("Control", "ControlLeft", { ctrlKey: true }));
144 expect(tab.preventDefault).not.toHaveBeenCalled();
145 expect(recorded).not.toHaveBeenCalled();
146 expect(render().recording).toBe(true);
147});
148it("rejects modifier pairs on Wayland but accepts the next key chord", async () => {
149 const recorder = await start();
150 recorder.input.props.onKeyDown(event("Shift", "ShiftLeft", { shiftKey: true }));
151 recorder.input.props.onKeyDown(event("Shift", "ShiftRight", { shiftKey: true }));
152 expect(recorded).not.toHaveBeenCalled();
153 expect(error).toHaveBeenCalledWith(expect.stringContaining("Add a letter, number"));
154 expect(render().recording).toBe(true);
155 render().input.props.onKeyDown(event("y", "KeyY", { ctrlKey: true, altKey: true }));
156 expect(recorded).toHaveBeenCalledOnce();
157});
158it("preserves native modifier-pair recording and tracks released keys", async () => {
159 const recorder = await start(true);
160 recorder.input.props.onKeyDown(event("Shift", "ShiftLeft", { shiftKey: true }));
161 recorder.input.props.onKeyUp(event("Shift", "ShiftLeft"));
162 recorder.input.props.onKeyDown(event("Shift", "ShiftRight", { shiftKey: true }));
163 expect(recorded).not.toHaveBeenCalled();
164 recorder.input.props.onKeyDown(event("Shift", "ShiftLeft", { shiftKey: true }));
165 expect(recorded).toHaveBeenCalledExactlyOnceWith({ kind: "both-shift-keys" });
166});