返回 DeepSeek-Reasonix
use-controller-live-context.test.tsx
根目录 / desktop / frontend / src / __tests__ / use-controller-live-context.test.tsx
1 // Run: tsx src/__tests__/use-controller-live-context.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { act, useCallback, useSyncExternalStore } from "react";
5 import { createRoot } from "react-dom/client";
6 import { ContextPanel } from "../components/ContextPanel";
7 import { StatusBar } from "../components/StatusBar";
8 import type { AppBindings } from "../lib/bridge";
9 import { LocaleProvider } from "../lib/i18n";
10 import type { BalanceInfo, ContextInfo, ContextPanelInfo, EffortInfo, Meta, TabMeta, WireEvent } from "../lib/types";
11 import { useController } from "../lib/useController";
12
13 let passed = 0;
14 let failed = 0;
15
16 function ok(value: boolean, label: string) {
17 if (value) {
18 process.stdout.write(` PASS ${label}\n`);
19 passed += 1;
20 } else {
21 process.stdout.write(` FAIL ${label}\n`);
22 failed += 1;
23 }
24 }
25
26 function eq(actual: unknown, expected: unknown, label: string) {
27 ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
28 }
29
30 function flushPromises(delay = 0): Promise<void> {
31 return new Promise((resolve) => setTimeout(resolve, delay));
32 }
33
34 function deferred<T>() {
35 let resolve!: (value: T) => void;
36 const promise = new Promise<T>((res) => {
37 resolve = res;
38 });
39 return { promise, resolve };
40 }
41
42 async function settleUntil(predicate: () => boolean): Promise<boolean> {
43 for (let attempt = 0; attempt < 30; attempt += 1) {
44 await act(async () => {
45 await flushPromises();
46 });
47 if (predicate()) return true;
48 }
49 return false;
50 }
51
52 function tabMeta(): TabMeta {
53 return {
54 id: "tab-live-context",
55 scope: "project",
56 workspaceRoot: "/repo",
57 workspaceName: "repo",
58 workspacePath: "/repo",
59 topicId: "topic-live-context",
60 topicTitle: "Live context",
61 label: "model",
62 ready: true,
63 running: false,
64 cancellable: false,
65 mode: "normal",
66 toolApprovalMode: "ask",
67 tokenMode: "full",
68 active: true,
69 cwd: "/repo",
70 };
71 }
72
73 function meta(): Meta {
74 return {
75 label: backendModel,
76 ready: true,
77 eventChannel: "agent:event",
78 cwd: "/repo",
79 workspaceRoot: "/repo",
80 workspaceName: "repo",
81 workspacePath: "/repo",
82 autoApproveTools: false,
83 bypass: false,
84 collaborationMode: "normal",
85 toolApprovalMode: "ask",
86 tokenMode: "full",
87 goal: "",
88 goalStatus: "stopped",
89 };
90 }
91
92 function usageEvent(source = "executor"): WireEvent {
93 return {
94 kind: "usage",
95 tabId: "tab-live-context",
96 usage: {
97 promptTokens: 100,
98 completionTokens: 10,
99 totalTokens: 110,
100 cacheHitTokens: 90,
101 cacheMissTokens: 10,
102 sessionCacheHitTokens: 90,
103 sessionCacheMissTokens: 10,
104 source,
105 },
106 };
107 }
108
109 console.log("\nuse controller live context refresh");
110
111 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
112 pretendToBeVisual: true,
113 url: "http://localhost/",
114 });
115 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
116 globalThis.window = dom.window as unknown as Window & typeof globalThis;
117 globalThis.document = dom.window.document;
118 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
119 globalThis.Node = dom.window.Node;
120 globalThis.HTMLElement = dom.window.HTMLElement;
121 globalThis.Event = dom.window.Event;
122 globalThis.CustomEvent = dom.window.CustomEvent;
123 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
124 globalThis.MouseEvent = dom.window.MouseEvent;
125 globalThis.localStorage = dom.window.localStorage;
126 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
127 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
128
129 const eventHandlers: Array<(event: WireEvent) => void> = [];
130 const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
131 let backendContext: ContextInfo = {
132 used: 100,
133 window: 1_000,
134 sessionTokens: 110,
135 cacheHitTokens: 0,
136 cacheMissTokens: 100,
137 };
138 let contextCalls = 0;
139 let contextLoader: (() => Promise<ContextInfo>) | undefined;
140 let backendBalance: BalanceInfo = { available: true, display: "¥88.00" };
141 let backendModel = "model";
142 let balanceCalls = 0;
143 let balanceLoader: (() => Promise<BalanceInfo>) | undefined;
144 type ModelSwitchStep = {
145 gate: ReturnType<typeof deferred<void>>;
146 balance?: BalanceInfo;
147 error?: Error;
148 };
149 const modelSwitchSteps: ModelSwitchStep[] = [];
150 let modelSwitchCalls = 0;
151 const stalePanelInfo: ContextPanelInfo = {
152 usedTokens: 100,
153 windowTokens: 1_000,
154 promptTokens: 100,
155 completionTokens: 10,
156 totalTokens: 110,
157 reasoningTokens: 0,
158 cacheHitTokens: 0,
159 cacheMissTokens: 100,
160 sessionCacheHitTokens: 0,
161 sessionCacheMissTokens: 100,
162 sessionCompletionTokens: 10,
163 requestCount: 1,
164 elapsedMs: 1_000,
165 readFiles: [],
166 changedFiles: [],
167 };
168
169 window.runtime = {
170 EventsOn: (name: string, cb: (payload: unknown) => void) => {
171 if (name === "agent:event") eventHandlers.push(cb as (event: WireEvent) => void);
172 return () => {};
173 },
174 BrowserOpenURL: () => {},
175 };
176 window.go = {
177 main: {
178 App: {
179 ListTabs: async () => [tabMeta()],
180 MetaForTab: async () => meta(),
181 ContextUsageForTab: async () => {
182 contextCalls += 1;
183 return contextLoader ? contextLoader() : backendContext;
184 },
185 // Keep this private snapshot deliberately stale. The shared ContextInfo
186 // must still keep the panel average aligned with StatusBar during bursts.
187 ContextPanel: async () => stalePanelInfo,
188 EffortForTab: async () => effort,
189 BalanceForTab: async () => {
190 balanceCalls += 1;
191 return balanceLoader ? balanceLoader() : backendBalance;
192 },
193 SetModelForTab: async (_tabId, name) => {
194 modelSwitchCalls += 1;
195 const step = modelSwitchSteps.shift();
196 if (!step) throw new Error("missing model switch step");
197 await step.gate.promise;
198 if (step.error) throw step.error;
199 backendModel = name;
200 backendBalance = step.balance ?? { available: false, display: "" };
201 balanceLoader = undefined;
202 },
203 CloseTab: async () => {
204 throw new Error("cannot close tab");
205 },
206 JobsForTab: async () => [],
207 CheckpointsForTab: async () => [],
208 HistoryForTab: async () => [],
209 HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
210 HistoryCheckpointTurnsForTab: async () => [],
211 ReplayPendingPrompts: async () => {},
212 } as Partial<AppBindings> as AppBindings,
213 },
214 };
215
216 type Controller = ReturnType<typeof useController>;
217 let controller: Controller | undefined;
218 let controllerProbeRenders = 0;
219
220 function LiveProbe({ value }: { value: Controller }) {
221 const subscribe = useCallback(
222 (listener: () => void) => value.liveStore.subscribe(value.activeTabId, listener),
223 [value.activeTabId, value.liveStore],
224 );
225 const getSnapshot = useCallback(
226 () => value.liveStore.getSnapshot(value.activeTabId)?.text ?? "",
227 [value.activeTabId, value.liveStore],
228 );
229 const text = useSyncExternalStore(subscribe, getSnapshot);
230 return <span data-live-text>{text}</span>;
231 }
232
233 function Probe() {
234 controller = useController();
235 controllerProbeRenders += 1;
236 return (
237 <LocaleProvider>
238 <>
239 <StatusBar
240 context={controller.state.context}
241 usage={controller.state.usage}
242 balance={controller.state.balance}
243 running={controller.state.running}
244 items={["cache_avg", "balance"]}
245 />
246 <ContextPanel
247 tabId={controller.activeTabId}
248 context={controller.state.context}
249 usage={controller.state.usage}
250 sessionTokens={controller.state.sessionTokens}
251 sessionCost={controller.state.sessionCost}
252 sessionCurrency={controller.state.sessionCurrency}
253 turnTokens={controller.state.turnTotalTokens}
254 turnCost={controller.state.turnCost}
255 sessionGen={controller.state.sessionGen}
256 usageSeq={controller.state.usageSeq}
257 />
258 <LiveProbe value={controller} />
259 </>
260 </LocaleProvider>
261 );
262 }
263
264 function renderedAverage(): string {
265 return document.querySelector('[data-statusbar-item="cache_avg"] b')?.textContent ?? "";
266 }
267
268 function renderedPanelAverage(): string {
269 return document.querySelector(".context-panel__summary-rows .context-panel__mini-stat strong")?.textContent ?? "";
270 }
271
272 function renderedBalance(): string {
273 return document.querySelector('[data-statusbar-item="balance"] b')?.textContent ?? "";
274 }
275
276 const rootEl = document.getElementById("root");
277 if (!rootEl) throw new Error("missing root");
278 const root = createRoot(rootEl);
279
280 await act(async () => {
281 root.render(<Probe />);
282 await flushPromises();
283 });
284
285 ok(
286 await settleUntil(() => controller?.activeTabId === "tab-live-context" && controller.state.context.cacheMissTokens === 100),
287 "initial completed-turn context loads",
288 );
289 ok(await settleUntil(() => renderedBalance() === "¥88.00"), "initial DeepSeek balance loads");
290 const initialContextCalls = contextCalls;
291
292 backendContext = {
293 used: 900,
294 window: 1_000,
295 sessionTokens: 1_000,
296 cacheHitTokens: 900,
297 cacheMissTokens: 100,
298 };
299 await act(async () => {
300 for (const handler of eventHandlers) {
301 handler({ kind: "turn_started", tabId: "tab-live-context" });
302 handler(usageEvent());
303 }
304 await flushPromises();
305 });
306
307 ok(
308 await settleUntil(() => controller?.state.context.cacheHitTokens === 900),
309 "executor usage refreshes all-source context before turn_done",
310 );
311 eq(renderedAverage(), "90.00%", "status bar renders the live executor-era session average");
312 eq(renderedPanelAverage(), "90.00%", "panel ignores its stale private snapshot and matches the status bar");
313 ok(contextCalls > initialContextCalls, "usage triggers a new ContextUsageForTab snapshot");
314
315 const rendersBeforeTextBurst = controllerProbeRenders;
316 await act(async () => {
317 for (const handler of eventHandlers) {
318 handler({ kind: "text", tabId: "tab-live-context", text: "one " });
319 handler({ kind: "text", tabId: "tab-live-context", text: "two " });
320 handler({ kind: "text", tabId: "tab-live-context", text: "three" });
321 }
322 await flushPromises(20);
323 });
324 eq(document.querySelector("[data-live-text]")?.textContent, "one two three", "live subscriber receives the coalesced text burst");
325 eq(controllerProbeRenders, rendersBeforeTextBurst, "pure stream deltas do not re-render the controller owner");
326
327 backendContext = {
328 used: 960,
329 window: 1_000,
330 sessionTokens: 1_100,
331 cacheHitTokens: 960,
332 cacheMissTokens: 40,
333 };
334 await act(async () => {
335 for (const handler of eventHandlers) handler(usageEvent("subagent"));
336 await flushPromises();
337 });
338
339 ok(
340 await settleUntil(() => controller?.state.context.cacheHitTokens === 960),
341 "subagent usage also refreshes the shared all-source context",
342 );
343 eq(renderedAverage(), "96.00%", "status bar renders the live all-source session average");
344 eq(renderedPanelAverage(), "96.00%", "panel stays aligned after a burst usage update");
345 eq(controller?.state.usage?.source, "executor", "subagent usage does not replace the executor latest-request metric");
346
347 const staleSnapshot = deferred<ContextInfo>();
348 const latestSnapshot = deferred<ContextInfo>();
349 const pendingSnapshots = [staleSnapshot.promise, latestSnapshot.promise];
350 contextLoader = async () => pendingSnapshots.shift() ?? backendContext;
351 const raceStartCalls = contextCalls;
352
353 await act(async () => {
354 for (const handler of eventHandlers) handler(usageEvent());
355 await flushPromises();
356 });
357 ok(await settleUntil(() => contextCalls === raceStartCalls + 1), "first live snapshot starts");
358
359 await act(async () => {
360 for (const handler of eventHandlers) handler(usageEvent());
361 await flushPromises();
362 });
363 ok(await settleUntil(() => contextCalls === raceStartCalls + 2), "newer live snapshot starts");
364
365 latestSnapshot.resolve({
366 used: 990,
367 window: 1_000,
368 sessionTokens: 1_200,
369 cacheHitTokens: 990,
370 cacheMissTokens: 10,
371 });
372 ok(
373 await settleUntil(() => controller?.state.context.cacheHitTokens === 990),
374 "newest usage snapshot wins",
375 );
376 eq(renderedAverage(), "99.00%", "status bar follows the newest usage snapshot");
377 eq(renderedPanelAverage(), "99.00%", "panel follows the same newest usage snapshot");
378
379 staleSnapshot.resolve({
380 used: 100,
381 window: 1_000,
382 sessionTokens: 200,
383 cacheHitTokens: 100,
384 cacheMissTokens: 900,
385 });
386 await act(async () => {
387 await flushPromises();
388 });
389 eq(controller?.state.context.cacheHitTokens, 990, "late stale snapshot cannot regress the status bar");
390 eq(renderedAverage(), "99.00%", "late stale snapshot cannot regress the rendered average");
391 eq(renderedPanelAverage(), "99.00%", "late stale snapshot cannot regress the panel average");
392 contextLoader = undefined;
393
394 const staleBalance = deferred<BalanceInfo>();
395 balanceLoader = () => staleBalance.promise;
396 const balanceRaceStartCalls = balanceCalls;
397 await act(async () => {
398 for (const handler of eventHandlers) handler({ kind: "turn_done", tabId: "tab-live-context" });
399 await flushPromises();
400 });
401 ok(await settleUntil(() => balanceCalls === balanceRaceStartCalls + 1), "pre-switch balance refresh starts");
402
403 const firstSwitchGate = deferred<void>();
404 const latestSwitchGate = deferred<void>();
405 modelSwitchSteps.push(
406 { gate: firstSwitchGate, balance: { available: true, display: "A 40.00" } },
407 { gate: latestSwitchGate, balance: { available: true, display: "B 25.00" } },
408 );
409 const modelSwitchStartCalls = modelSwitchCalls;
410 let firstSwitchPromise: Promise<boolean> | undefined;
411 let latestSwitchPromise: Promise<boolean> | undefined;
412 await act(async () => {
413 firstSwitchPromise = controller?.setModel("provider-a/model-a");
414 latestSwitchPromise = controller?.setModel("provider-b/model-b");
415 await flushPromises();
416 });
417 eq(renderedBalance(), "-", "starting a hot model switch immediately hides the DeepSeek balance");
418 eq(modelSwitchCalls, modelSwitchStartCalls + 1, "rapid model switches enter the backend in click order");
419
420 firstSwitchGate.resolve();
421 let firstSwitchResult: boolean | undefined;
422 await act(async () => {
423 firstSwitchResult = await firstSwitchPromise;
424 await flushPromises();
425 });
426 eq(firstSwitchResult, false, "superseded model switch reports that it no longer owns the UI");
427 ok(await settleUntil(() => modelSwitchCalls === modelSwitchStartCalls + 2), "latest model switch starts after the older backend call");
428 eq(renderedBalance(), "-", "superseded switch cannot restore its provider balance");
429
430 latestSwitchGate.resolve();
431 let latestSwitchResult: boolean | undefined;
432 await act(async () => {
433 latestSwitchResult = await latestSwitchPromise;
434 await flushPromises();
435 });
436 eq(latestSwitchResult, true, "latest model switch owns the completed UI refresh");
437 ok(await settleUntil(() => renderedBalance() === "B 25.00"), "latest model switch balance wins");
438
439 const coalescedFirstGate = deferred<void>();
440 const coalescedLatestGate = deferred<void>();
441 modelSwitchSteps.push(
442 { gate: coalescedFirstGate, balance: { available: true, display: "G 20.00" } },
443 { gate: coalescedLatestGate, balance: { available: true, display: "I 15.00" } },
444 );
445 const coalescedStartCalls = modelSwitchCalls;
446 let coalescedFirst: Promise<boolean> | undefined;
447 let coalescedMiddle: Promise<boolean> | undefined;
448 let coalescedLatest: Promise<boolean> | undefined;
449 await act(async () => {
450 coalescedFirst = controller?.setModel("provider-g/model-g");
451 coalescedMiddle = controller?.setModel("provider-h/model-h");
452 coalescedLatest = controller?.setModel("provider-i/model-i");
453 await flushPromises();
454 });
455 eq(modelSwitchCalls, coalescedStartCalls + 1, "only the active model switch enters the backend immediately");
456 eq(await coalescedMiddle, false, "an unstarted intermediate model switch is coalesced");
457
458 coalescedFirstGate.resolve();
459 await act(async () => {
460 await coalescedFirst;
461 await flushPromises();
462 });
463 ok(
464 await settleUntil(() => modelSwitchCalls === coalescedStartCalls + 2),
465 "the latest coalesced model switch starts after the active call",
466 );
467 coalescedLatestGate.resolve();
468 let coalescedLatestResult: boolean | undefined;
469 await act(async () => {
470 coalescedLatestResult = await coalescedLatest;
471 await flushPromises();
472 });
473 eq(coalescedLatestResult, true, "the latest coalesced model switch owns reconciliation");
474 eq(backendModel, "provider-i/model-i", "the skipped intermediate model never reaches the backend");
475 eq(modelSwitchCalls, coalescedStartCalls + 2, "three rapid picks perform only two controller rebuilds");
476
477 staleBalance.resolve({ available: true, display: "¥88.00" });
478 await act(async () => {
479 await flushPromises();
480 });
481 eq(renderedBalance(), "I 15.00", "late DeepSeek balance response cannot overwrite the latest switched provider");
482
483 const overlappingSuccessGate = deferred<void>();
484 const overlappingFailureGate = deferred<void>();
485 modelSwitchSteps.push(
486 { gate: overlappingSuccessGate, balance: { available: true, display: "A 40.00" } },
487 { gate: overlappingFailureGate, error: new Error("provider B failed") },
488 );
489 let overlappingSuccess: Promise<boolean> | undefined;
490 let overlappingFailure: Promise<boolean> | undefined;
491 await act(async () => {
492 overlappingSuccess = controller?.setModel("provider-a/model-a");
493 overlappingFailure = controller?.setModel("provider-b/model-b");
494 await flushPromises();
495 });
496 overlappingSuccessGate.resolve();
497 await act(async () => {
498 await overlappingSuccess;
499 await flushPromises();
500 });
501 eq(renderedBalance(), "-", "older successful switch stays hidden while the latest switch is pending");
502 overlappingFailureGate.resolve();
503 let overlappingFailureResult: boolean | undefined;
504 await act(async () => {
505 overlappingFailureResult = await overlappingFailure;
506 await flushPromises();
507 });
508 eq(overlappingFailureResult, false, "latest failed switch reports failure");
509 ok(
510 await settleUntil(() => renderedBalance() === "A 40.00"),
511 "failed latest switch refreshes the provider established by the older queued success",
512 );
513 eq(
514 controller?.state.meta?.label,
515 "provider-a/model-a",
516 "failed latest switch reconciles metadata from the older queued success",
517 );
518
519 const failedSwitchGate = deferred<void>();
520 modelSwitchSteps.push({ gate: failedSwitchGate, error: new Error("session is busy") });
521 balanceLoader = async () => ({ available: false, display: "", err: "balance fetch failed" });
522 let failedSwitch: Promise<boolean> | undefined;
523 await act(async () => {
524 failedSwitch = controller?.setModel("provider-c/model-c");
525 await flushPromises();
526 });
527 eq(renderedBalance(), "-", "a failing switch hides the outgoing balance while pending");
528 failedSwitchGate.resolve();
529 let failedSwitchResult: boolean | undefined;
530 await act(async () => {
531 failedSwitchResult = await failedSwitch;
532 await flushPromises();
533 });
534 eq(failedSwitchResult, false, "failed model switch reports failure to its caller");
535 eq(renderedBalance(), "A 40.00", "failed switch restores the known balance when its confirmation refresh fails");
536
537 const firstFailedQueueGate = deferred<void>();
538 const latestFailedQueueGate = deferred<void>();
539 modelSwitchSteps.push(
540 { gate: firstFailedQueueGate, error: new Error("provider E failed") },
541 { gate: latestFailedQueueGate, error: new Error("provider F failed") },
542 );
543 const failedQueueStartCalls = modelSwitchCalls;
544 let firstFailedQueueSwitch: Promise<boolean> | undefined;
545 let latestFailedQueueSwitch: Promise<boolean> | undefined;
546 await act(async () => {
547 firstFailedQueueSwitch = controller?.setModel("provider-e/model-e");
548 latestFailedQueueSwitch = controller?.setModel("provider-f/model-f");
549 await flushPromises();
550 });
551 eq(renderedBalance(), "-", "queued failing switches keep the outgoing balance hidden while pending");
552 eq(modelSwitchCalls, failedQueueStartCalls + 1, "queued failing switches enter the backend in click order");
553
554 firstFailedQueueGate.resolve();
555 let firstFailedQueueResult: boolean | undefined;
556 await act(async () => {
557 firstFailedQueueResult = await firstFailedQueueSwitch;
558 await flushPromises();
559 });
560 eq(firstFailedQueueResult, false, "superseded queued failure does not own balance reconciliation");
561 ok(
562 await settleUntil(() => modelSwitchCalls === failedQueueStartCalls + 2),
563 "latest queued failing switch starts after the older failure",
564 );
565 eq(renderedBalance(), "-", "superseded queued failure cannot restore the outgoing balance");
566
567 latestFailedQueueGate.resolve();
568 let latestFailedQueueResult: boolean | undefined;
569 await act(async () => {
570 latestFailedQueueResult = await latestFailedQueueSwitch;
571 await flushPromises();
572 });
573 eq(latestFailedQueueResult, false, "latest queued failure reports failure");
574 eq(
575 renderedBalance(),
576 "A 40.00",
577 "consecutive queued failures restore the pre-queue balance when confirmation fails",
578 );
579 balanceLoader = undefined;
580
581 const switchDuringFailedCloseGate = deferred<void>();
582 modelSwitchSteps.push({
583 gate: switchDuringFailedCloseGate,
584 balance: { available: true, display: "D 10.00" },
585 });
586 let switchDuringFailedClose: Promise<boolean> | undefined;
587 await act(async () => {
588 switchDuringFailedClose = controller?.setModel("provider-d/model-d");
589 await flushPromises();
590 });
591 await act(async () => {
592 await controller?.closeTab("tab-live-context");
593 await flushPromises();
594 });
595 switchDuringFailedCloseGate.resolve();
596 let switchDuringFailedCloseResult: boolean | undefined;
597 await act(async () => {
598 switchDuringFailedCloseResult = await switchDuringFailedClose;
599 await flushPromises();
600 });
601 eq(switchDuringFailedCloseResult, true, "failed close keeps the in-flight model switch current");
602 eq(controller?.activeTabId, "tab-live-context", "failed close keeps the tab mounted");
603 eq(
604 controller?.state.meta?.label,
605 "provider-d/model-d",
606 "failed close preserves model metadata reconciliation",
607 );
608 ok(
609 await settleUntil(() => renderedBalance() === "D 10.00"),
610 "failed close preserves model balance reconciliation",
611 );
612
613 await act(async () => {
614 root.unmount();
615 });
616 dom.window.close();
617
618 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
619 if (failed > 0) process.exit(1);
620
620 lines Plain Text