1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 makeQuitShortcutHandler,
5 QUIT_DOUBLE_PRESS_MS,
6 QUIT_HOLD_DURATION_MS,
7 QUIT_HOLD_RELEASE_GRACE_MS,
8} from "./QuitHold.ts";
9import type { QuitHoldKeyInput } from "./QuitHold.ts";
10import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts";
11
12const HOLD_DOWN = { state: "down", mode: "hold" } as const;
13const DOUBLE_CLICK_DOWN = { state: "down", mode: "double-click" } as const;
14const UP = { state: "up" } as const;
15
16function makeInput(overrides: Partial<QuitHoldKeyInput>): QuitHoldKeyInput {
17 return {
18 type: "keyDown",
19 key: "q",
20 meta: true,
21 control: false,
22 alt: false,
23 shift: false,
24 isAutoRepeat: false,
25 ...overrides,
26 };
27}
28
29function makeHarness(options?: {
30 mode?: QuitConfirmationMode;
31 platform?: NodeJS.Platform;
32 getMode?: () => Promise<QuitConfirmationMode>;
33}) {
34 const notifications: Array<QuitShortcutHintEvent> = [];
35 const concealWindow = vi.fn();
36 const quit = vi.fn();
37 const handler = makeQuitShortcutHandler({
38 platform: options?.platform ?? "darwin",
39 getMode: options?.getMode ?? (() => Promise.resolve(options?.mode ?? "hold")),
40 notify: (event) => notifications.push(event),
41 concealWindow,
42 quit,
43 });
44 const preventDefault = vi.fn();
45 const send = async (input: QuitHoldKeyInput) => {
46 handler({ preventDefault }, input);
47 // Let the getMode promise settle.
48 await Promise.resolve();
49 await Promise.resolve();
50 };
51 // Simulates the OS auto-repeating the held shortcut every `intervalMs`.
52 const holdFor = async (
53 durationMs: number,
54 repeatOverrides: Partial<QuitHoldKeyInput> = {},
55 intervalMs = 100,
56 ) => {
57 for (let elapsed = 0; elapsed < durationMs; elapsed += intervalMs) {
58 vi.advanceTimersByTime(intervalMs);
59 await send(makeInput({ isAutoRepeat: true, ...repeatOverrides }));
60 }
61 };
62 return { notifications, concealWindow, quit, preventDefault, send, holdFor };
63}
64
65describe("makeQuitShortcutHandler", () => {
66 beforeEach(() => {
67 vi.useFakeTimers();
68 });
69 afterEach(() => {
70 vi.useRealTimers();
71 });
72
73 it("shows the hint on a tap without quitting, even when the release is never seen", async () => {
74 // macOS suppresses the letter's keyUp while Cmd is held, so a tap may
75 // produce no keyUp at all. Quit must still not fire.
76 const harness = makeHarness();
77 await harness.send(makeInput({}));
78 expect(harness.preventDefault).toHaveBeenCalledTimes(1);
79 expect(harness.notifications).toEqual([HOLD_DOWN]);
80
81 vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS);
82 expect(harness.quit).not.toHaveBeenCalled();
83 // The watchdog dismisses the hint once the press is clearly over.
84 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
85 });
86
87 it("conceals a completed hold, then quits after release", async () => {
88 const harness = makeHarness();
89 await harness.send(makeInput({}));
90 await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
91 expect(harness.concealWindow).toHaveBeenCalledTimes(1);
92 expect(harness.quit).not.toHaveBeenCalled();
93 await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));
94 expect(harness.quit).not.toHaveBeenCalled();
95 vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS);
96 expect(harness.quit).toHaveBeenCalledTimes(1);
97 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
98 });
99
100 it("keeps a concealed hold committed when another key is pressed", async () => {
101 const harness = makeHarness();
102 await harness.send(makeInput({}));
103 await harness.holdFor(QUIT_HOLD_DURATION_MS);
104
105 await harness.send(makeInput({ key: "Shift", shift: true }));
106 expect(harness.concealWindow).toHaveBeenCalledTimes(1);
107 expect(harness.quit).not.toHaveBeenCalled();
108
109 vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS);
110 expect(harness.quit).toHaveBeenCalledTimes(1);
111 });
112
113 it("keeps a concealed hold committed through a fresh Cmd+Q press", async () => {
114 const harness = makeHarness();
115 await harness.send(makeInput({}));
116 await harness.holdFor(QUIT_HOLD_DURATION_MS);
117
118 await harness.send(makeInput({}));
119 expect(harness.concealWindow).toHaveBeenCalledTimes(1);
120 expect(harness.quit).not.toHaveBeenCalled();
121
122 await harness.send(makeInput({ type: "keyUp" }));
123 expect(harness.quit).toHaveBeenCalledTimes(1);
124 });
125
126 it("quits when a completed hold goes quiet without release events", async () => {
127 const harness = makeHarness();
128 await harness.send(makeInput({}));
129 await harness.holdFor(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS * 2);
130
131 // If neither keyUp reaches the handler, continued repeats must keep the
132 // app alive. Once they stop, the quiet period is the release signal.
133 expect(harness.quit).not.toHaveBeenCalled();
134 vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS);
135
136 expect(harness.quit).toHaveBeenCalledTimes(1);
137 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
138 });
139
140 it("waits for slow repeats to stop before quitting", async () => {
141 const harness = makeHarness();
142 await harness.send(makeInput({}));
143
144 vi.advanceTimersByTime(300);
145 await harness.send(makeInput({ isAutoRepeat: true }));
146 vi.advanceTimersByTime(900);
147 await harness.send(makeInput({ isAutoRepeat: true }));
148
149 vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS);
150 expect(harness.quit).not.toHaveBeenCalled();
151
152 vi.advanceTimersByTime(300);
153 await harness.send(makeInput({ isAutoRepeat: true }));
154 vi.advanceTimersByTime(1_799);
155 expect(harness.quit).not.toHaveBeenCalled();
156 vi.advanceTimersByTime(1);
157 expect(harness.quit).toHaveBeenCalledTimes(1);
158 });
159
160 it("uses the initial repeat delay when the first repeat completes the hold", async () => {
161 const harness = makeHarness();
162 await harness.send(makeInput({}));
163
164 vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + 100);
165 await harness.send(makeInput({ isAutoRepeat: true }));
166
167 vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS);
168 expect(harness.quit).not.toHaveBeenCalled();
169 vi.advanceTimersByTime(1_999);
170 expect(harness.quit).not.toHaveBeenCalled();
171 vi.advanceTimersByTime(1);
172 expect(harness.quit).toHaveBeenCalledTimes(1);
173 });
174
175 it("waits for Q release when Cmd is released first", async () => {
176 const harness = makeHarness();
177 await harness.send(makeInput({}));
178 await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
179 await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));
180 harness.preventDefault.mockClear();
181 // Repeats without the modifier prove Q is still down, so they hold the
182 // quit back for as long as they keep arriving.
183 await harness.holdFor(QUIT_HOLD_RELEASE_GRACE_MS * 2, { meta: false });
184 expect(harness.preventDefault).toHaveBeenCalled();
185 expect(harness.quit).not.toHaveBeenCalled();
186 await harness.send(makeInput({ type: "keyUp", meta: false }));
187 expect(harness.quit).toHaveBeenCalledTimes(1);
188 });
189
190 it("commits a concealed hold when the last Q repeat is never released", async () => {
191 // macOS can drop the final Q keyUp. The quit must land on its own once
192 // repeats stop, rather than sitting armed until an unrelated key arrives.
193 const harness = makeHarness();
194 await harness.send(makeInput({}));
195 await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
196 await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));
197 await harness.send(makeInput({ meta: false, isAutoRepeat: true }));
198
199 vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS);
200 expect(harness.quit).toHaveBeenCalledTimes(1);
201
202 // A lone Cmd tap afterwards must not quit a second time.
203 harness.quit.mockClear();
204 await harness.send(makeInput({ key: "Meta" }));
205 await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));
206 vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS * 4);
207 expect(harness.quit).not.toHaveBeenCalled();
208 });
209
210 it("does not quit when the hold stops before the duration", async () => {
211 const harness = makeHarness();
212 await harness.send(makeInput({}));
213 await harness.holdFor(500);
214 await harness.send(makeInput({ type: "keyUp" }));
215 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
216 vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2);
217 expect(harness.concealWindow).not.toHaveBeenCalled();
218 expect(harness.quit).not.toHaveBeenCalled();
219 });
220
221 it("cancels the hold when the modifier is released first", async () => {
222 const harness = makeHarness();
223 await harness.send(makeInput({}));
224 await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));
225 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
226 vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2);
227 expect(harness.quit).not.toHaveBeenCalled();
228 });
229
230 it("quits without showing a hint in direct mode", async () => {
231 const harness = makeHarness({ mode: "direct" });
232 await harness.send(makeInput({}));
233 expect(harness.concealWindow).not.toHaveBeenCalled();
234 expect(harness.quit).toHaveBeenCalledTimes(1);
235 expect(harness.notifications).toEqual([]);
236 });
237
238 it("honors direct mode when the key is released before its mode read settles", async () => {
239 let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined;
240 const harness = makeHarness({
241 getMode: () =>
242 new Promise((resolve) => {
243 resolveMode = resolve;
244 }),
245 });
246 await harness.send(makeInput({}));
247 await harness.send(makeInput({ type: "keyUp" }));
248
249 resolveMode?.("direct");
250 await Promise.resolve();
251 await Promise.resolve();
252
253 expect(harness.quit).toHaveBeenCalledTimes(1);
254 expect(harness.notifications).toEqual([]);
255 });
256
257 it("does not arm hold mode after a released key's mode read settles", async () => {
258 let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined;
259 const harness = makeHarness({
260 getMode: () =>
261 new Promise((resolve) => {
262 resolveMode = resolve;
263 }),
264 });
265 await harness.send(makeInput({}));
266 await harness.send(makeInput({ type: "keyUp" }));
267
268 resolveMode?.("hold");
269 await Promise.resolve();
270 await Promise.resolve();
271
272 expect(harness.quit).not.toHaveBeenCalled();
273 expect(harness.notifications).toEqual([]);
274 });
275
276 it.each(["direct", "hold", "double-click"] as const)(
277 "quits on a quick second press without waiting for a pending %s mode read",
278 async (mode) => {
279 const resolvers: Array<(mode: QuitConfirmationMode) => void> = [];
280 const harness = makeHarness({
281 getMode: () => new Promise((resolve) => resolvers.push(resolve)),
282 });
283 await harness.send(makeInput({}));
284 await harness.send(makeInput({ type: "keyUp" }));
285 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
286 await harness.send(makeInput({}));
287
288 expect(harness.quit).toHaveBeenCalledTimes(1);
289 expect(harness.notifications).toEqual([]);
290
291 await harness.send(makeInput({ type: "keyUp" }));
292
293 resolvers[0]?.(mode);
294 await Promise.resolve();
295 await Promise.resolve();
296
297 expect(harness.quit).toHaveBeenCalledTimes(1);
298 expect(harness.notifications).toEqual([]);
299 },
300 );
301
302 it("discards a stale mode resolution from a superseded press", async () => {
303 // Press #1's mode is still pending when the user releases and
304 // presses again; its late resolution must not act for press #2.
305 const resolvers: Array<(mode: QuitConfirmationMode) => void> = [];
306 const harness = makeHarness({
307 getMode: () => new Promise((resolve) => resolvers.push(resolve)),
308 });
309 await harness.send(makeInput({}));
310 await harness.send(makeInput({ type: "keyUp" }));
311 // Outside the double-press window, so the second press starts a new hold.
312 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100);
313 await harness.send(makeInput({}));
314 expect(resolvers).toHaveLength(2);
315
316 // Press #1 resolves late with "direct". It must not quit press #2.
317 resolvers[0]?.("direct");
318 await Promise.resolve();
319 await Promise.resolve();
320 expect(harness.quit).not.toHaveBeenCalled();
321
322 // Press #2 resolves to hold and completes the gesture.
323 resolvers[1]?.("hold");
324 await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
325 await harness.send(makeInput({ type: "keyUp" }));
326 expect(harness.quit).toHaveBeenCalledTimes(1);
327 });
328
329 it("quits on a quick double press in double-click mode when the first release is unseen", async () => {
330 const harness = makeHarness({ mode: "double-click" });
331 await harness.send(makeInput({}));
332 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
333 await harness.send(makeInput({}));
334 expect(harness.concealWindow).not.toHaveBeenCalled();
335 expect(harness.quit).toHaveBeenCalledTimes(1);
336 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]);
337 });
338
339 it("keeps the double-press hint visible after key release until the window ends", async () => {
340 const harness = makeHarness({ mode: "double-click" });
341 await harness.send(makeInput({}));
342 vi.advanceTimersByTime(100);
343 await harness.send(makeInput({ type: "keyUp" }));
344 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]);
345
346 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 101);
347 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]);
348 vi.advanceTimersByTime(1);
349 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]);
350 });
351
352 it("accepts a second full shortcut after the modifier is released and pressed again", async () => {
353 const harness = makeHarness({ mode: "double-click" });
354 await harness.send(makeInput({}));
355 await harness.send(makeInput({ type: "keyUp" }));
356 await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));
357 vi.advanceTimersByTime(100);
358
359 await harness.send(makeInput({ key: "Meta" }));
360 await harness.send(makeInput({}));
361
362 expect(harness.quit).toHaveBeenCalledTimes(1);
363 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]);
364 });
365
366 it("expires a delayed double-press hint from keydown rather than mode resolution", async () => {
367 let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined;
368 const harness = makeHarness({
369 getMode: () =>
370 new Promise((resolve) => {
371 resolveMode = resolve;
372 }),
373 });
374 await harness.send(makeInput({}));
375 vi.advanceTimersByTime(100);
376 await harness.send(makeInput({ type: "keyUp" }));
377 vi.advanceTimersByTime(100);
378 resolveMode?.("double-click");
379 await Promise.resolve();
380 await Promise.resolve();
381 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]);
382
383 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 201);
384 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]);
385 vi.advanceTimersByTime(1);
386 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]);
387 });
388
389 it("treats two slow presses as separate attempts in double-click mode", async () => {
390 const harness = makeHarness({ mode: "double-click" });
391 await harness.send(makeInput({}));
392 await harness.send(makeInput({ type: "keyUp" }));
393 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100);
394 await harness.send(makeInput({}));
395 expect(harness.quit).not.toHaveBeenCalled();
396 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]);
397 });
398
399 it("quits on a quick second press in hold mode", async () => {
400 const harness = makeHarness();
401 await harness.send(makeInput({}));
402 await harness.send(makeInput({ type: "keyUp" }));
403 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
404 await harness.send(makeInput({}));
405 expect(harness.quit).toHaveBeenCalledTimes(1);
406 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
407 });
408
409 it("quits on a quick second press in hold mode when the first release is unseen", async () => {
410 const harness = makeHarness();
411 await harness.send(makeInput({}));
412 vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
413 await harness.send(makeInput({}));
414
415 expect(harness.concealWindow).not.toHaveBeenCalled();
416 expect(harness.quit).toHaveBeenCalledTimes(1);
417 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
418 });
419
420 it("does not count auto-repeat as a second press", async () => {
421 const harness = makeHarness();
422 await harness.send(makeInput({}));
423 await harness.holdFor(QUIT_DOUBLE_PRESS_MS - 100);
424
425 expect(harness.quit).not.toHaveBeenCalled();
426 expect(harness.notifications).toEqual([HOLD_DOWN]);
427 });
428
429 it("does not count a released tap after another shortcut interrupts it", async () => {
430 const harness = makeHarness();
431 await harness.send(makeInput({}));
432 await harness.send(makeInput({ type: "keyUp" }));
433 await harness.send(makeInput({ key: "c" }));
434 vi.advanceTimersByTime(100);
435 await harness.send(makeInput({}));
436
437 expect(harness.quit).not.toHaveBeenCalled();
438 expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]);
439 });
440
441 it("cancels the hold when another key interrupts it", async () => {
442 const harness = makeHarness();
443 await harness.send(makeInput({}));
444 await harness.holdFor(500);
445 // Shift pressed mid-hold breaks the gesture...
446 await harness.send(makeInput({ shift: true }));
447 expect(harness.notifications).toEqual([HOLD_DOWN, UP]);
448 // ...so later repeats past the threshold must not quit.
449 await harness.holdFor(QUIT_HOLD_DURATION_MS);
450 expect(harness.quit).not.toHaveBeenCalled();
451 });
452
453 it("does not count an interrupted press toward a double press", async () => {
454 const harness = makeHarness({ mode: "double-click" });
455 await harness.send(makeInput({}));
456 await harness.send(makeInput({ shift: true }));
457 // A fresh press right after the interruption starts a new attempt.
458 await harness.send(makeInput({}));
459 expect(harness.quit).not.toHaveBeenCalled();
460 expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]);
461 });
462
463 it("ignores other shortcuts", async () => {
464 const harness = makeHarness();
465 await harness.send(makeInput({ key: "w" }));
466 await harness.send(makeInput({ shift: true }));
467 await harness.send(makeInput({ meta: false }));
468 expect(harness.preventDefault).not.toHaveBeenCalled();
469 expect(harness.notifications).toEqual([]);
470 });
471
472 it("uses control on non-mac platforms", async () => {
473 const harness = makeHarness({ platform: "linux" });
474 await harness.send(makeInput({ meta: false, control: true }));
475 expect(harness.preventDefault).toHaveBeenCalledTimes(1);
476 await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true });
477 await harness.send(makeInput({ type: "keyUp", meta: false, control: true }));
478 expect(harness.quit).toHaveBeenCalledTimes(1);
479 });
480});