返回 DeepSeek-Reasonix
recovery-wait-banner.test.tsx
根目录 / desktop / frontend / src / __tests__ / recovery-wait-banner.test.tsx
1 // Run: tsx src/__tests__/recovery-wait-banner.test.tsx
2 //
3 // A minute-scale provider-recovery wait renders a prominent banner above the
4 // composer card (phase, code, countdown, waited-vs-budget, Stop) while the
5 // small run-strip status line keeps working; short retries render no banner.
6
7 import { JSDOM } from "jsdom";
8 import React from "react";
9 import { act } from "react";
10 import { createRoot } from "react-dom/client";
11 import { Composer } from "../components/Composer";
12 import { RecoveryWaitBanner } from "../components/RecoveryWaitBanner";
13 import { LocaleProvider } from "../lib/i18n";
14 import { ToastProvider } from "../lib/toast";
15 import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
16
17 let passed = 0;
18 let failed = 0;
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(): Promise<void> {
36 return new Promise((resolve) => setTimeout(resolve, 0));
37 }
38
39 // The composer loads the banner as a lazy chunk; give the dynamic import a
40 // few macrotasks to resolve before asserting on it.
41 async function settle(ready: () => boolean): Promise<void> {
42 for (let i = 0; i < 100 && !ready(); i += 1) {
43 await act(async () => {
44 await new Promise((resolve) => setTimeout(resolve, 10));
45 });
46 }
47 }
48
49 class TestResizeObserver {
50 observe() {}
51 unobserve() {}
52 disconnect() {}
53 }
54
55 function installDom() {
56 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
57 pretendToBeVisual: true,
58 url: "http://localhost/",
59 });
60 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
61 globalThis.window = dom.window as unknown as Window & typeof globalThis;
62 globalThis.document = dom.window.document;
63 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
64 globalThis.Node = dom.window.Node;
65 globalThis.HTMLElement = dom.window.HTMLElement;
66 globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
67 globalThis.Event = dom.window.Event;
68 globalThis.CustomEvent = dom.window.CustomEvent;
69 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
70 globalThis.InputEvent = dom.window.InputEvent;
71 globalThis.MouseEvent = dom.window.MouseEvent;
72 globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
73 globalThis.MutationObserver = dom.window.MutationObserver;
74 globalThis.File = dom.window.File;
75 globalThis.FileReader = dom.window.FileReader;
76 globalThis.localStorage = dom.window.localStorage;
77 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
78 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
79 globalThis.ResizeObserver = TestResizeObserver;
80 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
81 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
82 Object.defineProperty(window, "matchMedia", {
83 configurable: true,
84 value: () => ({
85 matches: true,
86 media: "(prefers-reduced-motion: reduce)",
87 onchange: null,
88 addEventListener() {},
89 removeEventListener() {},
90 addListener() {},
91 removeListener() {},
92 dispatchEvent: () => false,
93 }),
94 });
95 return dom;
96 }
97
98 async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
99 const rootEl = document.getElementById("root");
100 if (!rootEl) throw new Error("missing root");
101 const root = createRoot(rootEl);
102 const calls = { cancel: 0 };
103 let currentProps: Parameters<typeof Composer>[0] = {
104 running: false,
105 collaborationMode: "normal" as CollaborationMode,
106 toolApprovalMode: "ask" as ToolApprovalMode,
107 goal: "",
108 cwd: "/repo",
109 modelLabel: "DeepSeek-R1",
110 onSend: () => {},
111 onCancel: () => {
112 calls.cancel += 1;
113 return undefined;
114 },
115 onCycleMode: () => {},
116 onSetMode: () => {},
117 onSetCollaborationMode: () => {},
118 onSetToolApprovalMode: () => {},
119 onClearGoal: () => {},
120 onSwitchModel: () => {},
121 onSetEffort: () => {},
122 ready: true,
123 ...props,
124 };
125 const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
126 currentProps = { ...currentProps, ...nextProps };
127 await act(async () => {
128 root.render(
129 <LocaleProvider>
130 <ToastProvider>
131 <Composer {...currentProps} />
132 </ToastProvider>
133 </LocaleProvider>,
134 );
135 await flushTimers();
136 });
137 };
138 await paint();
139 return { root, calls, rerender: paint };
140 }
141
142 const waitingRecovery = {
143 phase: "headers",
144 reason: "rate_limit",
145 next_attempt_at: 0,
146 waited_ms: 74_000,
147 wait_budget_ms: 600_000,
148 waiting: true,
149 };
150
151 console.log("\nrecovery wait banner");
152
153 // Standalone banner: phase title, code, countdown, waited-vs-budget, Stop.
154 {
155 const dom = installDom();
156 const rootEl = document.getElementById("root");
157 if (!rootEl) throw new Error("missing root");
158 const root = createRoot(rootEl);
159 const now = 1_700_000_000_000;
160 let stops = 0;
161 const paint = async (retry: Parameters<typeof RecoveryWaitBanner>[0]["retry"]) => {
162 await act(async () => {
163 root.render(
164 <LocaleProvider>
165 <RecoveryWaitBanner retry={retry} now={now} onStop={() => { stops += 1; }} />
166 </LocaleProvider>,
167 );
168 await flushTimers();
169 });
170 };
171
172 await paint({ attempt: 4, max: 3, recovery: { ...waitingRecovery, next_attempt_at: now + 42_000 } });
173 const banner = document.querySelector(".recovery-wait-banner");
174 ok(banner !== null, "waiting recovery renders the banner");
175 eq(banner?.getAttribute("data-phase"), "headers", "banner exposes the failure phase");
176 eq(document.querySelector(".recovery-wait-banner__title")?.textContent, "Waiting for the provider to recover", "headers phase reads as a provider outage");
177 eq(document.querySelector(".recovery-wait-banner__countdown")?.textContent, "Next attempt in 42s", "countdown derives from next_attempt_at");
178 eq(document.querySelector(".recovery-wait-banner__progress")?.textContent, "Waited 1 min of 10 min", "waited-so-far is shown against the wait budget");
179 eq(document.querySelector(".recovery-wait-banner__code")?.textContent, "rate_limit", "provider error code is shown");
180 await act(async () => {
181 document.querySelector<HTMLButtonElement>(".recovery-wait-banner__stop")?.click();
182 await flushTimers();
183 });
184 eq(stops, 1, "Stop calls the cancel action");
185
186 await paint({ attempt: 4, max: 3, recovery: { phase: "connect", next_attempt_at: now + 5_000, waited_ms: 14_000, waiting: true } });
187 eq(document.querySelector(".recovery-wait-banner__title")?.textContent, "Waiting for the network to recover", "connect phase reads as a network outage");
188 eq(document.querySelector(".recovery-wait-banner__progress"), null, "an older kernel without a budget shows no waited-vs-budget line");
189 eq(document.querySelector(".recovery-wait-banner__code"), null, "no code chip without a provider code");
190
191 await paint({ attempt: 2, max: 3 });
192 eq(document.querySelector(".recovery-wait-banner"), null, "short retries render no banner");
193
194 await act(async () => {
195 root.unmount();
196 });
197 dom.window.close();
198 }
199
200 // Composer host: the banner sits above the shared composer frame, the run strip keeps its
201 // status line, and Stop routes through the composer's cancel path.
202 {
203 const dom = installDom();
204 const { root, calls, rerender } = await renderComposer({
205 running: true,
206 retry: { attempt: 4, max: 3, recovery: { ...waitingRecovery, next_attempt_at: Date.now() + 60_000 } },
207 });
208
209 await settle(() => document.querySelector(".recovery-wait-banner") !== null);
210 const banner = document.querySelector(".composer-wrap > .recovery-wait-banner");
211 ok(banner !== null, "waiting recovery renders the banner inside the composer area");
212 const frame = banner?.nextElementSibling;
213 ok(Boolean(frame?.classList.contains("composer-workspace-frame") && frame.querySelector(":scope > .composer-card")), "banner sits directly above the composer frame");
214 const strip = document.querySelector(".composer-run-strip__text")?.textContent ?? "";
215 ok(strip.startsWith("Waiting for provider (service unavailable)"), `run strip keeps the compact status line: ${JSON.stringify(strip)}`);
216 await act(async () => {
217 document.querySelector<HTMLButtonElement>(".recovery-wait-banner__stop")?.click();
218 await flushTimers();
219 });
220 eq(calls.cancel, 1, "banner Stop calls onCancel exactly once");
221
222 await rerender({ retry: { attempt: 2, max: 3 } });
223 eq(document.querySelector(".recovery-wait-banner"), null, "a short retry hides the banner");
224 eq(document.querySelector(".composer-run-strip__text")?.textContent, "retrying (2/3)…", "short retries keep the retrying status line");
225
226 await rerender({ running: false, retry: undefined });
227 eq(document.querySelector(".recovery-wait-banner"), null, "idle composer renders no banner");
228
229 await act(async () => {
230 root.unmount();
231 });
232 dom.window.close();
233 }
234
235 console.log(`\n${passed} passed, ${failed} failed`);
236 if (failed > 0) process.exit(1);
237
237 lines Plain Text