返回 DeepSeek-Reasonix
approval-animation.test.tsx
根目录 / desktop / frontend / src / __tests__ / approval-animation.test.tsx
1 // Run: tsx src/__tests__/approval-animation.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React from "react";
5 import { act } from "react";
6 import { createRoot, type Root } from "react-dom/client";
7 import { ApprovalModal } from "../components/ApprovalModal";
8 import { AskCard } from "../components/AskCard";
9 import { LocaleProvider } from "../lib/i18n";
10
11 let passed = 0;
12 let failed = 0;
13
14 type SubmittedAnswer = [allow: boolean, session: boolean, persist: boolean];
15 type ControllableAnimation = {
16 onfinish: (() => void) | null;
17 oncancel: (() => void) | null;
18 };
19
20 function ok(value: boolean, label: string) {
21 if (value) {
22 process.stdout.write(` PASS ${label}\n`);
23 passed += 1;
24 } else {
25 process.stdout.write(` FAIL ${label}\n`);
26 failed += 1;
27 }
28 }
29
30 function eq(actual: unknown, expected: unknown, label: string) {
31 if (actual === expected) ok(true, label);
32 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
33 }
34
35 function flushTimers(ms = 0): Promise<void> {
36 return new Promise((resolve) => setTimeout(resolve, ms));
37 }
38
39 function installDom() {
40 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
41 pretendToBeVisual: true,
42 url: "http://localhost/",
43 });
44 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
45 globalThis.window = dom.window as unknown as Window & typeof globalThis;
46 globalThis.document = dom.window.document;
47 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
48 globalThis.Node = dom.window.Node;
49 globalThis.Element = dom.window.Element;
50 globalThis.HTMLElement = dom.window.HTMLElement;
51 globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
52 globalThis.Event = dom.window.Event;
53 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
54 globalThis.MouseEvent = dom.window.MouseEvent;
55 globalThis.localStorage = dom.window.localStorage;
56 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
57 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
58 globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
59 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
60 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
61 return dom;
62 }
63
64 function mockNativeAnimate(
65 dom: JSDOM,
66 implementation: (options: KeyframeAnimationOptions) => ControllableAnimation,
67 ) {
68 Object.defineProperty(dom.window.Element.prototype, "animate", {
69 configurable: true,
70 value: (_frames: Keyframe[] | PropertyIndexedKeyframes | null, options: number | KeyframeAnimationOptions) => {
71 if (typeof options === "number") throw new TypeError("expected keyframe animation options");
72 return implementation(options) as unknown as Animation;
73 },
74 });
75 }
76
77 async function renderToolApproval(onAnswer: (...answer: SubmittedAnswer) => void | Promise<void>, onStop: () => void | Promise<void> = () => undefined) {
78 const root = createRoot(document.getElementById("root")!);
79 await act(async () => {
80 root.render(
81 <LocaleProvider>
82 <ApprovalModal
83 approval={{ id: "approval-animation", tool: "bash", subject: "echo safe" }}
84 onAnswer={onAnswer}
85 onStop={onStop}
86 />
87 </LocaleProvider>,
88 );
89 await flushTimers();
90 });
91 return root;
92 }
93
94 async function confirmSelectedAction() {
95 const confirm = document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement | null;
96 if (!confirm) throw new Error("approval confirm button did not render");
97 await act(async () => {
98 confirm.click();
99 await flushTimers();
100 });
101 }
102
103 async function cleanup(root: Root, dom: JSDOM) {
104 await act(async () => {
105 root.unmount();
106 });
107 dom.window.close();
108 }
109
110 console.log("\napproval shelf animation");
111
112 // A real Web Animations implementation validates easing synchronously. The
113 // decision starts immediately; the transition has no business ownership.
114 {
115 const dom = installDom();
116 const answers: SubmittedAnswer[] = [];
117 const animations: ControllableAnimation[] = [];
118 let easing: string | undefined;
119 mockNativeAnimate(dom, (options) => {
120 easing = options.easing;
121 if (typeof easing !== "string" || easing.includes("power")) {
122 throw new TypeError(`${String(easing)} is not a valid CSS easing`);
123 }
124 const animation: ControllableAnimation = { onfinish: null, oncancel: null };
125 animations.push(animation);
126 return animation;
127 });
128
129 const root = await renderToolApproval((...answer) => answers.push(answer));
130 await confirmSelectedAction();
131
132 eq(easing, "cubic-bezier(0.8, 0, 0.8, 0.28)", "shelf exit passes a valid CSS easing to Element.animate");
133 eq(answers.length, 1, "approval submits before the shelf exit animation finishes");
134 eq(animations.length, 1, "approval starts one shelf exit animation");
135
136 await act(async () => {
137 animations[0].onfinish?.();
138 animations[0].oncancel?.();
139 await flushTimers();
140 });
141 eq(answers.length, 1, "finish and late cancel do not resubmit the approval");
142 eq(JSON.stringify(answers[0]), JSON.stringify([true, false, false]), "finished animation preserves the selected approval");
143
144 await cleanup(root, dom);
145 }
146
147 // Animation cancellation must not discard a decision that the user already
148 // confirmed.
149 {
150 const dom = installDom();
151 const answers: SubmittedAnswer[] = [];
152 const animation: ControllableAnimation = { onfinish: null, oncancel: null };
153 mockNativeAnimate(dom, () => animation);
154
155 const root = await renderToolApproval((...answer) => answers.push(answer));
156 await confirmSelectedAction();
157 await act(async () => {
158 animation.oncancel?.();
159 await flushTimers();
160 });
161
162 eq(answers.length, 1, "cancelled shelf animation still submits the approval once");
163 await cleanup(root, dom);
164 }
165
166 // The transition is cosmetic. Even a synchronously rejecting WebView must not
167 // block the underlying approval RPC.
168 {
169 const dom = installDom();
170 const answers: SubmittedAnswer[] = [];
171 let attempts = 0;
172 mockNativeAnimate(dom, () => {
173 attempts += 1;
174 throw new TypeError("WebView rejected the animation options");
175 });
176
177 const root = await renderToolApproval((...answer) => answers.push(answer));
178 await confirmSelectedAction();
179 await confirmSelectedAction();
180
181 eq(attempts, 1, "a rejected animation is not retried by a second confirm");
182 eq(answers.length, 1, "a rejected animation falls back to one approval submission");
183 eq(JSON.stringify(answers[0]), JSON.stringify([true, false, false]), "animation fallback preserves the selected approval");
184
185 await cleanup(root, dom);
186 }
187
188 // A pending decision must never trap the user in the approval shelf.
189 for (const viaKeyboard of [false, true]) {
190 const dom = installDom();
191 let stops = 0;
192 let reject!: (error: Error) => void;
193 const pending = new Promise<void>((_resolve, fail) => { reject = fail; });
194 const root = await renderToolApproval(() => pending, () => { stops++; });
195 await confirmSelectedAction();
196 const stop = document.querySelector('[aria-label="Stop task"]') as HTMLButtonElement;
197 ok(!stop.disabled, "stop stays enabled while a decision is in flight");
198 await act(async () => {
199 if (viaKeyboard) document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
200 else stop.click();
201 });
202 eq(stops, 1, `${viaKeyboard ? "Escape" : "stop button"} cancels without switching sessions`);
203 await act(async () => { reject(new Error("cancelled decision")); await flushTimers(); });
204 await cleanup(root, dom);
205 }
206
207 // Replay and replacement preserve the right request's lock, even when a
208 // previous transport fails after the next same-id request has mounted.
209 {
210 const dom = installDom();
211 const root = createRoot(document.getElementById("root")!);
212 const requests: Array<{ reject: (error: Error) => void }> = [];
213 const onAnswer = () => new Promise<void>((_resolve, reject) => requests.push({ reject }));
214 const paint = async (epoch: string) => act(async () => {
215 root.render(<LocaleProvider><ApprovalModal
216 approval={{ id: "1", tool: "write_file", subject: "animation.html", kind: "write_access",
217 turnId: "turn", runtimeEpoch: epoch, write_access: { directories: ["/tmp/render-tools"] } }}
218 onAnswer={onAnswer} onStop={() => undefined} /></LocaleProvider>);
219 });
220 const confirm = () => document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement;
221 await paint("old");
222 await confirmSelectedAction();
223 await paint("old");
224 ok(confirm().disabled, "replaying the same write request cannot duplicate an in-flight answer");
225 await act(async () => { requests[0].reject(new Error("transport failed")); await flushTimers(); });
226 ok(!confirm().disabled, "failed answer releases the replayed card without navigating away");
227 ok(Boolean(document.querySelector('[role="alert"]')), "failed answer is visible on the card");
228 await confirmSelectedAction();
229 eq(requests.length, 2, "the second confirmation retries the same request");
230 await paint("new");
231 ok(!confirm().disabled, "a reused prompt id in a new runtime has fresh submission state");
232 await confirmSelectedAction();
233 await act(async () => { requests[1].reject(new Error("old request failed late")); await flushTimers(); });
234 ok(confirm().disabled, "late failure from the previous runtime cannot unlock the new decision");
235 await act(async () => { requests[2].reject(new Error("new request failed")); await flushTimers(); });
236 ok(!confirm().disabled, "the current request's failure unlocks only its own card");
237 await cleanup(root, dom);
238 }
239
240 // Stop must capture its source during the click, before a committed-command
241 // callback can observe navigation in a later microtask.
242 {
243 const dom = installDom();
244 let active = "A";
245 const calls: string[] = [];
246 let reject!: (error: Error) => void;
247 const root = await renderToolApproval(() => {}, () => {
248 calls.push(active);
249 return new Promise<void>((_resolve, fail) => { reject = fail; });
250 });
251 const stop = () => document.querySelector('[aria-label="Stop task"]') as HTMLButtonElement;
252 await act(async () => { stop().click(); active = "B"; });
253 eq(calls[0], "A", "stop captures the clicked session before navigation");
254 await act(async () => {
255 document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
256 });
257 eq(calls.length, 1, "pending stop deduplicates keyboard cancellation");
258 await act(async () => { reject(new Error("stop failed")); });
259 ok(!stop().disabled, "failed stop releases the stop lock");
260 ok(Boolean(document.querySelector('[role="alert"]')), "failed stop is visible");
261 await cleanup(root, dom);
262 }
263
264 // Ask uses the same shelf and must retain the independent cancellation path.
265 {
266 const dom = installDom();
267 let stops = 0;
268 const root = createRoot(document.getElementById("root")!);
269 await act(async () => root.render(<LocaleProvider><AskCard
270 ask={{ id: "ask", questions: [{ id: "q", prompt: "Choose", options: [{ label: "A" }] }] }}
271 draftScope="ask-stop" onAnswer={() => new Promise<void>(() => {})} onStop={() => { stops++; }}
272 /></LocaleProvider>));
273 await act(async () => (document.querySelector('.prompt-action') as HTMLButtonElement).click());
274 await confirmSelectedAction();
275 const stop = document.querySelector('[aria-label="Stop task"]') as HTMLButtonElement;
276 ok(!stop.disabled, "question stop stays enabled during answer submission");
277 await act(async () => stop.click());
278 eq(stops, 1, "question cancellation does not wait for its answer RPC");
279 await cleanup(root, dom);
280 }
281
282 // Stop during an Ask submission owns a separate lock: repeated clicks and
283 // Escape must not dispatch duplicate cancellation requests.
284 {
285 const dom = installDom();
286 let stops = 0;
287 const root = createRoot(document.getElementById("root")!);
288 await act(async () => root.render(<LocaleProvider><AskCard
289 ask={{ id: "ask", questions: [{ id: "q", prompt: "Choose", options: [{ label: "A" }] }] }}
290 draftScope="ask-stop-dedup" onAnswer={() => new Promise<void>(() => {})}
291 onStop={() => { stops++; return new Promise<void>(() => {}); }}
292 /></LocaleProvider>));
293 await confirmSelectedAction();
294 await act(async () => {
295 const stop = document.querySelector('[aria-label="Stop task"]') as HTMLButtonElement;
296 stop.click();
297 stop.click();
298 document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
299 });
300 eq(stops, 1, "question stop deduplicates clicks and Escape while pending");
301 await cleanup(root, dom);
302 }
303
304 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
305 if (failed > 0) process.exit(1);
306
306 lines Plain Text