返回 DeepSeek-Reasonix
shortcuts-recorder-focus.test.tsx
根目录 / desktop / frontend / src / __tests__ / shortcuts-recorder-focus.test.tsx
1 // Run: tsx src/__tests__/shortcuts-recorder-focus.test.tsx
2 //
3 // Regression test for the shortcut recorder on WebKit (WKWebView). WebKit does
4 // not focus <button> elements on mouse click, and the recorder's keydown
5 // listener lives on the button — so without an explicit focus() the recorder
6 // never sees any keys. JSDOM clicks share that behavior, which lets this test
7 // reproduce the WebKit flow exactly.
8
9 import { JSDOM } from "jsdom";
10 import React from "react";
11 import { act } from "react";
12 import { createRoot } from "react-dom/client";
13
14 let passed = 0;
15 let failed = 0;
16
17 function ok(value: boolean, label: string) {
18 if (value) {
19 process.stdout.write(` PASS ${label}\n`);
20 passed += 1;
21 } else {
22 process.stdout.write(` FAIL ${label}\n`);
23 failed += 1;
24 }
25 }
26
27 function flushPromises(): Promise<void> {
28 return new Promise((resolve) => setTimeout(resolve, 0));
29 }
30
31 function installDom() {
32 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
33 pretendToBeVisual: true,
34 url: "http://localhost/",
35 });
36 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
37 globalThis.window = dom.window as unknown as Window & typeof globalThis;
38 globalThis.document = dom.window.document;
39 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
40 globalThis.Node = dom.window.Node;
41 globalThis.HTMLElement = dom.window.HTMLElement;
42 globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
43 globalThis.Event = dom.window.Event;
44 globalThis.MouseEvent = dom.window.MouseEvent;
45 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
46 globalThis.FocusEvent = dom.window.FocusEvent;
47 globalThis.CustomEvent = dom.window.CustomEvent;
48 globalThis.localStorage = dom.window.localStorage;
49 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
50 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
51 Object.defineProperty(window, "matchMedia", {
52 configurable: true,
53 value: () => ({
54 matches: false,
55 media: "",
56 addEventListener: () => {},
57 removeEventListener: () => {},
58 addListener: () => {},
59 removeListener: () => {},
60 }),
61 });
62 return dom;
63 }
64
65 async function main() {
66 installDom();
67
68 // Import after the DOM globals exist so module-level window guards hold.
69 const { ShortcutsSection } = await import("../components/SettingsPanel");
70 const { LocaleProvider } = await import("../lib/i18n");
71 const { loadCustomShortcuts, resetCustomShortcuts } = await import("../lib/keyboardShortcuts");
72
73 resetCustomShortcuts();
74
75 const container = document.getElementById("root")!;
76 const root = createRoot(container);
77 await act(async () => {
78 root.render(
79 <LocaleProvider>
80 <ShortcutsSection />
81 </LocaleProvider>,
82 );
83 await flushPromises();
84 });
85
86 const keyButton = container.querySelector<HTMLButtonElement>(".shortcuts-settings__key");
87 ok(Boolean(keyButton), "recorder button renders");
88 if (!keyButton) throw new Error("no recorder button");
89 const undoButton = container.querySelector<HTMLButtonElement>('[data-shortcut-action="composer.undo"]');
90 const redoButton = container.querySelector<HTMLButtonElement>('[data-shortcut-action="composer.redo"]');
91 ok(Boolean(undoButton), "composer undo appears in shortcut settings");
92 ok(Boolean(redoButton), "composer redo appears in shortcut settings");
93 ok(Boolean(undoButton?.disabled), "composer undo stays locked to the native editing chord");
94 ok(Boolean(redoButton?.disabled), "composer redo stays locked to the native editing chord");
95 ok(undoButton?.textContent?.includes("Ctrl") === true && undoButton.textContent.includes("Z"), "composer undo shows Ctrl+Z on non-mac platforms");
96 ok(
97 redoButton?.textContent?.includes("Ctrl") === true
98 && redoButton.textContent.includes("Shift")
99 && redoButton.textContent.includes("Z"),
100 "composer redo shows Ctrl+Shift+Z on non-mac platforms",
101 );
102
103 // Click WITHOUT focusing — JSDOM (like WebKit/WKWebView) does not focus
104 // buttons on click, so this reproduces the desktop app's event flow.
105 await act(async () => {
106 keyButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
107 await flushPromises();
108 });
109
110 ok(keyButton.classList.contains("shortcuts-settings__key--recording"), "clicking enters recording state");
111 ok(document.activeElement === keyButton, "recorder button is focused after click (WebKit needs explicit focus)");
112
113 // The key lands on whatever has focus — exactly what WKWebView does.
114 await act(async () => {
115 (document.activeElement ?? document.body).dispatchEvent(
116 new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }),
117 );
118 await flushPromises();
119 });
120
121 ok(!keyButton.classList.contains("shortcuts-settings__key--recording"), "combo press leaves recording state");
122 const saved = loadCustomShortcuts();
123 const first = Object.values(saved)[0];
124 ok(Boolean(first && first.key === "Enter" && first.ctrl), "Ctrl+Enter is saved as the custom combo");
125
126 // Losing focus while recording must cancel the recording state, otherwise
127 // the UI claims to listen while no keys can reach it.
128 await act(async () => {
129 keyButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
130 await flushPromises();
131 });
132 ok(keyButton.classList.contains("shortcuts-settings__key--recording"), "second click re-enters recording state");
133 await act(async () => {
134 keyButton.dispatchEvent(new FocusEvent("blur", { bubbles: false }));
135 keyButton.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
136 await flushPromises();
137 });
138 ok(!keyButton.classList.contains("shortcuts-settings__key--recording"), "blur cancels the recording state");
139
140 await act(async () => {
141 resetCustomShortcuts();
142 await flushPromises();
143 });
144 const sendButton = container.querySelector<HTMLButtonElement>('[data-shortcut-action="composer.send"]');
145 ok(Boolean(sendButton), "composer send recorder renders");
146 if (!sendButton) throw new Error("no composer send recorder");
147
148 await act(async () => {
149 sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
150 await flushPromises();
151 });
152 await act(async () => {
153 sendButton.dispatchEvent(
154 new KeyboardEvent("keydown", { key: "s", ctrlKey: true, bubbles: true, cancelable: true }),
155 );
156 await flushPromises();
157 });
158 ok(sendButton.classList.contains("shortcuts-settings__key--recording"), "non-Enter key keeps the composer recorder active");
159 ok(!loadCustomShortcuts()["composer.send"], "non-Enter key is not saved for composer send");
160 ok(Boolean(container.querySelector('[role="alert"]')), "non-Enter key shows an Enter-only validation message");
161
162 await act(async () => {
163 sendButton.dispatchEvent(
164 new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }),
165 );
166 await flushPromises();
167 });
168 const savedSend = loadCustomShortcuts()["composer.send"];
169 ok(!sendButton.classList.contains("shortcuts-settings__key--recording"), "Ctrl+Enter completes composer shortcut recording");
170 ok(Boolean(savedSend && savedSend.key === "Enter" && savedSend.ctrl), "Ctrl+Enter is saved for composer send");
171
172 await act(async () => {
173 sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
174 await flushPromises();
175 });
176 await act(async () => {
177 sendButton.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
178 await flushPromises();
179 });
180 ok(!sendButton.classList.contains("shortcuts-settings__key--recording"), "Escape cancels composer shortcut recording");
181 const afterEscape = loadCustomShortcuts()["composer.send"];
182 ok(Boolean(afterEscape && afterEscape.key === "Enter" && afterEscape.ctrl), "Escape preserves the saved composer shortcut");
183
184 await act(async () => {
185 sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
186 await flushPromises();
187 });
188 const tabEvent = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
189 await act(async () => {
190 sendButton.dispatchEvent(tabEvent);
191 await flushPromises();
192 });
193 ok(!tabEvent.defaultPrevented, "Tab remains available for keyboard focus navigation");
194 ok(document.activeElement !== sendButton, "Tab releases focus when native traversal is unavailable");
195 ok(!sendButton.classList.contains("shortcuts-settings__key--recording"), "Tab exits composer shortcut recording");
196 const afterTab = loadCustomShortcuts()["composer.send"];
197 ok(Boolean(afterTab && afterTab.key === "Enter" && afterTab.ctrl), "Tab preserves the saved composer shortcut");
198
199 await act(async () => {
200 resetCustomShortcuts();
201 await flushPromises();
202 });
203 await act(async () => {
204 root.unmount();
205 });
206
207 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
208 if (failed > 0) process.exit(1);
209 }
210
211 main().catch((error) => {
212 console.error(error);
213 process.exit(1);
214 });
215
215 lines Plain Text