返回 DeepSeek-Reasonix
decision-surface.test.tsx
根目录 / desktop / frontend / src / __tests__ / decision-surface.test.tsx
1 // Run: tsx src/__tests__/decision-surface.test.tsx
2 //
3 // Decision surfaces: ordinary approvals stay select-then-confirm while Plan
4 // and Auto boundary cards use immediate buttons; no double submit.
5
6 import { readFileSync } from "node:fs";
7 import { JSDOM } from "jsdom";
8 import React from "react";
9 import { act } from "react";
10 import { createRoot } from "react-dom/client";
11 import { ApprovalModal } from "../components/ApprovalModal";
12 import { ClearContextCard } from "../components/ClearContextCard";
13 import { RuntimeDecisionCard } from "../components/RuntimeDecisionCard";
14 import { LocaleProvider } from "../lib/i18n";
15 import {
16 DECISION_SURFACE_MOCK_TRIGGERS,
17 LONG_DECISION_OPTIONS_MOCK_TRIGGER,
18 decisionSurfaceMockFromInput,
19 isLongDecisionOptionsMockInput,
20 } from "../lib/decisionSurfaceMock";
21 import type { WireApproval } from "../lib/types";
22
23 const styles = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
24
25 let passed = 0;
26 let failed = 0;
27
28 function ok(value: boolean, label: string) {
29 if (value) {
30 process.stdout.write(` PASS ${label}\n`);
31 passed += 1;
32 } else {
33 process.stdout.write(` FAIL ${label}\n`);
34 failed += 1;
35 }
36 }
37
38 function eq(actual: unknown, expected: unknown, label: string) {
39 if (actual === expected) ok(true, label);
40 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
41 }
42
43 function flushTimers(ms = 0): Promise<void> {
44 return new Promise((resolve) => setTimeout(resolve, ms));
45 }
46
47 function installDom(language = "en-US", descriptionOverflows = true) {
48 const dom = new JSDOM("<!doctype html><html><head></head><body><div id=\"root\"></div></body></html>", {
49 pretendToBeVisual: true,
50 url: "http://localhost/",
51 });
52 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
53 globalThis.window = dom.window as unknown as Window & typeof globalThis;
54 globalThis.document = dom.window.document;
55 Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: language });
56 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
57 globalThis.Node = dom.window.Node;
58 globalThis.Element = dom.window.Element;
59 globalThis.HTMLElement = dom.window.HTMLElement;
60 globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
61 globalThis.Event = dom.window.Event;
62 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
63 globalThis.MouseEvent = dom.window.MouseEvent;
64 globalThis.localStorage = dom.window.localStorage;
65 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
66 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
67 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
68 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
69 Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", {
70 configurable: true,
71 get() {
72 return this.classList.contains("prompt-action__desc") ? 42 : 0;
73 },
74 });
75 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", {
76 configurable: true,
77 get() {
78 return this.classList.contains("prompt-action__desc")
79 ? (descriptionOverflows ? 84 : 42)
80 : 0;
81 },
82 });
83 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
84 configurable: true,
85 value() {
86 this.setAttribute("data-scrolled-into-view", "true");
87 },
88 });
89 const style = document.createElement("style");
90 style.textContent = styles;
91 document.head.appendChild(style);
92 return dom;
93 }
94
95 console.log("\ndecision surface");
96
97 eq(Object.keys(DECISION_SURFACE_MOCK_TRIGGERS).length, 8, "all eight product decision surfaces have browser mocks");
98 for (const [kind, trigger] of Object.entries(DECISION_SURFACE_MOCK_TRIGGERS)) {
99 eq(decisionSurfaceMockFromInput(trigger), kind, `${kind} has a distinct canonical browser mock trigger`);
100 }
101 eq(decisionSurfaceMockFromInput("mock 工作区冲突"), "workspace_conflict", "Chinese mock phrases remain convenient for visual QA");
102 eq(decisionSurfaceMockFromInput("/approve-preview"), "tool_approval", "legacy approval preview trigger remains compatible");
103 eq(isLongDecisionOptionsMockInput(LONG_DECISION_OPTIONS_MOCK_TRIGGER), true, "long-option QA has a canonical browser trigger");
104 eq(isLongDecisionOptionsMockInput("mock 长文案选项"), true, "long-option QA has a convenient Chinese trigger");
105 eq(decisionSurfaceMockFromInput(LONG_DECISION_OPTIONS_MOCK_TRIGGER), null, "long-option QA is not counted as a ninth product surface");
106
107 // Plan exposes start, revise, and leave-without-executing as direct buttons so
108 // declining the current plan never traps the user in Plan mode.
109 {
110 const dom = installDom();
111 const root = createRoot(document.getElementById("root")!);
112 const answers: Array<[boolean, boolean, boolean]> = [];
113 const revisions: string[] = [];
114 let exits = 0;
115 const approval: WireApproval = {
116 id: "plan-1",
117 tool: "exit_plan_mode",
118 subject: "Plan ready",
119 };
120
121 await act(async () => {
122 root.render(
123 <LocaleProvider>
124 <ApprovalModal
125 approval={approval}
126 onAnswer={(a, s, p) => answers.push([a, s, p])}
127 onRevisePlan={(text) => revisions.push(text)}
128 onExitPlan={() => { exits += 1; }}
129 onStop={() => undefined}
130 />
131 </LocaleProvider>,
132 );
133 await flushTimers();
134 });
135
136 const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
137 eq(actions.length, 3, "Plan has start, revise, and exit-without-executing actions");
138 eq(document.querySelector(".prompt-shelf__actions")?.getAttribute("role"), "group", "Plan actions use button-group semantics");
139 ok(actions.every((action) => action.getAttribute("role") === "button"), "Plan actions are announced as buttons");
140 ok(!document.querySelector(".decision-confirm-bar__confirm"), "Plan has no redundant confirm button");
141 ok(actions[2].textContent?.includes("Exit without executing"), "Plan card exposes a clear non-executing exit");
142 ok(!document.body.textContent?.includes("Stop task"), "Plan card relies on the global Stop control");
143 const planDescriptionToggle = document.querySelector(".prompt-action-row .prompt-action__description-toggle") as HTMLButtonElement | null;
144 if (!planDescriptionToggle) throw new Error("Plan description disclosure did not render");
145 await act(async () => {
146 planDescriptionToggle.click();
147 await flushTimers();
148 });
149 eq(answers.length, 0, "expanding a Plan description never starts execution");
150 eq(planDescriptionToggle.getAttribute("aria-expanded"), "true", "Plan disclosure announces its expanded state");
151
152 await act(async () => {
153 actions[1].click();
154 await flushTimers();
155 });
156 ok(document.querySelector(".plan-revision__input") != null, "Revise opens the inline editor in one click");
157 eq(answers.length, 0, "Opening revision does not start execution");
158
159 await act(async () => {
160 actions[2].click();
161 actions[2].click();
162 await flushTimers(220);
163 });
164 eq(exits, 1, "Exit without executing runs once and ignores a double click");
165 eq(answers.length, 0, "Exit without executing never approves plan execution");
166 eq(revisions.length, 0, "Exit without executing does not submit a plan revision");
167
168 await act(async () => {
169 root.unmount();
170 });
171 dom.window.close();
172 }
173
174 {
175 const dom = installDom();
176 const root = createRoot(document.getElementById("root")!);
177 let exits = 0;
178
179 await act(async () => {
180 root.render(
181 <LocaleProvider>
182 <ApprovalModal
183 approval={{ id: "plan-exit-key", tool: "exit_plan_mode", subject: "Plan ready" }}
184 onAnswer={() => undefined}
185 onExitPlan={() => { exits += 1; }}
186 onStop={() => undefined}
187 />
188 </LocaleProvider>,
189 );
190 await flushTimers();
191 });
192
193 await act(async () => {
194 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true }));
195 await flushTimers(220);
196 });
197 eq(exits, 1, "number key 3 exits Plan without execution");
198
199 await act(async () => {
200 root.unmount();
201 });
202 dom.window.close();
203 }
204
205 {
206 const dom = installDom();
207 const root = createRoot(document.getElementById("root")!);
208 const answers: Array<[boolean, boolean, boolean]> = [];
209
210 await act(async () => {
211 root.render(
212 <LocaleProvider>
213 <ApprovalModal
214 approval={{ id: "plan-start", tool: "exit_plan_mode", subject: "Plan ready" }}
215 onAnswer={(a, s, p) => answers.push([a, s, p])}
216 onStop={() => undefined}
217 />
218 </LocaleProvider>,
219 );
220 await flushTimers();
221 });
222
223 const start = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")]
224 .find((action) => action.textContent?.includes("Start execution")) as HTMLButtonElement;
225 await act(async () => {
226 start.click();
227 start.click();
228 await flushTimers(220);
229 });
230 eq(answers.length, 1, "Plan starts with one click and ignores double submit");
231 eq(JSON.stringify(answers[0]), JSON.stringify([true, false, false]), "Plan start approves execution once");
232
233 await act(async () => {
234 root.unmount();
235 });
236 dom.window.close();
237 }
238
239 // Tool approval: click only selects; confirm submits once; double-confirm ignored.
240 {
241 const dom = installDom();
242 const root = createRoot(document.getElementById("root")!);
243 const answers: Array<[boolean, boolean, boolean]> = [];
244 const approval: WireApproval = {
245 id: "bash-1",
246 tool: "bash",
247 subject: "ls -la",
248 };
249
250 await act(async () => {
251 root.render(
252 <LocaleProvider>
253 <ApprovalModal
254 approval={approval}
255 onAnswer={(a, s, p) => answers.push([a, s, p])}
256 onStop={() => undefined}
257 />
258 </LocaleProvider>,
259 );
260 await flushTimers();
261 });
262
263 const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
264 eq(actions.length, 3, "ordinary tool approval has once, session-scoped, and deny options");
265 ok(actions[0].classList.contains("prompt-action--selected"), "default selection is allow once");
266 ok(Boolean(actions[0].title), "tool approval keeps the complete option description in a desktop tooltip");
267 const toolDescriptionToggle = document.querySelector(".prompt-shelf__footnote .prompt-action__description-toggle") as HTMLButtonElement | null;
268 if (!toolDescriptionToggle) throw new Error("tool approval description disclosure did not render");
269 const toolContent = document.querySelector(".prompt-shelf__content") as HTMLElement | null;
270 const toolFooter = document.querySelector(".prompt-shelf__footer") as HTMLElement | null;
271 if (!toolContent || !toolFooter) throw new Error("tool approval scroll layout did not render");
272 eq(window.getComputedStyle(toolContent).overflow, "auto", "tool approval uses the shared decision scroller");
273 eq(toolContent.contains(toolFooter), false, "tool approval confirm footer stays visible outside the scroller");
274 await act(async () => {
275 toolDescriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
276 key: "Enter",
277 bubbles: true,
278 cancelable: true,
279 }));
280 await flushTimers();
281 });
282 eq(answers.length, 0, "Enter on tool disclosure never approves the default permission");
283 eq(toolDescriptionToggle.getAttribute("aria-expanded"), "true", "Enter expands tool approval copy");
284 await act(async () => {
285 toolDescriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
286 key: "Enter",
287 bubbles: true,
288 cancelable: true,
289 }));
290 await flushTimers();
291 });
292 eq(toolDescriptionToggle.getAttribute("aria-expanded"), "false", "Enter collapses tool approval copy again");
293 await act(async () => {
294 toolDescriptionToggle.click();
295 await flushTimers();
296 });
297 eq(answers.length, 0, "expanding tool approval copy never submits the selected permission");
298 eq(toolDescriptionToggle.getAttribute("aria-expanded"), "true", "tool approval disclosure announces its expanded state");
299
300 await act(async () => {
301 actions[2].click();
302 await flushTimers();
303 });
304 eq(answers.length, 0, "clicking deny only selects");
305 ok(actions[2].classList.contains("prompt-action--selected"), "deny becomes selected");
306
307 const confirm = document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement;
308 await act(async () => {
309 confirm.click();
310 confirm.click();
311 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
312 await flushTimers(220);
313 });
314 eq(answers.length, 1, "double click/enter submits only once");
315 eq(JSON.stringify(answers[0]), JSON.stringify([false, false, false]), "deny maps to (false,false,false)");
316
317 await act(async () => {
318 root.unmount();
319 });
320 dom.window.close();
321 }
322
323 // Auto Guard recovery decisions are retired. Old payloads remain visible as
324 // history, with no confirmation, retry, task grant, or guidance action.
325 {
326 const dom = installDom();
327 const root = createRoot(document.getElementById("root")!);
328 const decisions: Array<{ action: string; feedback?: string }> = [];
329 const approval: WireApproval = {
330 id: "guard-history",
331 tool: "bash",
332 subject: "git push origin feature",
333 kind: "recovery",
334 recovery: {
335 next_action: "git push origin feature",
336 change_kind: "risk",
337 can_grant_task: true,
338 task_grant_scope: "git push origin → feature",
339 failed_summary: "The previous result was not recorded.",
340 },
341 };
342
343 await act(async () => {
344 root.render(
345 <LocaleProvider>
346 <ApprovalModal
347 approval={approval}
348 onAnswer={() => undefined}
349 onResolveRecovery={(action, feedback) => decisions.push({ action, feedback })}
350 onStop={() => undefined}
351 />
352 </LocaleProvider>,
353 );
354 await flushTimers();
355 });
356
357 ok(document.body.textContent?.includes("Historical recovery record"), "old recovery payload is labeled as history");
358 ok(document.body.textContent?.includes("retired recovery mechanism"), "history explains that the recovery gate is retired");
359 ok(document.body.textContent?.includes("git push origin feature"), "historical action remains visible");
360 eq(document.querySelectorAll(".prompt-shelf__actions .prompt-action").length, 0, "history exposes no decision actions");
361 ok(!document.querySelector(".recovery-task-grant"), "history exposes no task grant");
362 ok(!document.querySelector(".recovery-guidance-trigger"), "history exposes no recovery guidance control");
363 eq(decisions.length, 0, "rendering history never resolves or replays the old action");
364
365 await act(async () => root.unmount());
366 dom.window.close();
367 }
368
369 // A fresh decision for a dynamic tool is one-shot.
370 {
371 const dom = installDom();
372 const root = createRoot(document.getElementById("root")!);
373 const answers: Array<[boolean, boolean, boolean]> = [];
374 const approval: WireApproval = {
375 id: "dynamic-danger-1",
376 tool: "extension__wipe",
377 subject: "Dynamic tool declares destructive side effects",
378 reason: "Review the target and arguments before allowing this call.",
379 fresh: true,
380 };
381
382 await act(async () => {
383 root.render(
384 <LocaleProvider>
385 <ApprovalModal
386 approval={approval}
387 onAnswer={(a, s, p) => answers.push([a, s, p])}
388 onStop={() => undefined}
389 />
390 </LocaleProvider>,
391 );
392 await flushTimers();
393 });
394
395 const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
396 eq(actions.length, 2, "fresh dynamic-tool decision only offers allow once and deny");
397 ok(!document.body.textContent?.includes("Always allow"), "fresh dynamic-tool decision hides remembered grants");
398
399 const confirm = document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement;
400 await act(async () => {
401 confirm.click();
402 await flushTimers(220);
403 });
404 eq(JSON.stringify(answers[0]), JSON.stringify([true, false, false]), "fresh dynamic-tool decision is one-shot");
405
406 await act(async () => {
407 root.unmount();
408 });
409 dom.window.close();
410 }
411
412 // Runtime decisions expose direct actions, so their displayed number keys must
413 // invoke the same callbacks while preserving normal typing behavior.
414 {
415 const dom = installDom();
416 const root = createRoot(document.getElementById("root")!);
417 const selected: string[] = [];
418 let cancelled = 0;
419
420 await act(async () => {
421 root.render(
422 <LocaleProvider>
423 <RuntimeDecisionCard
424 id="runtime-shortcuts"
425 title="Choose runtime action"
426 badge="Runtime"
427 meta="Select an action"
428 actions={["1", "2", "3"].map((key) => ({
429 key,
430 label: `Action ${key}`,
431 description: `Run action ${key} across a deliberately long runtime boundary that must wrap without overlapping the next decision row.`,
432 danger: key === "3",
433 onClick: () => selected.push(key),
434 }))}
435 onCancel={() => { cancelled += 1; }}
436 />
437 </LocaleProvider>,
438 );
439 await flushTimers();
440 });
441
442 const runtimeActions = document.querySelector(".prompt-shelf__actions") as HTMLElement | null;
443 const runtimeAction = document.querySelector(".prompt-action") as HTMLElement | null;
444 const runtimeActionKey = runtimeAction?.querySelector(".prompt-action__key") as HTMLElement | null;
445 const runtimeActionLabel = runtimeAction?.querySelector(".prompt-action__label") as HTMLElement | null;
446 const runtimeActionDescription = runtimeAction?.querySelector(".prompt-action__desc") as HTMLElement | null;
447 if (!runtimeActions || !runtimeAction || !runtimeActionKey || !runtimeActionLabel || !runtimeActionDescription) {
448 throw new Error("runtime decision layout did not render");
449 }
450
451 const runtimeActionsStyle = window.getComputedStyle(runtimeActions);
452 eq(runtimeActionsStyle.gridAutoRows, "max-content", "decision row wrappers accommodate external details");
453 eq(runtimeActionsStyle.alignContent, "start", "all decision rows stay aligned at the top of the scroll region");
454
455 const runtimeActionStyle = window.getComputedStyle(runtimeAction);
456 eq(runtimeActionStyle.height, "38px", "shared decision rows keep a stable desktop height");
457 eq(runtimeActionStyle.minHeight, "38px", "shared decision rows retain a compact click target");
458 eq(runtimeActionStyle.alignItems, "center", "single-line decision copy stays vertically centered with the option key");
459 eq(window.getComputedStyle(runtimeActionKey).marginTop, "0px", "decision keys do not carry a top offset");
460 eq(window.getComputedStyle(runtimeActionLabel).fontWeight, "620", "decision labels keep a clear visual hierarchy");
461
462 const runtimeDescriptionStyle = window.getComputedStyle(runtimeActionDescription);
463 eq(runtimeDescriptionStyle.whiteSpace, "nowrap", "shared decision descriptions stay on one summary line");
464 eq(runtimeDescriptionStyle.display, "block", "supplementary runtime copy uses ordinary single-line flow");
465 eq(runtimeDescriptionStyle.overflow, "hidden", "collapsed runtime summaries stay inside their row");
466 eq(runtimeDescriptionStyle.textOverflow, "ellipsis", "long runtime summaries end with an ellipsis");
467 eq(runtimeDescriptionStyle.lineHeight, "1.4", "runtime summaries keep readable density");
468 const runtimeDescriptionToggle = document.querySelector(".prompt-action-row .prompt-action__description-toggle") as HTMLButtonElement | null;
469 if (!runtimeDescriptionToggle) throw new Error("runtime description disclosure did not render");
470 eq(runtimeDescriptionToggle.getAttribute("aria-expanded"), "false", "runtime description starts collapsed");
471 await act(async () => {
472 runtimeDescriptionToggle.click();
473 await flushTimers();
474 });
475 eq(selected.length, 0, "expanding runtime details never triggers the decision action");
476 eq(runtimeDescriptionToggle.getAttribute("aria-expanded"), "true", "runtime description expansion is announced");
477 eq(window.getComputedStyle(runtimeAction).alignItems, "center", "expanded details keep the runtime row vertically centered");
478 eq(window.getComputedStyle(runtimeActionDescription).overflow, "hidden", "expanded details do not alter the runtime row summary");
479 const runtimeDetail = runtimeAction.closest(".prompt-action-row")?.querySelector(".prompt-description-detail") as HTMLElement | null;
480 if (!runtimeDetail) throw new Error("runtime detail region did not render");
481 eq(runtimeDetail.hidden, false, "runtime full copy opens in a separate detail region");
482 eq(runtimeDetail.textContent?.includes("deliberately long runtime boundary"), true, "runtime detail region reveals the full copy");
483 const runtimeActionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
484 const dangerousRuntimeDescription = runtimeActionButtons[2]?.querySelector(".prompt-action__desc") as HTMLElement | null;
485 if (!dangerousRuntimeDescription) throw new Error("dangerous runtime description did not render");
486 const dangerousRuntimeDetail = runtimeActionButtons[2]
487 ?.closest(".prompt-action-row")
488 ?.querySelector(".prompt-description-detail") as HTMLElement | null;
489 if (!dangerousRuntimeDetail) throw new Error("dangerous runtime detail did not render");
490 eq(window.getComputedStyle(dangerousRuntimeDescription).overflow, "hidden", "dangerous runtime summary keeps the shared stable row");
491 eq(dangerousRuntimeDetail.hidden, false, "truncated dangerous runtime consequences open automatically outside the row");
492
493 await act(async () => {
494 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "1", bubbles: true }));
495 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true }));
496 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true }));
497 await flushTimers();
498 });
499 eq(selected.join(","), "1,2,3", "runtime decision number keys invoke their displayed actions");
500
501 await act(async () => {
502 root.render(
503 <LocaleProvider>
504 <RuntimeDecisionCard
505 id="runtime-shortcuts"
506 title="Choose runtime action"
507 badge="Runtime"
508 meta="Select an action"
509 actions={[{
510 key: "2", label: "Unavailable action", description: "Cannot run yet",
511 disabled: true, onClick: () => selected.push("disabled"),
512 }]}
513 onCancel={() => { cancelled += 1; }}
514 />
515 </LocaleProvider>,
516 );
517 await flushTimers();
518 });
519 await act(async () => {
520 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true }));
521 await flushTimers();
522 });
523 eq(selected.join(","), "1,2,3", "runtime decision shortcuts do not invoke disabled actions");
524
525 const input = document.createElement("input");
526 document.body.append(input);
527 await act(async () => {
528 input.dispatchEvent(new window.KeyboardEvent("keydown", { key: "1", bubbles: true }));
529 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
530 await flushTimers();
531 });
532 eq(selected.join(","), "1,2,3", "runtime decision shortcuts ignore editable fields");
533 eq(cancelled, 1, "Escape cancels the runtime decision");
534
535 await act(async () => {
536 root.unmount();
537 });
538 dom.window.close();
539 }
540
541 // A complete danger summary is already the full safety explanation. It must
542 // not be duplicated below the stable action row merely because it is dangerous.
543 {
544 const dom = installDom("en-US", false);
545 const root = createRoot(document.getElementById("root")!);
546
547 await act(async () => {
548 root.render(
549 <LocaleProvider>
550 <RuntimeDecisionCard
551 id="runtime-danger-complete"
552 title="Stop jobs"
553 badge="2 running"
554 meta="Stopping is required"
555 actions={[{
556 key: "1",
557 label: "Stop jobs and switch",
558 description: "Wait for the processes to exit, then switch modes.",
559 danger: true,
560 onClick: () => {},
561 }]}
562 onCancel={() => {}}
563 />
564 </LocaleProvider>,
565 );
566 await flushTimers();
567 });
568
569 eq(document.querySelectorAll(".prompt-action").length, 1, "complete dangerous runtime summary renders one action row");
570 eq(document.querySelector(".prompt-description-detail"), null, "complete dangerous runtime summary is not repeated below the row");
571 eq(document.querySelector(".prompt-action__description-toggle"), null, "complete dangerous runtime summary needs no redundant disclosure");
572
573 await act(async () => {
574 root.unmount();
575 });
576 dom.window.close();
577 }
578
579 // Clear context: default cancel; clear requires explicit confirm; Escape cancels.
580 {
581 const dom = installDom();
582 const root = createRoot(document.getElementById("root")!);
583 let cancelled = 0;
584 let confirmed = 0;
585
586 await act(async () => {
587 root.render(
588 <LocaleProvider>
589 <ClearContextCard onCancel={() => { cancelled += 1; }} onConfirm={() => { confirmed += 1; }} />
590 </LocaleProvider>,
591 );
592 await flushTimers();
593 });
594
595 const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
596 ok(actions[0].classList.contains("prompt-action--selected"), "clear context defaults to cancel");
597 eq(
598 document.querySelector(".prompt-shelf__meta")?.textContent,
599 "This deletes the current transcript from local history and keeps only the system prompt.",
600 "clear context combines the consequence into one header summary",
601 );
602 ok(document.querySelector(".prompt-shelf__body") == null, "clear context does not repeat the consequence in a second body row");
603 const clearDescriptionToggle = document.querySelector(".prompt-shelf__footnote .prompt-action__description-toggle") as HTMLButtonElement | null;
604 if (!clearDescriptionToggle) throw new Error("clear-context description disclosure did not render");
605 await act(async () => {
606 clearDescriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
607 key: "Enter",
608 bubbles: true,
609 cancelable: true,
610 }));
611 await flushTimers();
612 });
613 eq(cancelled, 0, "Enter on clear-context disclosure does not trigger the safe default");
614 eq(confirmed, 0, "Enter on clear-context disclosure never clears the conversation");
615 eq(clearDescriptionToggle.getAttribute("aria-expanded"), "true", "Enter expands clear-context copy");
616 await act(async () => {
617 clearDescriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
618 key: "Enter",
619 bubbles: true,
620 cancelable: true,
621 }));
622 await flushTimers();
623 });
624 eq(clearDescriptionToggle.getAttribute("aria-expanded"), "false", "Enter collapses clear-context copy again");
625 await act(async () => {
626 clearDescriptionToggle.click();
627 await flushTimers();
628 });
629 eq(cancelled, 0, "expanding clear-context copy does not trigger the safe default");
630 eq(confirmed, 0, "expanding clear-context copy does not clear anything");
631
632 await act(async () => {
633 actions[1].click();
634 await flushTimers();
635 });
636 eq(confirmed, 0, "clicking clear only selects");
637 ok(actions[1].classList.contains("prompt-action--selected"), "clear option becomes selected");
638 const dangerousClearDetail = document.querySelector(".prompt-shelf__footnote .prompt-description-detail") as HTMLElement | null;
639 if (!dangerousClearDetail) throw new Error("dangerous clear-context detail did not render");
640 eq(dangerousClearDetail.hidden, false, "truncated destructive consequence opens automatically after selection");
641 eq(document.querySelector(".prompt-shelf__footnote .prompt-action__description-toggle"), null, "auto-open destructive consequence has no redundant toggle");
642
643 await act(async () => {
644 (document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement).click();
645 await flushTimers();
646 });
647 eq(confirmed, 1, "confirm runs clear once");
648
649 await act(async () => {
650 root.unmount();
651 });
652 dom.window.close();
653 }
654
655 {
656 const dom = installDom();
657 const root = createRoot(document.getElementById("root")!);
658 let cancelled = 0;
659
660 await act(async () => {
661 root.render(
662 <LocaleProvider>
663 <ClearContextCard onCancel={() => { cancelled += 1; }} onConfirm={() => undefined} />
664 </LocaleProvider>,
665 );
666 await flushTimers();
667 });
668
669 await act(async () => {
670 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
671 await flushTimers();
672 });
673 eq(cancelled, 1, "Escape cancels clear context immediately");
674
675 await act(async () => {
676 root.unmount();
677 });
678 dom.window.close();
679 }
680
681 // Composer decision host stays in the tree while visually hidden.
682 {
683 const dom = installDom();
684 const root = createRoot(document.getElementById("root")!);
685 await act(async () => {
686 root.render(
687 <div>
688 <div className="composer-decision-host composer-decision-host--hidden" hidden inert aria-hidden="true">
689 <textarea id="composer-input" defaultValue="draft text" />
690 </div>
691 </div>,
692 );
693 await flushTimers();
694 });
695
696 const host = document.querySelector(".composer-decision-host") as HTMLElement;
697 const input = document.getElementById("composer-input") as HTMLTextAreaElement;
698 ok(host != null, "composer decision host remains mounted");
699 ok(host.hasAttribute("hidden"), "host is hidden during decision");
700 ok(host.hasAttribute("inert") || (host as HTMLElement & { inert?: boolean }).inert === true, "host is inert during decision");
701 eq(input.value, "draft text", "draft value survives while host is hidden");
702 eq(host.getAttribute("aria-hidden"), "true", "host is aria-hidden during decision");
703
704 await act(async () => {
705 root.unmount();
706 });
707 dom.window.close();
708 }
709
710 // New approval id resets selection and submitting state.
711 {
712 const dom = installDom();
713 const root = createRoot(document.getElementById("root")!);
714 const answers: Array<[boolean, boolean, boolean]> = [];
715 let approval: WireApproval = { id: "a1", tool: "bash", subject: "echo 1" };
716
717 const paint = async (next: WireApproval) => {
718 approval = next;
719 await act(async () => {
720 root.render(
721 <LocaleProvider>
722 <ApprovalModal
723 approval={approval}
724 onAnswer={(a, s, p) => answers.push([a, s, p])}
725 onStop={() => undefined}
726 />
727 </LocaleProvider>,
728 );
729 await flushTimers();
730 });
731 };
732
733 await paint(approval);
734 const actions = () => [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
735 await act(async () => {
736 actions()[2].click();
737 await flushTimers();
738 });
739 ok(actions()[2].classList.contains("prompt-action--selected"), "deny selected on first prompt");
740
741 await paint({ id: "a2", tool: "bash", subject: "echo 2" });
742 ok(actions()[0].classList.contains("prompt-action--selected"), "new prompt id resets selection to allow once");
743 eq(answers.length, 0, "selection reset does not submit");
744
745 await act(async () => {
746 root.unmount();
747 });
748 dom.window.close();
749 }
750
751 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
752 if (failed > 0) process.exit(1);
753
753 lines Plain Text