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