| 1 | // Run: tsx src/__tests__/hydrate-failure-attribution.test.ts |
| 2 | import assert from "node:assert/strict"; |
| 3 | import { readFileSync } from "node:fs"; |
| 4 | import { dirname, join } from "node:path"; |
| 5 | import test from "node:test"; |
| 6 | import { fileURLToPath } from "node:url"; |
| 7 | import type { FollowRequest, TranscriptFollowResponse } from "../generated/desktopContract.generated"; |
| 8 | import { hydrateFailureDetail } from "../lib/hydrateErrorState"; |
| 9 | import { projectSessionAvailability } from "../lib/sessionAvailability"; |
| 10 | import { TranscriptFollowClient } from "../lib/transcriptFollowClient"; |
| 11 | import { initialState, reducer } from "../lib/useController"; |
| 12 | |
| 13 | const SUMMARY = "Failed to load conversation history."; |
| 14 | |
| 15 | async function localFollowFailure(cause: unknown): Promise<unknown> { |
| 16 | const client = new TranscriptFollowClient((request: FollowRequest) => |
| 17 | request.close ? Promise.resolve({ protocolVersion: 2, subscription: "", changes: [], resetRequired: false } as TranscriptFollowResponse) |
| 18 | : Promise.reject(cause), { transport: "local" }); |
| 19 | try { |
| 20 | await client.start({ install: () => {}, changes: () => {}, connection: () => {} }); |
| 21 | } catch (error) { |
| 22 | return error; |
| 23 | } |
| 24 | assert.fail("follow start resolved although the host rejected the baseline read"); |
| 25 | } |
| 26 | |
| 27 | test("a failed local follow keeps its stage, reason and host cause in the recovery detail", async () => { |
| 28 | const failure = await localFollowFailure(new Error("session store: database is locked")); |
| 29 | const detail = hydrateFailureDetail(SUMMARY, failure); |
| 30 | assert.ok(detail.startsWith(SUMMARY), detail); |
| 31 | assert.match(detail, /baseline_read\.transport_rejected/); |
| 32 | assert.match(detail, /session store: database is locked/); |
| 33 | |
| 34 | const failed = reducer({ ...initialState, hydrating: true }, { type: "hydrate_error", reason: "startup", error: detail }); |
| 35 | const availability = projectSessionAvailability({ local: failed }); |
| 36 | assert.deepEqual({ kind: availability.kind, source: availability.source }, { kind: "error", source: "history" }); |
| 37 | assert.equal(availability.detail, detail, "the banner's details show the cause, not only the summary"); |
| 38 | }); |
| 39 | |
| 40 | test("causes without a follow identity keep their message; an absent cause keeps the summary alone", () => { |
| 41 | assert.equal(hydrateFailureDetail(SUMMARY, new Error("resident snapshot unreadable")), `${SUMMARY}\nresident snapshot unreadable`); |
| 42 | assert.equal(hydrateFailureDetail(SUMMARY, "plain rejection"), `${SUMMARY}\nplain rejection`); |
| 43 | assert.equal(hydrateFailureDetail(SUMMARY, undefined), SUMMARY); |
| 44 | assert.equal(hydrateFailureDetail(SUMMARY, new Error(" ")), SUMMARY); |
| 45 | }); |
| 46 | |
| 47 | test("every local history-read failure hands its cause to the recovery detail", () => { |
| 48 | const root = join(dirname(fileURLToPath(import.meta.url)), ".."); |
| 49 | const controller = readFileSync(join(root, "lib/useController.ts"), "utf8"); |
| 50 | assert.doesNotMatch(controller, /type: "hydrate_error", reason, error: t\("history\.failedLoadHistory"\) \}/, |
| 51 | "a history-read failure must not replace its cause with the generic summary"); |
| 52 | assert.doesNotMatch(controller, /const error = t\("history\.failedLoadHistory"\);/, |
| 53 | "the follow failure must not be replaced by the generic summary"); |
| 54 | assert.equal(controller.match(/hydrateFailureDetail\(t\("history\.failedLoadHistory"\), /g)?.length, 2, |
| 55 | "both the follow path and the readable-baseline path attribute their failure"); |
| 56 | }); |
| 57 |