| 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 |