| 1 | // Run: tsx src/__tests__/new-session-load-race.test.tsx |
| 2 | |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | import React, { act } from "react"; |
| 5 | import { createRoot } from "react-dom/client"; |
| 6 | import { initialState, reducer, useController, type Item } from "../lib/useController"; |
| 7 | import type { AppBindings } from "../lib/bridge"; |
| 8 | import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta, WireEvent } from "../lib/types"; |
| 9 | |
| 10 | let passed = 0; |
| 11 | let failed = 0; |
| 12 | |
| 13 | function ok(value: boolean, label: string) { |
| 14 | if (value) { |
| 15 | process.stdout.write(` PASS ${label}\n`); |
| 16 | passed += 1; |
| 17 | } else { |
| 18 | process.stdout.write(` FAIL ${label}\n`); |
| 19 | failed += 1; |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | function eq(actual: unknown, expected: unknown, label: string) { |
| 24 | if (actual === expected) { |
| 25 | ok(true, label); |
| 26 | } else { |
| 27 | ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | function flushPromises(): Promise<void> { |
| 32 | return new Promise((resolve) => setTimeout(resolve, 0)); |
| 33 | } |
| 34 | |
| 35 | function deferred<T>() { |
| 36 | let resolve!: (value: T) => void; |
| 37 | let reject!: (reason?: unknown) => void; |
| 38 | const promise = new Promise<T>((res, rej) => { |
| 39 | resolve = res; |
| 40 | reject = rej; |
| 41 | }); |
| 42 | return { promise, resolve, reject }; |
| 43 | } |
| 44 | |
| 45 | async function waitFor(label: string, predicate: () => boolean) { |
| 46 | for (let attempt = 0; attempt < 20; attempt += 1) { |
| 47 | await act(async () => { |
| 48 | await flushPromises(); |
| 49 | }); |
| 50 | if (predicate()) return; |
| 51 | } |
| 52 | throw new Error(`timed out waiting for ${label}`); |
| 53 | } |
| 54 | |
| 55 | function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta { |
| 56 | return { |
| 57 | id: "tab-a", |
| 58 | scope: "project", |
| 59 | workspaceRoot: "/repo", |
| 60 | workspaceName: "repo", |
| 61 | workspacePath: "/repo", |
| 62 | gitBranch: "main", |
| 63 | topicId: "topic-a", |
| 64 | topicTitle: "General", |
| 65 | label: "model", |
| 66 | ready: true, |
| 67 | running: false, |
| 68 | mode: "normal", |
| 69 | toolApprovalMode: "ask", |
| 70 | tokenMode: "full", |
| 71 | active: true, |
| 72 | cwd: "/repo", |
| 73 | ...overrides, |
| 74 | }; |
| 75 | } |
| 76 | |
| 77 | function meta(overrides: Partial<Meta> = {}): Meta { |
| 78 | return { |
| 79 | label: "model", |
| 80 | ready: true, |
| 81 | eventChannel: "agent:event", |
| 82 | cwd: "/repo", |
| 83 | workspaceRoot: "/repo", |
| 84 | workspaceName: "repo", |
| 85 | workspacePath: "/repo", |
| 86 | gitBranch: "main", |
| 87 | autoApproveTools: false, |
| 88 | bypass: false, |
| 89 | collaborationMode: "normal", |
| 90 | toolApprovalMode: "ask", |
| 91 | tokenMode: "full", |
| 92 | goal: "", |
| 93 | goalStatus: "stopped", |
| 94 | ...overrides, |
| 95 | }; |
| 96 | } |
| 97 | |
| 98 | console.log("\nnew session load race"); |
| 99 | |
| 100 | const resetSourceItems: Item[] = [{ kind: "user", id: "old-user", text: "old prompt" }]; |
| 101 | const resetPlaceholderItems: Item[] = [{ kind: "user", id: "placeholder-user", text: "placeholder prompt" }]; |
| 102 | const resetState = reducer( |
| 103 | { |
| 104 | ...initialState, |
| 105 | items: resetSourceItems, |
| 106 | hydrating: true, |
| 107 | hydrateReason: "open-topic", |
| 108 | hydratePlaceholderItems: resetPlaceholderItems, |
| 109 | }, |
| 110 | { type: "reset" }, |
| 111 | ); |
| 112 | eq(resetState.items.length, 0, "reset clears real transcript items"); |
| 113 | eq(resetState.hydratePlaceholderItems?.length, 1, "reset preserves hydration placeholder separately"); |
| 114 | |
| 115 | const emptyHistoryState = reducer(resetState, { type: "history", messages: [] }); |
| 116 | eq(emptyHistoryState.items.length, 0, "empty history keeps the real transcript empty"); |
| 117 | eq(emptyHistoryState.hydrateHistoryLoaded, true, "empty history marks transcript hydration loaded"); |
| 118 | eq(emptyHistoryState.hydratePlaceholderItems?.length ?? 0, 0, "empty history clears hydration placeholder items"); |
| 119 | |
| 120 | const hydrateDoneState = reducer(emptyHistoryState, { type: "hydrate_done" }); |
| 121 | eq(Boolean(hydrateDoneState.hydrateHistoryLoaded), false, "hydrate_done clears the history-loaded marker"); |
| 122 | |
| 123 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { |
| 124 | pretendToBeVisual: true, |
| 125 | url: "http://localhost/", |
| 126 | }); |
| 127 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 128 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 129 | globalThis.document = dom.window.document; |
| 130 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 131 | globalThis.Node = dom.window.Node; |
| 132 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 133 | globalThis.Event = dom.window.Event; |
| 134 | globalThis.CustomEvent = dom.window.CustomEvent; |
| 135 | globalThis.KeyboardEvent = dom.window.KeyboardEvent; |
| 136 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 137 | globalThis.localStorage = dom.window.localStorage; |
| 138 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 139 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 140 | |
| 141 | const staleHistory = deferred<HistoryMessage[]>(); |
| 142 | const staleSessionMeta = deferred<Meta>(); |
| 143 | let newSessionCalls = 0; |
| 144 | let backendCanonicalTodos = [{ content: "Old task", status: "in_progress" }]; |
| 145 | let holdNextMeta = false; |
| 146 | let staleMetaStarted = false; |
| 147 | const eventHandlers: Array<(event: WireEvent) => void> = []; |
| 148 | const context: ContextInfo = { used: 12, window: 100, sessionTokens: 12 }; |
| 149 | const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] }; |
| 150 | const balance: BalanceInfo = { available: false, display: "" }; |
| 151 | const jobs: JobView[] = []; |
| 152 | const checkpoints: CheckpointMeta[] = []; |
| 153 | |
| 154 | window.runtime = { |
| 155 | EventsOn: (name: string, cb: (...data: unknown[]) => void) => { |
| 156 | if (name === "agent:event") eventHandlers.push(cb as (event: WireEvent) => void); |
| 157 | return () => {}; |
| 158 | }, |
| 159 | BrowserOpenURL: () => {}, |
| 160 | }; |
| 161 | window.go = { |
| 162 | main: { |
| 163 | App: { |
| 164 | ListTabs: async () => [tabMeta()], |
| 165 | MetaForTab: async () => { |
| 166 | if (holdNextMeta) { |
| 167 | holdNextMeta = false; |
| 168 | staleMetaStarted = true; |
| 169 | return staleSessionMeta.promise; |
| 170 | } |
| 171 | return meta({ canonicalTodos: backendCanonicalTodos }); |
| 172 | }, |
| 173 | ContextUsageForTab: async () => context, |
| 174 | EffortForTab: async () => effort, |
| 175 | BalanceForTab: async () => balance, |
| 176 | JobsForTab: async () => jobs, |
| 177 | CheckpointsForTab: async () => checkpoints, |
| 178 | HistoryForTab: async () => staleHistory.promise, |
| 179 | HistoryPageForTab: async () => { |
| 180 | const messages = await staleHistory.promise; |
| 181 | return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false }; |
| 182 | }, |
| 183 | HistoryCheckpointTurnsForTab: async () => [], |
| 184 | ReplayPendingPrompts: async () => {}, |
| 185 | NewSession: async () => { |
| 186 | newSessionCalls += 1; |
| 187 | backendCanonicalTodos = []; |
| 188 | }, |
| 189 | NewSessionForTab: async (tabID: string) => { |
| 190 | if (tabID !== "tab-a") throw new Error(`unexpected new-session target ${tabID}`); |
| 191 | newSessionCalls += 1; |
| 192 | backendCanonicalTodos = []; |
| 193 | }, |
| 194 | ResumeSessionPageForTab: async () => { |
| 195 | backendCanonicalTodos = [{ content: "Restored task", status: "completed" }]; |
| 196 | return { |
| 197 | messages: [{ role: "user", content: "restore" }, { role: "assistant", content: "done" }], |
| 198 | startTurn: 0, |
| 199 | endTurn: 1, |
| 200 | totalTurns: 1, |
| 201 | hasOlder: false, |
| 202 | }; |
| 203 | }, |
| 204 | } as Partial<AppBindings> as AppBindings, |
| 205 | }, |
| 206 | }; |
| 207 | |
| 208 | type Controller = ReturnType<typeof useController>; |
| 209 | let controller: Controller | undefined; |
| 210 | |
| 211 | function Probe() { |
| 212 | controller = useController(); |
| 213 | return null; |
| 214 | } |
| 215 | |
| 216 | const rootEl = document.getElementById("root"); |
| 217 | if (!rootEl) throw new Error("missing root"); |
| 218 | const root = createRoot(rootEl); |
| 219 | |
| 220 | await act(async () => { |
| 221 | root.render(<Probe />); |
| 222 | await flushPromises(); |
| 223 | }); |
| 224 | await waitFor("active tab", () => controller?.activeTabId === "tab-a"); |
| 225 | |
| 226 | await act(async () => { |
| 227 | await controller?.refreshMeta(); |
| 228 | await flushPromises(); |
| 229 | }); |
| 230 | eq(controller?.state.meta?.canonicalTodos?.[0]?.content, "Old task", "pre-reset metadata exposes the current session todo"); |
| 231 | |
| 232 | holdNextMeta = true; |
| 233 | await act(async () => { |
| 234 | for (const handler of eventHandlers) handler({ kind: "turn_done", tabId: "tab-a" }); |
| 235 | await flushPromises(); |
| 236 | }); |
| 237 | await waitFor("stale metadata request", () => staleMetaStarted); |
| 238 | |
| 239 | await act(async () => { |
| 240 | await controller?.newSession(); |
| 241 | await flushPromises(); |
| 242 | }); |
| 243 | eq(newSessionCalls, 1, "tab-scoped NewSession is called once"); |
| 244 | eq(controller?.state.items.length, 0, "new session clears the visible transcript"); |
| 245 | eq(controller?.state.meta?.canonicalTodos?.length, 0, "new session refresh replaces the previous session todo with an authoritative empty list"); |
| 246 | |
| 247 | await act(async () => { |
| 248 | staleSessionMeta.resolve(meta({ canonicalTodos: [{ content: "Old task", status: "in_progress" }] })); |
| 249 | await staleSessionMeta.promise; |
| 250 | await flushPromises(); |
| 251 | }); |
| 252 | eq(controller?.state.meta?.canonicalTodos?.length, 0, "metadata started before a session transition cannot restore the previous todo"); |
| 253 | |
| 254 | await act(async () => { |
| 255 | staleHistory.resolve([{ role: "user", content: "old prompt" }]); |
| 256 | await staleHistory.promise; |
| 257 | await flushPromises(); |
| 258 | }); |
| 259 | |
| 260 | eq(controller?.state.items.length, 0, "stale history load cannot repopulate a new blank session"); |
| 261 | |
| 262 | await act(async () => { |
| 263 | await controller?.resumeSession("/sessions/restored.jsonl", "tab-a"); |
| 264 | await flushPromises(); |
| 265 | }); |
| 266 | eq(controller?.state.meta?.canonicalTodos?.[0]?.status, "completed", "resuming a session refreshes its authoritative canonical todo state"); |
| 267 | |
| 268 | await act(async () => { |
| 269 | root.unmount(); |
| 270 | }); |
| 271 | |
| 272 | // Reusing a blank tab must invalidate the old hydration request. The backend |
| 273 | // may return the same tab id, so the request sequence (not the tab id) is the |
| 274 | // session boundary that prevents orphaned tool cards from coming back. |
| 275 | const reusedOldHistory = deferred<{ |
| 276 | messages: HistoryMessage[]; |
| 277 | startTurn: number; |
| 278 | endTurn: number; |
| 279 | totalTurns: number; |
| 280 | hasOlder: boolean; |
| 281 | }>(); |
| 282 | const reusedHistoryCalls: string[] = []; |
| 283 | const reusedTab = tabMeta({ id: "tab-reused", sessionPath: "/sessions/old.jsonl" }); |
| 284 | const reusedTabPage = { |
| 285 | messages: [ |
| 286 | { role: "assistant", content: "", toolCalls: [{ id: "old-call", name: "bash", arguments: "pwd" }] }, |
| 287 | { role: "tool", toolCallId: "old-call", toolName: "bash", content: "/old" }, |
| 288 | ] as HistoryMessage[], |
| 289 | startTurn: 0, |
| 290 | endTurn: 0, |
| 291 | totalTurns: 0, |
| 292 | hasOlder: false, |
| 293 | }; |
| 294 | const reusedEmptyPage = { messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }; |
| 295 | window.go.main.App = { |
| 296 | ListTabs: async () => [reusedTab], |
| 297 | MetaForTab: async () => meta({ sessionPath: "/sessions/new.jsonl" }), |
| 298 | ContextUsageForTab: async () => context, |
| 299 | EffortForTab: async () => effort, |
| 300 | BalanceForTab: async () => balance, |
| 301 | JobsForTab: async () => jobs, |
| 302 | CheckpointsForTab: async () => checkpoints, |
| 303 | HistoryPageForTab: async () => { |
| 304 | reusedHistoryCalls.push("history"); |
| 305 | return reusedHistoryCalls.length === 1 ? reusedOldHistory.promise : reusedEmptyPage; |
| 306 | }, |
| 307 | HistoryCheckpointTurnsForTab: async () => [], |
| 308 | ReplayPendingPrompts: async () => {}, |
| 309 | EnsureBlankTab: async () => ({ ...reusedTab, sessionPath: "/sessions/new.jsonl", active: true }), |
| 310 | } as Partial<AppBindings> as AppBindings; |
| 311 | |
| 312 | controller = undefined; |
| 313 | const reuseRoot = createRoot(rootEl); |
| 314 | await act(async () => { |
| 315 | reuseRoot.render(<Probe />); |
| 316 | await flushPromises(); |
| 317 | }); |
| 318 | await waitFor("reused tab startup history", () => reusedHistoryCalls.length === 1); |
| 319 | |
| 320 | await act(async () => { |
| 321 | await controller?.ensureBlankTab("project", "/repo"); |
| 322 | await flushPromises(); |
| 323 | }); |
| 324 | eq(reusedHistoryCalls.length, 2, "reusing a blank tab forces a fresh history request"); |
| 325 | eq(controller?.state.items.some((item) => item.kind === "tool" && item.id === "old-call"), false, "fresh blank-tab hydration has no old tool card"); |
| 326 | |
| 327 | await act(async () => { |
| 328 | reusedOldHistory.resolve(reusedTabPage); |
| 329 | await reusedOldHistory.promise; |
| 330 | await flushPromises(); |
| 331 | }); |
| 332 | eq(controller?.state.items.some((item) => item.kind === "tool" && item.id === "old-call"), false, "late old-session history cannot restore an orphaned tool card"); |
| 333 | |
| 334 | await act(async () => { |
| 335 | reuseRoot.unmount(); |
| 336 | }); |
| 337 | |
| 338 | // A tab-bar click can overtake EnsureBlankTab while its backend call is still |
| 339 | // in flight. Its intent must invalidate the older completion immediately, and |
| 340 | // the stale backend activation must be repaired after it eventually returns. |
| 341 | const queuedBlank = deferred<TabMeta>(); |
| 342 | const raceTabA = tabMeta({ id: "race-a", active: true, sessionPath: "/sessions/race-a.jsonl" }); |
| 343 | const raceTabB = tabMeta({ id: "race-b", active: false, sessionPath: "/sessions/race-b.jsonl" }); |
| 344 | const raceBlank = tabMeta({ id: "race-blank", active: false, sessionPath: "/sessions/race-blank.jsonl" }); |
| 345 | let raceBackendActiveId = raceTabA.id; |
| 346 | const raceHistoryCalls: string[] = []; |
| 347 | const raceSetActiveCalls: string[] = []; |
| 348 | window.go.main.App = { |
| 349 | ListTabs: async () => [raceTabA, raceTabB, raceBlank].map((tab) => ({ ...tab, active: tab.id === raceBackendActiveId })), |
| 350 | MetaForTab: async (tabID: string) => meta({ sessionPath: `/sessions/${tabID}.jsonl` }), |
| 351 | ContextUsageForTab: async () => context, |
| 352 | EffortForTab: async () => effort, |
| 353 | BalanceForTab: async () => balance, |
| 354 | JobsForTab: async () => jobs, |
| 355 | CheckpointsForTab: async () => checkpoints, |
| 356 | HistoryPageForTab: async (tabID: string) => { |
| 357 | raceHistoryCalls.push(tabID); |
| 358 | return reusedEmptyPage; |
| 359 | }, |
| 360 | HistoryCheckpointTurnsForTab: async () => [], |
| 361 | ReplayPendingPrompts: async () => {}, |
| 362 | EnsureBlankTab: async () => { |
| 363 | const tab = await queuedBlank.promise; |
| 364 | raceBackendActiveId = tab.id; |
| 365 | return tab; |
| 366 | }, |
| 367 | SetActiveTab: async (tabID: string) => { |
| 368 | raceSetActiveCalls.push(tabID); |
| 369 | raceBackendActiveId = tabID; |
| 370 | }, |
| 371 | } as Partial<AppBindings> as AppBindings; |
| 372 | |
| 373 | controller = undefined; |
| 374 | const queuedRaceRoot = createRoot(rootEl); |
| 375 | await act(async () => { |
| 376 | queuedRaceRoot.render(<Probe />); |
| 377 | await flushPromises(); |
| 378 | }); |
| 379 | await waitFor("queued blank race startup", () => controller?.activeTabId === raceTabA.id); |
| 380 | |
| 381 | let pendingBlank: Promise<TabMeta> | undefined; |
| 382 | await act(async () => { |
| 383 | pendingBlank = controller?.ensureBlankTab("project", "/repo"); |
| 384 | await flushPromises(); |
| 385 | }); |
| 386 | const tabClickIntent = controller?.noteNavigationIntent(); |
| 387 | if (tabClickIntent === undefined) throw new Error("missing queued tab intent"); |
| 388 | |
| 389 | let pendingTabSwitch: Promise<TabMeta[] | undefined> | undefined; |
| 390 | await act(async () => { |
| 391 | pendingTabSwitch = controller?.switchTab(raceTabB.id, raceTabB, tabClickIntent); |
| 392 | await pendingTabSwitch; |
| 393 | await flushPromises(); |
| 394 | }); |
| 395 | eq(controller?.activeTabId, raceTabB.id, "queued tab click becomes visible before the older blank completion"); |
| 396 | eq(raceBackendActiveId, raceTabB.id, "queued tab click becomes backend-active before the older blank completion"); |
| 397 | |
| 398 | await act(async () => { |
| 399 | queuedBlank.resolve({ ...raceBlank, active: true }); |
| 400 | await pendingBlank; |
| 401 | await flushPromises(); |
| 402 | }); |
| 403 | eq(controller?.activeTabId, raceTabB.id, "late blank completion cannot replace the newer visible tab"); |
| 404 | eq(raceHistoryCalls.includes(raceBlank.id), false, "stale blank completion does not hydrate the abandoned tab"); |
| 405 | eq(raceBackendActiveId, raceTabB.id, "late blank completion reasserts the newer backend-active tab"); |
| 406 | eq(raceSetActiveCalls.join(","), `${raceTabB.id},${raceTabB.id}`, "stale blank completion repairs backend focus exactly once"); |
| 407 | |
| 408 | await act(async () => { |
| 409 | queuedRaceRoot.unmount(); |
| 410 | }); |
| 411 | |
| 412 | const guardedStartupTabs = deferred<TabMeta[]>(); |
| 413 | const staleProjectA = "/repo/project-a"; |
| 414 | const targetProjectB = "/repo/project-b"; |
| 415 | const ensureBlankSurfaceCalls: Array<{ scope: string; workspaceRoot: string }> = []; |
| 416 | window.go.main.App = { |
| 417 | ListTabs: async () => guardedStartupTabs.promise, |
| 418 | MetaForTab: async (tabID: string) => tabID === "tab-new" |
| 419 | ? meta({ cwd: targetProjectB, workspaceRoot: targetProjectB, workspaceName: "project-b", workspacePath: targetProjectB }) |
| 420 | : meta({ cwd: staleProjectA, workspaceRoot: staleProjectA, workspaceName: "project-a", workspacePath: staleProjectA }), |
| 421 | ContextUsageForTab: async () => context, |
| 422 | EffortForTab: async () => effort, |
| 423 | BalanceForTab: async () => balance, |
| 424 | JobsForTab: async () => jobs, |
| 425 | CheckpointsForTab: async () => checkpoints, |
| 426 | HistoryForTab: async () => [], |
| 427 | HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }), |
| 428 | HistoryCheckpointTurnsForTab: async () => [], |
| 429 | ReplayPendingPrompts: async () => {}, |
| 430 | EnsureBlankSurface: async (scope: string, workspaceRoot: string) => { |
| 431 | ensureBlankSurfaceCalls.push({ scope, workspaceRoot }); |
| 432 | return tabMeta({ |
| 433 | id: "tab-new", |
| 434 | topicId: "topic-new", |
| 435 | topicTitle: "New session", |
| 436 | workspaceRoot: targetProjectB, |
| 437 | workspaceName: "project-b", |
| 438 | workspacePath: targetProjectB, |
| 439 | cwd: targetProjectB, |
| 440 | }); |
| 441 | }, |
| 442 | } as Partial<AppBindings> as AppBindings; |
| 443 | |
| 444 | controller = undefined; |
| 445 | const guardRoot = createRoot(rootEl); |
| 446 | |
| 447 | await act(async () => { |
| 448 | guardRoot.render(<Probe />); |
| 449 | await flushPromises(); |
| 450 | }); |
| 451 | |
| 452 | await act(async () => { |
| 453 | await controller?.ensureBlankSurface("project", targetProjectB); |
| 454 | await flushPromises(); |
| 455 | }); |
| 456 | |
| 457 | eq(ensureBlankSurfaceCalls.length, 1, "EnsureBlankSurface is called once"); |
| 458 | eq(ensureBlankSurfaceCalls[0]?.workspaceRoot, targetProjectB, "EnsureBlankSurface keeps the requested project root"); |
| 459 | eq(controller?.activeTabId, "tab-new", "blank surface becomes active before startup sync resolves"); |
| 460 | eq(controller?.state.meta?.workspaceRoot, targetProjectB, "blank surface exposes the new project root"); |
| 461 | |
| 462 | await act(async () => { |
| 463 | guardedStartupTabs.resolve([tabMeta({ |
| 464 | id: "tab-old", |
| 465 | topicId: "topic-old", |
| 466 | topicTitle: "Old session", |
| 467 | workspaceRoot: staleProjectA, |
| 468 | workspaceName: "project-a", |
| 469 | workspacePath: staleProjectA, |
| 470 | cwd: staleProjectA, |
| 471 | })]); |
| 472 | await guardedStartupTabs.promise; |
| 473 | await flushPromises(); |
| 474 | }); |
| 475 | |
| 476 | eq(controller?.activeTabId, "tab-new", "guarded startup sync cannot restore an older active tab"); |
| 477 | eq(controller?.state.meta?.workspaceRoot, targetProjectB, "guarded startup sync cannot restore the old project root"); |
| 478 | |
| 479 | await act(async () => { |
| 480 | guardRoot.unmount(); |
| 481 | }); |
| 482 | dom.window.close(); |
| 483 | |
| 484 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 485 | if (failed > 0) process.exit(1); |
| 486 |