| 1 | import assert from "node:assert/strict"; |
| 2 | import { register } from "node:module"; |
| 3 | import React, { act } from "react"; |
| 4 | import { createRoot } from "react-dom/client"; |
| 5 | import { JSDOM } from "jsdom"; |
| 6 | import { initialState } from "../lib/useController"; |
| 7 | import { applyHydrateErrorState } from "../lib/hydrateErrorState"; |
| 8 | import { projectSessionAvailability, submitBlockReason, type SessionAvailability } from "../lib/sessionAvailability"; |
| 9 | |
| 10 | assert.deepEqual(projectSessionAvailability({empty:true}),{kind:"ready",source:"history"},"an empty welcome has no runtime to wait for"); |
| 11 | assert.equal(projectSessionAvailability({}).kind,"loading","an unresolved selected source still reports loading"); |
| 12 | import { useTranscriptSurfaceProjection, type TranscriptSurfaceProjectionInput } from "../app-runtime/useTranscriptSurfaceProjection"; |
| 13 | import { projectNavigationSurfaceTarget } from "../app-runtime/conversationProjection"; |
| 14 | import { SessionRecoveryBanner } from "../components/SessionRecoveryBanner"; |
| 15 | import { LocaleProvider, type Translator } from "../lib/i18n"; |
| 16 | |
| 17 | register(new URL("../../scripts/svg-loader.mjs", import.meta.url)); |
| 18 | const { ChatPaneRegion } = await import("../app-shell/ChatPaneRegion"); |
| 19 | |
| 20 | const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost" }); |
| 21 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage, |
| 22 | IS_REACT_ACT_ENVIRONMENT: true }); |
| 23 | const root = createRoot(document.getElementById("root")!); |
| 24 | const readyLocal = { ...initialState, meta: { ready: true, eventChannel: "fixture" } }; |
| 25 | const failedLocal = applyHydrateErrorState(readyLocal, "startup", "history read failed"); |
| 26 | assert.deepEqual(projectSessionAvailability({ local: { ...failedLocal, meta: { |
| 27 | ...readyLocal.meta, ready: false, historicalSource: { hostId: "local", path: "/fixture/legacy.jsonl" }, |
| 28 | } } }), { kind: "pending", source: "runtime" }, "unprepared history is a user choice, not a failed recovery"); |
| 29 | const nativeHistory = projectSessionAvailability({ local: { ...readyLocal, meta: { |
| 30 | ...readyLocal.meta, historicalSource: { hostId: "local", path: "/fixture/legacy.jsonl" }, |
| 31 | } } }); |
| 32 | assert.deepEqual(nativeHistory, { kind: "pending", source: "runtime" }, "a readable native history runtime still cannot accept sends"); |
| 33 | const recordKey = ((key: string) => key) as Translator; |
| 34 | assert.equal(submitBlockReason(nativeHistory, recordKey), "sessionRecovery.importBeforeSend", "the disabled composer says why and what to do"); |
| 35 | assert.equal(submitBlockReason(projectSessionAvailability({ local: readyLocal }), recordKey), undefined); |
| 36 | const noop = () => {}; |
| 37 | const common: TranscriptSurfaceProjectionInput = { |
| 38 | hydrating: false, hydrateHistoryLoaded: undefined, hydratePlaceholderItems: undefined, hydratePlaceholderActive: false, |
| 39 | items: [], remote: false, remoteItems: [], activeTabId: "A", geometrySessionKey: "A", transitioning: false, |
| 40 | navigationDataReady: true, preserved: null, controllerReady: true, |
| 41 | availability: projectSessionAvailability({ local: readyLocal }), sessionActivity: false, imDetailActive: false, |
| 42 | sessionHasContent: false, commitRendered: noop, commitPaint: () => null, commitSingleSurface: noop, |
| 43 | ports: { loadOlderHistory: async () => false, commitThenSend: async () => {} }, |
| 44 | }; |
| 45 | let projection!: ReturnType<typeof useTranscriptSurfaceProjection>; |
| 46 | function ProjectionProbe({ input }: { input: TranscriptSurfaceProjectionInput }) { |
| 47 | projection = useTranscriptSurfaceProjection(input); |
| 48 | return null; |
| 49 | } |
| 50 | const paintProjection = (input: TranscriptSurfaceProjectionInput) => act(async () => root.render(<ProjectionProbe input={input} />)); |
| 51 | try { |
| 52 | await paintProjection(common); |
| 53 | assert.equal(projection.emptyHero, true, "successful empty history shows the welcome"); |
| 54 | const guidance = { key: "inbox-second", itemId: "inbox-second", text: "same guidance" }; |
| 55 | await paintProjection({ ...common, items: [{ kind: "notice", id: "he:m:first", level: "info", text: "↪ same guidance" }] }); |
| 56 | assert.equal(projection.latestGuidanceConsumed, null, "history rows never consume a matching pending draft"); |
| 57 | await paintProjection({ ...common, guidanceConsumed: guidance }); |
| 58 | assert.equal(projection.latestGuidanceConsumed, guidance, "receipt survives without a newly appended display row"); |
| 59 | await paintProjection({ ...common, availability: projectSessionAvailability({ local: failedLocal }) }); |
| 60 | assert.equal(projection.emptyHero, false, "failed history is not a successful empty session"); |
| 61 | for (const state of ["connecting", "reconnecting", "serve_down", "error", "disconnected", "ready"] as const) { |
| 62 | for (const hydrated of [false, true]) { |
| 63 | const remote = { state, hydrated, error: "", surfaceGeneration: 1 }; |
| 64 | await paintProjection({ ...common, remote: true, availability: projectSessionAvailability({ local: readyLocal, remote }) }); |
| 65 | assert.equal(projection.emptyHero, state === "ready" && hydrated, `${state}/${hydrated} uses remote readiness`); |
| 66 | } |
| 67 | } |
| 68 | const remoteHistoryFailure = { state: "ready" as const, hydrated: false, error: "snapshot failed", surfaceGeneration: 1 }; |
| 69 | const failedTarget = projectNavigationSurfaceTarget({ activeTabId: "remote", sessionKey: "remote", local: readyLocal, remote: remoteHistoryFailure }); |
| 70 | assert.equal(failedTarget.hydrating, false, "exhausted remote hydration releases the navigation overlay"); |
| 71 | assert.equal(failedTarget.hydrateError, "snapshot failed"); |
| 72 | await paintProjection({ ...common, remote: true, availability: projectSessionAvailability({ remote: remoteHistoryFailure }) }); |
| 73 | assert.equal(projection.emptyHero, false); |
| 74 | for (const partial of [{ transitioning: true }, { sessionActivity: true }, { hydratePlaceholderActive: true, hydratePlaceholderItems: [] }, { sessionHasContent: true }]) { |
| 75 | await paintProjection({ ...common, ...partial }); |
| 76 | assert.equal(projection.emptyHero, false, "navigation, decisions, running turns and content keep the main surface visible"); |
| 77 | } |
| 78 | |
| 79 | let retries = 0; |
| 80 | let rejectRetry!: (error: Error) => void; |
| 81 | const onRetry = () => { retries++; return new Promise<void>((_resolve, reject) => { rejectRetry = reject; }); }; |
| 82 | const availability: SessionAvailability = { kind: "error", source: "connection", detail: "tunnel closed" }; |
| 83 | const paintBanner = (identity: string, value = availability) => act(async () => root.render( |
| 84 | <LocaleProvider><SessionRecoveryBanner key={identity} availability={value} onRetry={onRetry} /></LocaleProvider>, |
| 85 | )); |
| 86 | await paintBanner("A"); |
| 87 | const button = document.querySelector<HTMLButtonElement>(".session-recovery .btn--primary")!; |
| 88 | await act(async () => { button.click(); button.click(); }); |
| 89 | assert.equal(retries, 1, "rapid retries share one in-flight action"); |
| 90 | assert.equal(button.disabled, true); |
| 91 | assert.equal(document.querySelector(".session-recovery")?.getAttribute("role"), "status"); |
| 92 | await act(async () => { rejectRetry(new Error("retry failed")); }); |
| 93 | assert.equal(button.disabled, false, "failed recovery restores the action"); |
| 94 | await act(async () => document.querySelector<HTMLButtonElement>("button[aria-controls]")!.click()); |
| 95 | assert.equal(document.querySelector(".session-recovery__detail")?.textContent, "retry failed"); |
| 96 | await act(async () => button.click()); |
| 97 | await paintBanner("B"); |
| 98 | await act(async () => { rejectRetry(new Error("old session failure")); }); |
| 99 | assert.equal(document.body.textContent?.includes("old session failure"), false, "late recovery cannot overwrite another session's error"); |
| 100 | await paintBanner("B", { kind: "ready", source: "history" }); |
| 101 | assert.equal(document.querySelector(".session-recovery"), null); |
| 102 | |
| 103 | await act(async () => root.render(<LocaleProvider><ChatPaneRegion transitioning={false} t={((key: string) => key) as Translator} |
| 104 | imDetail={null} transcript={{ state: failedLocal, items: [], tabId: "local", geometrySessionKey: "local", footerHeight: 140, |
| 105 | transcriptHydrating: false, navigationDataReady: true, readOnly: false, controllerReady: true, hydratePlaceholderActive: false, |
| 106 | clearContextPending: false, availability: projectSessionAvailability({ local: failedLocal }), |
| 107 | rewind: { stateActive: false, committing: false, signal: undefined }, revealSignal: 0, invocationMetadata: undefined, |
| 108 | surfaceCommitToken: undefined, liveStore: undefined }} onRetryHistory={async () => { retries++; }} |
| 109 | commands={{ onPrompt: noop, onFork: noop, onLoadOlderHistory: async () => false, onSurfacePaintReady: noop }} /></LocaleProvider>)); |
| 110 | const recovery = document.querySelector(".session-recovery")!; |
| 111 | assert.ok(recovery, "actual local chat region renders history recovery"); |
| 112 | assert.equal(recovery.closest("main"), null, "history retry is outside the main transcript collapse"); |
| 113 | assert.ok(document.querySelector("main .session-recovery-placeholder")); |
| 114 | const beforeRetry = retries; |
| 115 | await act(async () => recovery.querySelector<HTMLButtonElement>(".btn--primary")!.click()); |
| 116 | assert.equal(retries, beforeRetry + 1, "local history recovery calls the owning retry command"); |
| 117 | await act(async () => root.unmount()); |
| 118 | console.log("PASS session recovery: source readiness, navigation errors, visible controls, retry coalescing and stale completion isolation"); |
| 119 | } finally { dom.window.close(); } |
| 120 |