返回 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 {
41 const defaultItems = DEFAULT_STATUS_BAR_ITEMS as readonly string[];
42 ok(defaultItems.includes("workspace"), "workspace is a default configurable status item");
43 ok(defaultItems.includes("git_branch"), "git branch is a default configurable status item");
44 ok(
45 normalizeStatusBarItems(["git_branch", "workspace", "cache"]).join(",") === "git_branch,workspace,cache",
46 "workspace items preserve configured order",
47 );
48 }
49
50 {
51 const remoteHosts = [
52 { id: "demo", label: "demo", host: "192.0.2.10", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "~/app", serveInstall: "auto", useSSHConfig: false },
53 ];
54 const stopped = renderStatusBar({ workspacePath: "/workspace/repo", workspaceName: "repo", remoteHosts });
55 ok(stopped.includes("SSH · Disconnected"), "configured SSH entry remains visible while disconnected");
56 ok(stopped.indexOf("SSH · Disconnected") < stopped.indexOf("workspace/repo"), "window-level SSH entry leads the status bar");
57
58 const connected = renderStatusBar({
59 workspacePath: "/workspace/repo",
60 workspaceName: "repo",
61 remoteHosts,
62 remoteStatuses: { demo: { hostId: "demo", state: "connected" } },
63 });
64 ok(connected.includes("demo · Connected"), "SSH entry includes host and connected state text");
65
66 const failed = renderStatusBar({
67 workspacePath: "/workspace/repo",
68 remoteHosts,
69 remoteStatuses: { demo: { hostId: "demo", state: "stopped", error: "handshake failed" } },
70 });
71 ok(failed.includes("demo · Connection failed"), "SSH entry keeps a recoverable failure summary visible");
72 ok(!failed.includes("handshake failed"), "status entry keeps raw connection diagnostics out of primary chrome");
73
74 const degraded = renderStatusBar({
75 workspacePath: "/workspace/repo",
76 remoteHosts,
77 remoteStatuses: {
78 demo: {
79 hostId: "demo",
80 state: "degraded",
81 error: "forward attach failed",
82 },
83 },
84 });
85 ok(degraded.includes("demo · Degraded"), "degraded SSH remains connected with a warning state");
86 ok(!degraded.includes("demo · Connection failed"), "degraded SSH is not mislabeled as a failed connection");
87 }
88
89 {
90 const propsWithLegacySandbox = {
91 workspacePath: "/workspace/repo",
92 workspaceName: "repo",
93 sandboxPath: "/sandbox/repo",
94 gitBranch: "feature/meta",
95 };
96 const html = renderStatusBar(propsWithLegacySandbox);
97 ok(html.includes("workspace/repo"), "workspace chip uses workspace path");
98 ok(!html.includes("sandbox/repo"), "workspace chip does not display sandbox path");
99 ok(html.includes("feature/meta"), "git branch remains visible");
100 }
101
102 {
103 const html = renderStatusBar({
104 items: ["cache"],
105 workspacePath: "/workspace/repo",
106 workspaceName: "repo",
107 gitBranch: "feature/meta",
108 });
109 ok(!html.includes("workspace/repo"), "workspace can be hidden by status item config");
110 ok(!html.includes("feature/meta"), "git branch can be hidden by status item config");
111 }
112
113 {
114 const html = renderStatusBar({
115 items: ["git_branch", "workspace"],
116 workspacePath: "/workspace/repo",
117 workspaceName: "repo",
118 gitBranch: "feature/meta",
119 });
120 ok(html.indexOf("feature/meta") >= 0 && html.indexOf("workspace/repo") >= 0, "workspace and git branch render as configured items");
121 ok(html.indexOf("feature/meta") < html.indexOf("workspace/repo"), "workspace items follow configured order");
122 }
123
124 {
125 const html = renderStatusBar({ items: ["model"] });
126 ok(!html.includes("YOLO"), "status bar renders only configured status items, not mode indicators");
127 ok(!html.includes("后台作业") && !html.includes("Background jobs"), "status bar hides the operational jobs entry while idle");
128 }
129
130 {
131 const html = renderStatusBar({
132 items: ["model"],
133 jobs: [{ id: "bash-1", kind: "bash", label: "run tests", status: "running", startedAt: 1 }],
134 });
135 ok(html.includes("Background jobs"), "running background jobs remain visible outside configurable metrics");
136 ok(html.includes("1"), "background jobs entry exposes the running count");
137 }
138
139 {
140 const html = renderStatusBar({
141 items: ["model"],
142 backgroundRuntimes: [{
143 tabId: "running-1", title: "Detached delivery", detached: true,
144 running: true, pendingPrompt: false, jobs: [],
145 }],
146 });
147 ok(html.includes("Background jobs"), "a running detached task remains visible without child jobs");
148 ok(html.includes("<b>1</b>"), "a jobless active runtime contributes to the recovery count");
149 }
150
151 {
152 const defaultItems = DEFAULT_STATUS_BAR_ITEMS as readonly string[];
153 ok(!defaultItems.includes("autoresearch"), "autoresearch is not a configurable status bar UI item");
154 }
155
156 {
157 const estimated = renderStatusBar({
158 items: ["session_tokens", "turn_tokens", "turn_cost", "cost"],
159 context: { used: 0, window: 0, sessionTokens: 1_200, estimated: true },
160 usage: {
161 promptTokens: 800,
162 completionTokens: 200,
163 totalTokens: 1_000,
164 cacheHitTokens: 0,
165 cacheMissTokens: 800,
166 estimated: true,
167 },
168 sessionTokens: 1_200,
169 turnTokens: 1_000,
170 turnCost: 0.2,
171 cost: 0.3,
172 currency: "USD",
173 });
174 ok((estimated.match(/≈/g) ?? []).length === 4, "estimated token and cost metrics use an approximation marker");
175
176 const empty = renderStatusBar({
177 items: ["session_tokens", "turn_tokens", "turn_cost", "cost"],
178 context: { used: 0, window: 0, sessionTokens: 0, estimated: true },
179 usage: {
180 promptTokens: 0,
181 completionTokens: 0,
182 totalTokens: 0,
183 cacheHitTokens: 0,
184 cacheMissTokens: 0,
185 estimated: true,
186 },
187 currency: "USD",
188 });
189 ok(!empty.includes("≈-"), "empty estimated metrics remain a plain dash");
190 }
191
192 {
193 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
194 pretendToBeVisual: true,
195 url: "http://localhost/",
196 });
197 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
198 globalThis.window = dom.window as unknown as Window & typeof globalThis;
199 globalThis.document = dom.window.document;
200 globalThis.Node = dom.window.Node;
201 globalThis.HTMLElement = dom.window.HTMLElement;
202 globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
203 globalThis.Event = dom.window.Event;
204 globalThis.MouseEvent = dom.window.MouseEvent;
205 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
206 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
207 Object.defineProperty(window, "matchMedia", {
208 configurable: true,
209 value: () => ({ matches: true, addEventListener() {}, removeEventListener() {} }),
210 });
211
212 let stopped = "";
213 const rootEl = document.getElementById("root")!;
214 const root = createRoot(rootEl);
215 await act(async () => {
216 root.render(
217 <LocaleProvider>
218 <StatusBar
219 context={{ used: 0, window: 0, sessionTokens: 0 }}
220 running={false}
221 jobs={[{ id: "bash-1", kind: "bash", label: "run tests", status: "running", startedAt: 1 }]}
222 onCancelJob={async (jobID) => { stopped = jobID; return true; }}
223 />
224 </LocaleProvider>,
225 );
226 });
227 const jobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
228 await act(async () => { jobsButton?.click(); });
229 const stopButton = document.body.querySelector<HTMLButtonElement>(".jobs-popover__stop");
230 await act(async () => { stopButton?.click(); await Promise.resolve(); });
231 ok(stopped === "bash-1", "background jobs popover routes Stop to the selected job");
232
233 let routed = "";
234 let revealed = "";
235 await act(async () => {
236 root.render(
237 <LocaleProvider>
238 <StatusBar
239 context={{ used: 0, window: 0, sessionTokens: 0 }}
240 running={false}
241 backgroundRuntimes={[
242 {
243 tabId: "detached-1", title: "Detached delivery", detached: true,
244 running: false, pendingPrompt: false,
245 jobs: [{ id: "go-1", kind: "go", label: "go test", status: "running", startedAt: 1 }],
246 },
247 ]}
248 onCancelRuntimeJob={async (tabID, jobID) => { routed = `${tabID}:${jobID}`; return true; }}
249 onRevealRuntime={async (tabID) => { revealed = tabID; }}
250 />
251 </LocaleProvider>,
252 );
253 });
254 const globalJobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
255 if (globalJobsButton?.getAttribute("aria-expanded") !== "true") {
256 await act(async () => { globalJobsButton?.click(); });
257 }
258 const globalStop = document.body.querySelector<HTMLButtonElement>(".jobs-popover__stop");
259 const openTask = Array.from(document.body.querySelectorAll<HTMLButtonElement>(".jobs-popover__runtime-header button"))[0];
260 await act(async () => { globalStop?.click(); openTask?.click(); await Promise.resolve(); });
261 ok(routed === "detached-1:go-1", "global jobs route Stop to the owning detached task");
262 ok(revealed === "detached-1", "global jobs can reopen the exact detached task");
263
264 revealed = "";
265 await act(async () => {
266 root.render(
267 <LocaleProvider>
268 <StatusBar
269 context={{ used: 0, window: 0, sessionTokens: 0 }}
270 running={false}
271 backgroundRuntimes={[
272 {
273 tabId: "prompt-1", title: "Waiting delivery", detached: true,
274 running: false, pendingPrompt: true, jobs: [],
275 },
276 ]}
277 onRevealRuntime={async (tabID) => { revealed = tabID; }}
278 />
279 </LocaleProvider>,
280 );
281 });
282 const promptJobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
283 if (promptJobsButton?.getAttribute("aria-expanded") !== "true") {
284 await act(async () => { promptJobsButton?.click(); });
285 }
286 ok(document.body.textContent?.includes("Waiting for input") === true, "pending-prompt runtime explains why it remains active");
287 const promptOpenTask = document.body.querySelector<HTMLButtonElement>(".jobs-popover__runtime-header button");
288 await act(async () => { promptOpenTask?.click(); await Promise.resolve(); });
289 ok(revealed === "prompt-1", "a pending-prompt runtime can be reopened without child jobs");
290
291 await act(async () => {
292 root.render(
293 <LocaleProvider>
294 <StatusBar
295 context={{ used: 0, window: 0, sessionTokens: 0 }}
296 running={false}
297 jobs={[{ id: "local-job", kind: "go", label: "local test", status: "running", startedAt: 1 }]}
298 />
299 </LocaleProvider>,
300 );
301 });
302 const mixedJobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
303 ok(mixedJobsButton?.textContent?.includes("1") === true, "local jobs show in the status bar total");
304 if (mixedJobsButton?.getAttribute("aria-expanded") !== "true") {
305 await act(async () => { mixedJobsButton?.click(); });
306 }
307 ok(document.body.textContent?.includes("local test") === true, "local background jobs remain visible");
308 await act(async () => { root.unmount(); });
309 dom.window.close();
310 }
311
312 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
313 if (failed > 0) process.exit(1);
314
314 lines Plain Text