返回 DeepSeek-Reasonix
statusbar-workspace.test.tsx
根目录 / desktop / frontend / src / __tests__ / statusbar-workspace.test.tsx
1 // Run: tsx src/__tests__/statusbar-workspace.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React from "react";
5 import { act } from "react";
6 import { createRoot } from "react-dom/client";
7 import { renderToStaticMarkup } from "react-dom/server";
8 import { StatusBar } from "../components/StatusBar";
9 import { LocaleProvider } from "../lib/i18n";
10 import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "../lib/statusBarItems";
11
12 let passed = 0;
13 let failed = 0;
14
15 function ok(value: boolean, label: string) {
16 if (value) {
17 process.stdout.write(` PASS ${label}\n`);
18 passed += 1;
19 } else {
20 process.stdout.write(` FAIL ${label}\n`);
21 failed += 1;
22 }
23 }
24
25 function renderStatusBar(props: Partial<Parameters<typeof StatusBar>[0]> = {}): string {
26 return renderToStaticMarkup(
27 <LocaleProvider>
28 <StatusBar
29 context={{ used: 0, window: 0, sessionTokens: 0 }}
30 running={false}
31 {...props}
32 />
33 </LocaleProvider>,
34 );
35 }
36
37 console.log("\nstatus bar workspace");
38
39 {
40 ok(normalizeStatusBarItems(["model"]).join(",") === "workspace", "model-only migration keeps one workspace item");
41 ok(normalizeStatusBarItems(["model", "unknown"]).join(",") === "workspace", "removed model with unknown entries remains compact");
42 ok(normalizeStatusBarItems(["cache", "model", "git_branch", "workspace"]).join(",") === "cache,workspace", "migration preserves valid order and merges branch duplicates");
43 for (const input of [undefined, [], ["unknown"]]) {
44 ok(normalizeStatusBarItems(input).join(",") === DEFAULT_STATUS_BAR_ITEMS.join(","), "missing or invalid configuration retains defaults");
45 }
46 const saved = JSON.parse(JSON.stringify(normalizeStatusBarItems(["model"])));
47 ok(normalizeStatusBarItems(saved).join(",") === "workspace", "migration stays compact after save and reload");
48 }
49
50
51 {
52 const defaultItems = DEFAULT_STATUS_BAR_ITEMS as readonly string[];
53 ok(defaultItems.includes("workspace"), "workspace is a default configurable status item");
54 ok(!defaultItems.includes("git_branch"), "branch is merged into the workspace setting");
55 ok(
56 normalizeStatusBarItems(["git_branch", "workspace", "cache"]).join(",") === "workspace,cache",
57 "legacy workspace items merge at their first configured position",
58 );
59 }
60
61 {
62 const html = renderStatusBar({
63 items: ["context"],
64 context: { used: 1_001, window: 1_000, sessionTokens: 1_001, compactRatio: 0.8 },
65 });
66 ok(html.includes(">101%</b>"), "context status preserves a just-over-limit percentage");
67 ok(!html.includes(">100%</b>"), "context status does not clamp an over-limit percentage to 100 percent");
68 }
69
70 {
71 const remoteHosts = [
72 { id: "demo", label: "demo", host: "192.0.2.10", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "~/app", serveInstall: "auto", credentialMode: "remote", useSSHConfig: false },
73 ];
74 const stopped = renderStatusBar({ workspacePath: "/workspace/repo", workspaceName: "repo", remoteHosts });
75 ok(stopped.includes("SSH · Disconnected"), "disconnected SSH entry keeps its full accessible status");
76 ok(stopped.includes('statusbar__remote--idle'), "disconnected SSH entry uses the compact idle treatment");
77 ok(stopped.includes('<span class="statusbar__remote-label">SSH</span>'), "disconnected SSH entry renders only the compact SSH label");
78 ok(stopped.indexOf("SSH · Disconnected") < stopped.indexOf("workspace/repo"), "window-level SSH entry leads the status bar");
79
80 const connected = renderStatusBar({
81 workspacePath: "/workspace/repo",
82 workspaceName: "repo",
83 remoteHosts,
84 remoteStatuses: { demo: { hostId: "demo", state: "connected" } },
85 });
86 ok(connected.includes("demo · Connected"), "SSH entry includes host and connected state text");
87 ok(connected.includes('statusbar__remote-state-dot'), "connected SSH entry renders a state dot");
88 ok(connected.includes('<span class="statusbar__remote-label">demo</span>'), "connected SSH entry renders the host without redundant state text");
89
90 const failed = renderStatusBar({
91 workspacePath: "/workspace/repo",
92 remoteHosts,
93 remoteStatuses: { demo: { hostId: "demo", state: "stopped", error: "handshake failed" } },
94 });
95 ok(failed.includes("demo · Connection failed"), "SSH entry keeps a recoverable failure summary visible");
96 ok(failed.includes('<span class="statusbar__remote-label">demo · Connection failed</span>'), "failed SSH entry keeps the failure visible in the status bar");
97 ok(!failed.includes("handshake failed"), "status entry keeps raw connection diagnostics out of primary chrome");
98
99 const degraded = renderStatusBar({
100 workspacePath: "/workspace/repo",
101 remoteHosts,
102 remoteStatuses: {
103 demo: {
104 hostId: "demo",
105 state: "degraded",
106 error: "forward attach failed",
107 },
108 },
109 });
110 ok(degraded.includes("demo · Degraded"), "degraded SSH remains connected with a warning state");
111 ok(!degraded.includes("demo · Connection failed"), "degraded SSH is not mislabeled as a failed connection");
112 }
113
114 {
115 const propsWithLegacySandbox = {
116 workspacePath: "/workspace/repo",
117 workspaceName: "repo",
118 sandboxPath: "/sandbox/repo",
119 gitBranch: "feature/meta",
120 };
121 const html = renderStatusBar(propsWithLegacySandbox);
122 ok(!html.includes("workspace/repo"), "workspace path stays out of the visible branch label");
123 ok(!html.includes("sandbox/repo"), "workspace chip does not display sandbox path");
124 ok(html.includes("feature/meta"), "git branch remains visible");
125 }
126
127 {
128 const html = renderStatusBar({
129 items: ["cache"],
130 workspacePath: "/workspace/repo",
131 workspaceName: "repo",
132 gitBranch: "feature/meta",
133 });
134 ok(!html.includes("workspace/repo"), "workspace can be hidden by status item config");
135 ok(!html.includes("feature/meta"), "git branch can be hidden by status item config");
136 }
137
138 {
139 const html = renderStatusBar({
140 items: ["git_branch", "workspace"],
141 workspacePath: "/workspace/repo",
142 workspaceName: "repo",
143 gitBranch: "feature/meta",
144 });
145 ok(html.includes("feature/meta") && !html.includes("workspace/repo"), "combined chip shows only the current branch");
146 ok((html.match(/class="stat statusbar__workspace"/g) || []).length === 1, "legacy items render one combined chip");
147 ok(!html.includes('<b>…/workspace/repo</b>'), "workspace path is not a separate visible label when a branch is available");
148 }
149
150 {
151 const html = renderStatusBar({ items: ["model"] });
152 ok(!html.includes("stat--model"), "legacy model setting cannot restore the removed model entry");
153 ok(!html.includes("YOLO"), "status bar renders only configured status items, not mode indicators");
154 ok(!html.includes("后台作业") && !html.includes("Background jobs"), "status bar hides the operational jobs entry while idle");
155 }
156
157 {
158 const html = renderStatusBar({
159 items: ["model"],
160 jobs: [{ id: "bash-1", kind: "bash", label: "run tests", status: "running", startedAt: 1 }],
161 });
162 ok(html.includes("Background jobs"), "running background jobs remain visible outside configurable metrics");
163 ok(html.includes("1"), "background jobs entry exposes the running count");
164 }
165
166 {
167 const html = renderStatusBar({
168 items: ["model"],
169 backgroundRuntimes: [{
170 tabId: "running-1", title: "Detached delivery", detached: true,
171 running: true, pendingPrompt: false, jobs: [],
172 }],
173 });
174 ok(html.includes("Background jobs"), "a running detached task remains visible without child jobs");
175 ok(html.includes("<b>1</b>"), "a jobless active runtime contributes to the recovery count");
176 }
177
178 {
179 const defaultItems = DEFAULT_STATUS_BAR_ITEMS as readonly string[];
180 ok(!defaultItems.includes("autoresearch"), "autoresearch is not a configurable status bar UI item");
181 }
182
183 {
184 const estimated = renderStatusBar({
185 items: ["session_tokens", "turn_tokens", "turn_cost", "cost"],
186 context: { used: 0, window: 0, sessionTokens: 1_200, estimated: true },
187 usage: {
188 promptTokens: 800,
189 completionTokens: 200,
190 totalTokens: 1_000,
191 cacheHitTokens: 0,
192 cacheMissTokens: 800,
193 estimated: true,
194 },
195 sessionTokens: 1_200,
196 turnTokens: 1_000,
197 turnCost: 0.2,
198 cost: 0.3,
199 currency: "USD",
200 });
201 ok((estimated.match(/≈/g) ?? []).length === 4, "estimated token and cost metrics use an approximation marker");
202
203 const empty = renderStatusBar({
204 items: ["session_tokens", "turn_tokens", "turn_cost", "cost"],
205 context: { used: 0, window: 0, sessionTokens: 0, estimated: true },
206 usage: {
207 promptTokens: 0,
208 completionTokens: 0,
209 totalTokens: 0,
210 cacheHitTokens: 0,
211 cacheMissTokens: 0,
212 estimated: true,
213 },
214 currency: "USD",
215 });
216 ok(!empty.includes("≈-"), "empty estimated metrics remain a plain dash");
217 }
218
219 {
220 const exact = renderStatusBar({
221 items: ["turn_tps"],
222 lastTurnOutputTokens: 100,
223 lastTurnModelMs: 5_000,
224 });
225 ok(exact.includes("20 t/s"), "completed TPS uses provider-output time");
226
227 const estimated = renderStatusBar({
228 items: ["turn_tps"],
229 lastTurnOutputTokens: 100,
230 lastTurnModelMs: 5_000,
231 lastTurnOutputEstimated: true,
232 });
233 ok(estimated.includes("≈20 t/s"), "fallback TPS is visibly marked as estimated");
234
235 const perRequest = renderStatusBar({
236 items: ["turn_tps"],
237 lastRequestTps: 35,
238 lastTurnOutputTokens: 100,
239 lastTurnModelMs: 5_000,
240 });
241 ok(perRequest.includes("35 t/s"), "per-request TPS wins over the completed turn value");
242
243 const slowRequest = renderStatusBar({
244 items: ["turn_tps"], lastRequestTps: 1 / 3, lastTurnOutputTokens: 100, lastTurnModelMs: 5_000,
245 });
246 ok(slowRequest.includes("&lt;1 t/s") && !slowRequest.includes("20 t/s"), "sub-one request TPS replaces the stale turn fallback");
247
248 const unavailable = renderStatusBar({
249 items: ["turn_tps"], lastRequestTps: null, lastTurnOutputTokens: 100, lastTurnModelMs: 5_000,
250 });
251 ok(unavailable.includes('stat__value--empty">-</b>') && !unavailable.includes("20 t/s"), "unmeasured latest requests clear the stale turn fallback");
252 }
253
254 {
255 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
256 pretendToBeVisual: true,
257 url: "http://localhost/",
258 });
259 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
260 globalThis.window = dom.window as unknown as Window & typeof globalThis;
261 globalThis.document = dom.window.document;
262 // Node's built-in navigator reflects the machine's ICU locale; pin jsdom's
263 // en-US one so English-string assertions hold on zh-locale machines.
264 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
265 globalThis.Node = dom.window.Node;
266 globalThis.HTMLElement = dom.window.HTMLElement;
267 globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
268 globalThis.Event = dom.window.Event;
269 globalThis.MouseEvent = dom.window.MouseEvent;
270 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
271 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
272 Object.defineProperty(window, "matchMedia", {
273 configurable: true,
274 value: () => ({ matches: true, addEventListener() {}, removeEventListener() {} }),
275 });
276
277 let stopped = "";
278 const rootEl = document.getElementById("root")!;
279 const root = createRoot(rootEl);
280 await act(async () => {
281 root.render(
282 <LocaleProvider>
283 <StatusBar
284 context={{ used: 0, window: 0, sessionTokens: 0 }}
285 running={false}
286 jobs={[{ id: "bash-1", kind: "bash", label: "run tests", status: "running", startedAt: 1 }]}
287 onCancelJob={async (jobID) => { stopped = jobID; return true; }}
288 />
289 </LocaleProvider>,
290 );
291 });
292 const jobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
293 await act(async () => { jobsButton?.click(); });
294 const stopButton = document.body.querySelector<HTMLButtonElement>(".jobs-popover__stop");
295 await act(async () => { stopButton?.click(); await Promise.resolve(); });
296 ok(stopped === "bash-1", "background jobs popover routes Stop to the selected job");
297
298 let routed = "";
299 let revealed = "";
300 await act(async () => {
301 root.render(
302 <LocaleProvider>
303 <StatusBar
304 context={{ used: 0, window: 0, sessionTokens: 0 }}
305 running={false}
306 backgroundRuntimes={[
307 {
308 tabId: "detached-1", title: "Detached delivery", detached: true,
309 running: false, pendingPrompt: false,
310 jobs: [{ id: "go-1", kind: "go", label: "go test", status: "running", startedAt: 1 }],
311 },
312 ]}
313 onCancelRuntimeJob={async (tabID, jobID) => { routed = `${tabID}:${jobID}`; return true; }}
314 onRevealRuntime={async (tabID) => { revealed = tabID; }}
315 />
316 </LocaleProvider>,
317 );
318 });
319 const globalJobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
320 if (globalJobsButton?.getAttribute("aria-expanded") !== "true") {
321 await act(async () => { globalJobsButton?.click(); });
322 }
323 const globalStop = document.body.querySelector<HTMLButtonElement>(".jobs-popover__stop");
324 const openTask = Array.from(document.body.querySelectorAll<HTMLButtonElement>(".jobs-popover__runtime-header button"))[0];
325 await act(async () => { globalStop?.click(); openTask?.click(); await Promise.resolve(); });
326 ok(routed === "detached-1:go-1", "global jobs route Stop to the owning detached task");
327 ok(revealed === "detached-1", "global jobs can reopen the exact detached task");
328
329 revealed = "";
330 await act(async () => {
331 root.render(
332 <LocaleProvider>
333 <StatusBar
334 context={{ used: 0, window: 0, sessionTokens: 0 }}
335 running={false}
336 backgroundRuntimes={[
337 {
338 tabId: "prompt-1", title: "Waiting delivery", detached: true,
339 running: false, pendingPrompt: true, jobs: [],
340 },
341 ]}
342 onRevealRuntime={async (tabID) => { revealed = tabID; }}
343 />
344 </LocaleProvider>,
345 );
346 });
347 const promptJobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
348 if (promptJobsButton?.getAttribute("aria-expanded") !== "true") {
349 await act(async () => { promptJobsButton?.click(); });
350 }
351 ok(document.body.textContent?.includes("Waiting for input") === true, "pending-prompt runtime explains why it remains active");
352 const promptOpenTask = document.body.querySelector<HTMLButtonElement>(".jobs-popover__runtime-header button");
353 await act(async () => { promptOpenTask?.click(); await Promise.resolve(); });
354 ok(revealed === "prompt-1", "a pending-prompt runtime can be reopened without child jobs");
355
356 await act(async () => {
357 root.render(
358 <LocaleProvider>
359 <StatusBar
360 context={{ used: 0, window: 0, sessionTokens: 0 }}
361 running={false}
362 jobs={[{ id: "local-job", kind: "go", label: "local test", status: "running", startedAt: 1 }]}
363 />
364 </LocaleProvider>,
365 );
366 });
367 const mixedJobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
368 ok(mixedJobsButton?.textContent?.includes("1") === true, "local jobs show in the status bar total");
369 if (mixedJobsButton?.getAttribute("aria-expanded") !== "true") {
370 await act(async () => { mixedJobsButton?.click(); });
371 }
372 ok(document.body.textContent?.includes("local test") === true, "local background jobs remain visible");
373 await act(async () => { root.unmount(); });
374 dom.window.close();
375 }
376
377 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
378 if (failed > 0) process.exit(1);
379
379 lines Plain Text