返回 DeepSeek-Reasonix
composer-run-strip.test.tsx
根目录 / desktop / frontend / src / __tests__ / composer-run-strip.test.tsx
1 // Run: tsx src/__tests__/composer-run-strip.test.tsx
2 //
3 // The run state lives inside the composer card (no floating pill, no layout
4 // jump), stop has a fixed home next to send, and a pending approval/ask shifts
5 // the strip into a waiting state instead of a ticking "working" spinner.
6
7 import { readFileSync } from "node:fs";
8 import { dirname, resolve } from "node:path";
9 import { fileURLToPath } from "node:url";
10 import { JSDOM } from "jsdom";
11 import React from "react";
12 import { act } from "react";
13 import { createRoot } from "react-dom/client";
14 import { Composer } from "../components/Composer";
15 import { LocaleProvider } from "../lib/i18n";
16 import { ToastProvider } from "../lib/toast";
17 import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types";
18
19 let passed = 0;
20 let failed = 0;
21
22 function ok(value: boolean, label: string) {
23 if (value) {
24 process.stdout.write(` PASS ${label}\n`);
25 passed += 1;
26 } else {
27 process.stdout.write(` FAIL ${label}\n`);
28 failed += 1;
29 }
30 }
31
32 function eq(actual: unknown, expected: unknown, label: string) {
33 if (actual === expected) ok(true, label);
34 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
35 }
36
37 function flushTimers(): Promise<void> {
38 return new Promise((resolve) => setTimeout(resolve, 0));
39 }
40
41 class TestResizeObserver {
42 observe() {}
43 unobserve() {}
44 disconnect() {}
45 }
46
47 function installDom() {
48 const dom = new JSDOM("<!doctype html><html><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(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
56 globalThis.Node = dom.window.Node;
57 globalThis.HTMLElement = dom.window.HTMLElement;
58 globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
59 globalThis.Event = dom.window.Event;
60 globalThis.CustomEvent = dom.window.CustomEvent;
61 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
62 globalThis.InputEvent = dom.window.InputEvent;
63 globalThis.MouseEvent = dom.window.MouseEvent;
64 globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
65 globalThis.MutationObserver = dom.window.MutationObserver;
66 globalThis.File = dom.window.File;
67 globalThis.FileReader = dom.window.FileReader;
68 globalThis.localStorage = dom.window.localStorage;
69 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
70 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
71 globalThis.ResizeObserver = TestResizeObserver;
72 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
73 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
74 Object.defineProperty(window, "matchMedia", {
75 configurable: true,
76 value: () => ({
77 matches: true,
78 media: "(prefers-reduced-motion: reduce)",
79 onchange: null,
80 addEventListener() {},
81 removeEventListener() {},
82 addListener() {},
83 removeListener() {},
84 dispatchEvent: () => false,
85 }),
86 });
87 return dom;
88 }
89
90 async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
91 const rootEl = document.getElementById("root");
92 if (!rootEl) throw new Error("missing root");
93 const root = createRoot(rootEl);
94 const calls = { cancel: 0, tokenModes: [] as TokenMode[], approvalModes: [] as ToolApprovalMode[] };
95 let currentProps: Parameters<typeof Composer>[0] = {
96 running: false,
97 collaborationMode: "normal" as CollaborationMode,
98 toolApprovalMode: "ask" as ToolApprovalMode,
99 tokenMode: "full" as TokenMode,
100 goal: "",
101 cwd: "/repo",
102 modelLabel: "DeepSeek-R1",
103 onSend: () => {},
104 onCancel: () => {
105 calls.cancel += 1;
106 return undefined;
107 },
108 onCycleMode: () => {},
109 onSetMode: () => {},
110 onSetCollaborationMode: () => {},
111 onSetToolApprovalMode: (mode) => {
112 calls.approvalModes.push(mode);
113 },
114 onToggleYoloApprovalMode: () => {},
115 onClearGoal: () => {},
116 onSwitchModel: () => {},
117 onSetEffort: () => {},
118 onSetTokenMode: (mode) => {
119 calls.tokenModes.push(mode);
120 },
121 ready: true,
122 ...props,
123 };
124 const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
125 currentProps = { ...currentProps, ...nextProps };
126 await act(async () => {
127 root.render(
128 <LocaleProvider>
129 <ToastProvider>
130 <Composer {...currentProps} />
131 </ToastProvider>
132 </LocaleProvider>,
133 );
134 await flushTimers();
135 });
136 };
137 await paint();
138 return { root, calls, rerender: paint };
139 }
140
141 function installWindowTimerQueue() {
142 let clock = 0;
143 let nextId = 1;
144 const tasks = new Map<number, { at: number; callback: () => void }>();
145 const originalSetTimeout = window.setTimeout;
146 const originalClearTimeout = window.clearTimeout;
147
148 window.setTimeout = ((handler: TimerHandler, delay = 0, ...args: unknown[]) => {
149 if (typeof handler !== "function") throw new Error("string timers are unsupported in tests");
150 const id = nextId++;
151 tasks.set(id, { at: clock + Number(delay), callback: () => handler(...args) });
152 return id;
153 }) as typeof window.setTimeout;
154 window.clearTimeout = ((id?: number) => {
155 if (id !== undefined) tasks.delete(id);
156 }) as typeof window.clearTimeout;
157
158 return {
159 advance(ms: number) {
160 clock += ms;
161 while (true) {
162 const next = [...tasks.entries()]
163 .filter(([, task]) => task.at <= clock)
164 .sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0];
165 if (!next) break;
166 tasks.delete(next[0]);
167 next[1].callback();
168 }
169 },
170 restore() {
171 tasks.clear();
172 window.setTimeout = originalSetTimeout;
173 window.clearTimeout = originalClearTimeout;
174 },
175 };
176 }
177
178 console.log("\ncomposer run strip");
179
180 // Idle: no strip, no stop button, plain send arrow.
181 {
182 const dom = installDom();
183 const { root, calls } = await renderComposer();
184
185 eq(document.querySelector(".composer-run-strip"), null, "idle composer renders no run strip");
186 eq(document.querySelector(".composer__btn--stop"), null, "idle composer renders no stop button");
187 ok(document.querySelector(".composer__btn--send") !== null, "idle composer keeps the send button");
188 eq(document.querySelector(".composer-toolbar--status-only"), null, "floating status pill is gone");
189 const yolo = document.querySelector<HTMLButtonElement>(".composer-modebar__item--yolo");
190 ok(yolo !== null, "approval bar always exposes Yolo alongside Ask and Auto");
191 await act(async () => {
192 yolo?.click();
193 await flushTimers();
194 });
195 eq(calls.approvalModes.at(-1), "yolo", "the visible Yolo option selects Yolo approval");
196
197 await act(async () => {
198 root.unmount();
199 });
200 dom.window.close();
201 }
202
203 // Work mode is a first-class, always-visible selector. Its three profiles live
204 // in their own menu instead of the task-intent menu, and selecting a profile
205 // preserves the existing token-mode callback contract.
206 {
207 const dom = installDom();
208 const { root, calls } = await renderComposer();
209
210 const profileTrigger = document.querySelector(".composer-profile-trigger") as HTMLButtonElement | null;
211 if (!profileTrigger) throw new Error("work mode trigger did not render");
212 eq(profileTrigger.textContent?.trim(), "Balanced", "standalone control shows only the current profile");
213 eq(profileTrigger.getAttribute("aria-label"), "Work mode · Balanced", "work mode trigger keeps its full accessible name");
214 ok(profileTrigger.querySelector(".lucide-equal") !== null, "balanced work mode uses a simple equal icon");
215 await act(async () => {
216 profileTrigger.focus();
217 await flushTimers();
218 });
219 eq(document.querySelector('[role="tooltip"]')?.textContent, "Work mode · Balanced: Full tools, model-directed execution", "work mode tooltip combines category, value, and summary");
220 await act(async () => {
221 profileTrigger.blur();
222 await flushTimers();
223 });
224
225 await act(async () => {
226 profileTrigger.click();
227 await flushTimers();
228 });
229 const profileMenu = document.querySelector(".composer-profile-menu");
230 ok(profileMenu !== null, "standalone work mode trigger opens its own menu");
231 eq(profileMenu?.querySelectorAll('[role="menuitemradio"]').length, 3, "work mode menu exposes exactly three profiles");
232
233 const delivery = Array.from(profileMenu?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? [])
234 .find((item) => item.textContent?.includes("Delivery"));
235 if (!delivery) throw new Error("delivery work mode option did not render");
236 ok(delivery.querySelector(".lucide-flag") !== null, "delivery work mode uses a simple completion flag");
237 await act(async () => {
238 delivery.click();
239 await flushTimers();
240 });
241 eq(calls.tokenModes.at(-1), "delivery", "selecting delivery keeps the token-mode callback contract");
242
243 const intentTrigger = document.querySelector(".composer-task-mode-trigger") as HTMLButtonElement | null;
244 if (!intentTrigger) throw new Error("task intent trigger did not render");
245 await act(async () => {
246 intentTrigger.click();
247 await flushTimers();
248 });
249 eq(document.querySelector(".composer-intent-menu")?.textContent?.includes("Work mode"), false, "task-intent menu no longer owns work mode");
250 eq(document.querySelectorAll('.composer-intent-menu [role="menuitemradio"]').length, 3, "task method menu exposes direct, plan, and goal");
251
252 await act(async () => {
253 root.unmount();
254 });
255 dom.window.close();
256 }
257
258 // A short Creation hover must stay a no-op. In particular, leaving before the
259 // 120ms open delay must not manufacture a closing-only popover or flash the
260 // trigger's open styling 140ms later.
261 {
262 const dom = installDom();
263 const timers = installWindowTimerQueue();
264 const { root } = await renderComposer({ showContextWindowRing: true });
265
266 for (const selector of [".composer-task-mode-trigger", ".composer-profile-trigger"]) {
267 const trigger = document.querySelector(selector) as HTMLButtonElement | null;
268 if (!trigger) throw new Error(`missing Creation hover trigger: ${selector}`);
269
270 await act(async () => {
271 trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null }));
272 timers.advance(119);
273 trigger.dispatchEvent(new MouseEvent("mouseout", { bubbles: true, relatedTarget: document.body }));
274 timers.advance(140);
275 });
276
277 ok(!trigger.classList.contains(`${selector.slice(1)}--open`), `${selector} stays visually closed after a short hover`);
278 ok(
279 document.querySelector(selector.includes("task") ? ".composer-intent-menu" : ".composer-profile-menu") === null,
280 `${selector} does not render a closing-only menu`,
281 );
282 }
283
284 const intentTrigger = document.querySelector(".composer-task-mode-trigger") as HTMLButtonElement | null;
285 if (!intentTrigger) throw new Error("missing Creation intent trigger");
286 await act(async () => {
287 intentTrigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null }));
288 timers.advance(120);
289 });
290 ok(intentTrigger.classList.contains("composer-task-mode-trigger--open"), "a sustained Creation hover still opens the trigger");
291 ok(document.querySelector(".composer-intent-menu") !== null, "a sustained Creation hover still renders the menu");
292
293 timers.restore();
294 await act(async () => {
295 root.unmount();
296 });
297 dom.window.close();
298 }
299
300 // Runtime controller transitions disable every mode axis and submit together,
301 // so rapid Goal + Delivery + approval-mode clicks cannot mutate a half-rebuilt runtime.
302 {
303 const dom = installDom();
304 const { root } = await renderComposer({ disabled: true, goal: "ship it", collaborationMode: "goal" });
305 const profile = document.querySelector<HTMLButtonElement>(".composer-profile-trigger");
306 const task = document.querySelector<HTMLButtonElement>(".composer-task-mode-trigger");
307 const approvals = Array.from(document.querySelectorAll<HTMLButtonElement>(".composer-modebar--approval button"));
308 const send = document.querySelector<HTMLButtonElement>(".composer__btn--send");
309 ok(Boolean(profile?.disabled), "runtime transition disables Delivery profile changes");
310 ok(Boolean(task?.disabled), "runtime transition disables Goal mode changes");
311 ok(approvals.length === 3 && approvals.every((button) => button.disabled), "runtime transition disables Ask/Auto/Yolo changes");
312 ok(Boolean(send?.disabled), "runtime transition disables submit");
313
314 await act(async () => {
315 root.unmount();
316 });
317 dom.window.close();
318 }
319
320 // Running: strip lives inside the card, ticker is aria-hidden, stop cancels.
321 {
322 const dom = installDom();
323 const { root, calls } = await renderComposer({ running: true, turnStartAt: Date.now() });
324
325 const strip = document.querySelector(".composer-card .composer-run-strip");
326 ok(strip !== null, "running strip renders inside the composer card");
327 const ticker = strip?.querySelector(".composer-run-strip__text");
328 eq(ticker?.getAttribute("aria-hidden"), "true", "ticking spinner text stays out of the accessibility tree");
329 const live = strip?.querySelector(".sr-only[role=\"status\"]");
330 eq(live?.textContent, "Reasonix is working", "live region announces the stable state text only");
331 ok(document.querySelector(".composer-card--running") !== null, "running card keeps its running modifier");
332
333 const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
334 if (!stop) throw new Error("running composer stop button did not render");
335 await act(async () => {
336 stop.click();
337 await flushTimers();
338 });
339 eq(calls.cancel, 1, "stop button next to send cancels the turn");
340
341 await act(async () => {
342 root.unmount();
343 });
344 dom.window.close();
345 }
346
347 // Waiting on approval: strip switches to the waiting state and stops ticking.
348 {
349 const dom = installDom();
350 const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() });
351
352 await rerender({ pendingApprovalLabel: "Run command", disabled: true });
353
354 const strip = document.querySelector(".composer-run-strip");
355 ok(strip?.classList.contains("composer-run-strip--waiting") === true, "pending approval shifts the strip into waiting");
356 const text = strip?.querySelector(".composer-run-strip__text");
357 eq(text?.textContent, "Waiting for your approval — Run command", "waiting strip names the tool awaiting approval");
358 eq(text?.getAttribute("aria-hidden"), null, "waiting text is static and stays accessible");
359 eq(document.querySelector(".composer-card--running"), null, "waiting card hands the running accent off to the prompt card");
360 ok(document.querySelector(".composer-card--waiting") !== null, "waiting card takes the waiting modifier");
361
362 const modeButtons = [...document.querySelectorAll(".composer-modebar--approval .composer-modebar__item")] as HTMLButtonElement[];
363 ok(modeButtons.length === 3 && modeButtons.every((b) => !b.disabled), "approval bar stays usable while its own prompt disables the composer");
364
365 await rerender({ pendingApprovalLabel: null, pendingAsk: true });
366 eq(
367 document.querySelector(".composer-run-strip__text")?.textContent,
368 "Waiting for your answer",
369 "pending ask question shows the ask waiting state",
370 );
371 ok(
372 modeButtons.every((b) => b.disabled),
373 "approval bar stays disabled for non-approval reasons",
374 );
375
376 await rerender({ pendingAsk: false, disabled: false });
377 ok(
378 document.querySelector(".composer-run-strip__text")?.getAttribute("aria-hidden") === "true",
379 "resolving the prompt returns the strip to the ticking spinner",
380 );
381
382 await act(async () => {
383 root.unmount();
384 });
385 dom.window.close();
386 }
387
388 // Cancel restores queued guidance: stop means "stop acting", never "discard
389 // what I typed".
390 {
391 const dom = installDom();
392 const { root, calls } = await renderComposer({
393 running: true,
394 turnStartAt: Date.now(),
395 guidanceQueuePreviewItems: ["数到一半改用英文", "最后给出一句总结"],
396 });
397
398 ok(document.querySelector(".composer-guidance-shelf") !== null, "queued guidance renders in the shelf");
399
400 const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
401 if (!stop) throw new Error("stop button did not render");
402 await act(async () => {
403 stop.click();
404 await flushTimers();
405 });
406
407 eq(calls.cancel, 1, "stop cancels the running turn");
408 const ta = document.querySelector("textarea") as HTMLTextAreaElement;
409 eq(ta.value, "数到一半改用英文\n最后给出一句总结", "stop folds unconsumed queued guidance back into the draft");
410 eq(document.querySelector(".composer-guidance-shelf"), null, "restored queue clears the shelf");
411
412 await act(async () => {
413 root.unmount();
414 });
415 dom.window.close();
416 }
417
418 // Waiting on the user pauses the ticker clock: elapsed time means model time.
419 {
420 const dom = installDom();
421 const start = Date.now() - 30000;
422 const { root, rerender } = await renderComposer({ running: true, turnStartAt: start });
423
424 await rerender({ pendingApprovalLabel: "Run command", disabled: true });
425 await act(async () => {
426 await new Promise((resolve) => setTimeout(resolve, 2400));
427 });
428 await rerender({ pendingApprovalLabel: null, disabled: false });
429
430 const ticker = document.querySelector(".composer-run-strip__text")?.textContent ?? "";
431 ok(/ 30s| 31s/.test(ticker), `ticker excludes the time spent waiting for approval (got "${ticker}")`);
432 ok(!/ 32s| 33s/.test(ticker), "ticker does not count the ~2.4s approval wait as model time");
433
434 await act(async () => {
435 root.unmount();
436 });
437 dom.window.close();
438 }
439
440 // Decision surface suspension pauses the clock without a waiting strip.
441 {
442 const dom = installDom();
443 const start = Date.now() - 15000;
444 const { root, rerender } = await renderComposer({ running: true, turnStartAt: start });
445
446 await rerender({ suspendedByDecision: true, disabled: true });
447 eq(document.querySelector(".composer-run-strip--waiting"), null, "decision suspension does not render a waiting strip");
448 await act(async () => {
449 await new Promise((resolve) => setTimeout(resolve, 2400));
450 });
451 await rerender({ suspendedByDecision: false, disabled: false });
452
453 const ticker = document.querySelector(".composer-run-strip__text")?.textContent ?? "";
454 ok(/ 15s| 16s/.test(ticker), `suspendedByDecision excludes wait time from model clock (got "${ticker}")`);
455 ok(!/ 17s| 18s/.test(ticker), "suspended wait is not counted as model work");
456
457 await act(async () => {
458 root.unmount();
459 });
460 dom.window.close();
461 }
462
463 // Background user-wait is controller-scoped: B already waited ~3s off-screen
464 // while A was foregrounded. Model work for B must stay ~5s (8s turn − 3s wait),
465 // and tab A's local pause must never be subtracted from B.
466 {
467 const dom = installDom();
468 const tabAStart = Date.now() - 60_000;
469 const tabBStart = Date.now() - 8_000;
470 const tabBWaitStarted = Date.now() - 3_000;
471 const { root, rerender } = await renderComposer({
472 running: true,
473 turnStartAt: tabAStart,
474 sessionKey: "tab-a",
475 suspendedByDecision: true,
476 disabled: true,
477 });
478
479 // A stays locally suspended for a while (clear-context style / no controller wait).
480 await act(async () => {
481 await new Promise((resolve) => setTimeout(resolve, 2400));
482 });
483
484 // Switch to B, already awaiting approval since tabBWaitStarted (background).
485 await rerender({
486 sessionKey: "tab-b",
487 turnStartAt: tabBStart,
488 turnWaitAccumMs: 0,
489 promptWaitStartedAt: tabBWaitStarted,
490 suspendedByDecision: true,
491 disabled: true,
492 });
493 await act(async () => {
494 await new Promise((resolve) => setTimeout(resolve, 300));
495 });
496
497 // Controller closes the open wait into turnWaitAccumMs on resolve.
498 const closedWaitMs = Date.now() - tabBWaitStarted;
499 await rerender({
500 suspendedByDecision: false,
501 disabled: false,
502 promptWaitStartedAt: undefined,
503 turnWaitAccumMs: closedWaitMs,
504 });
505
506 const ticker = document.querySelector(".composer-run-strip__text")?.textContent ?? "";
507 // 8s turn age − ~3.3s user wait ≈ 5s model work (not ~8s wall, not ~0–2s from A leak).
508 ok(/ 4s| 5s| 6s/.test(ticker), `tab B excludes background user-wait from model clock (got "${ticker}")`);
509 ok(!/ 7s| 8s| 9s| 10s| 11s/.test(ticker), "background suspension is not counted as model work");
510 ok(!/ 5[5-9]s| 6[0-9]s/.test(ticker), "tab B does not show tab A's ~60s turn age as model time");
511
512 await act(async () => {
513 root.unmount();
514 });
515 dom.window.close();
516 }
517
518 // Resize consistency: --composer-height always carries the logical height in
519 // every writer (React render, live drag, keyboard), with the run strip's
520 // reservation isolated in a CSS calc — so dragging a resized composer during a
521 // running turn cannot flash-shrink the card.
522 {
523 const stylesSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../styles.css"), "utf8");
524 ok(
525 stylesSource.includes("calc(var(--composer-height) + var(--composer-run-strip-reserved, 0px))"),
526 "resized card height combines logical height and strip reservation in CSS",
527 );
528
529 const dom = installDom();
530 const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() });
531
532 const handle = document.querySelector(".composer-resize-handle") as HTMLButtonElement;
533 await act(async () => {
534 handle.focus();
535 handle.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Home", bubbles: true }));
536 await flushTimers();
537 });
538
539 const card = document.querySelector(".composer-card") as HTMLElement;
540 eq(card.style.getPropertyValue("--composer-height"), "104px", "render path writes the logical height, not a compensated one");
541 eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "running card reserves the strip height via its own variable");
542
543 // Drag while running: the live writer stays in logical-height space.
544 await act(async () => {
545 handle.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, clientY: 300 }));
546 await flushTimers();
547 });
548 eq(card.style.getPropertyValue("--composer-height"), "104px", "drag start does not flash-shrink the running card");
549
550 await act(async () => {
551 document.dispatchEvent(new window.MouseEvent("pointermove", { bubbles: true, clientY: 280 }));
552 document.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, clientY: 280 }));
553 await flushTimers();
554 });
555 eq(card.style.getPropertyValue("--composer-height"), "124px", "drag release keeps the same logical-height space as the render path");
556 eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "strip reservation survives the drag");
557 eq(handle.getAttribute("aria-valuenow"), "124", "separator reports the logical height");
558
559 await rerender({ running: false, turnStartAt: undefined });
560 eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "0px", "idle card releases the strip reservation");
561 eq(card.style.getPropertyValue("--composer-height"), "124px", "idle card keeps the user's logical height");
562
563 await act(async () => {
564 root.unmount();
565 });
566 dom.window.close();
567 }
568
569 console.log(`\n${passed} passed, ${failed} failed`);
570 if (failed > 0) process.exit(1);
571
571 lines Plain Text