返回 DeepSeek-Reasonix
session-recovery.test.tsx
根目录 / desktop / frontend / src / __tests__ / session-recovery.test.tsx
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, type SessionAvailability } from "../lib/sessionAvailability";
9 import { useTranscriptSurfaceProjection, type TranscriptSurfaceProjectionInput } from "../app-runtime/useTranscriptSurfaceProjection";
10 import { projectNavigationSurfaceTarget } from "../app-runtime/conversationProjection";
11 import { SessionRecoveryBanner } from "../components/SessionRecoveryBanner";
12 import { LocaleProvider, type Translator } from "../lib/i18n";
13
14 register(new URL("../../scripts/svg-loader.mjs", import.meta.url));
15 const { ChatPaneRegion } = await import("../app-shell/ChatPaneRegion");
16
17 const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost" });
18 Object.assign(globalThis, { window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage,
19 IS_REACT_ACT_ENVIRONMENT: true });
20 const root = createRoot(document.getElementById("root")!);
21 const readyLocal = { ...initialState, meta: { ready: true, eventChannel: "fixture" } };
22 const failedLocal = applyHydrateErrorState(readyLocal, "startup", "history read failed");
23 assert.deepEqual(projectSessionAvailability({ local: { ...failedLocal, meta: {
24 ...readyLocal.meta, ready: false, historicalSource: { hostId: "local", path: "/fixture/legacy.jsonl" },
25 } } }), { kind: "pending", source: "runtime" }, "unprepared history is a user choice, not a failed recovery");
26 const noop = () => {};
27 const common: TranscriptSurfaceProjectionInput = {
28 hydrating: false, hydrateHistoryLoaded: undefined, hydratePlaceholderItems: undefined, hydratePlaceholderActive: false,
29 items: [], remote: false, remoteItems: [], activeTabId: "A", geometrySessionKey: "A", transitioning: false,
30 navigationDataReady: true, preserved: null, controllerReady: true,
31 availability: projectSessionAvailability({ local: readyLocal }), sessionActivity: false, imDetailActive: false,
32 sessionHasContent: false, commitRendered: noop, commitPaint: () => null, commitSingleSurface: noop,
33 ports: { loadOlderHistory: async () => false, commitThenSend: async () => {} },
34 };
35 let projection!: ReturnType<typeof useTranscriptSurfaceProjection>;
36 function ProjectionProbe({ input }: { input: TranscriptSurfaceProjectionInput }) {
37 projection = useTranscriptSurfaceProjection(input);
38 return null;
39 }
40 const paintProjection = (input: TranscriptSurfaceProjectionInput) => act(async () => root.render(<ProjectionProbe input={input} />));
41 try {
42 await paintProjection(common);
43 assert.equal(projection.emptyHero, true, "successful empty history shows the welcome");
44 await paintProjection({ ...common, availability: projectSessionAvailability({ local: failedLocal }) });
45 assert.equal(projection.emptyHero, false, "failed history is not a successful empty session");
46 for (const state of ["connecting", "reconnecting", "serve_down", "error", "disconnected", "ready"] as const) {
47 for (const hydrated of [false, true]) {
48 const remote = { state, hydrated, error: "", surfaceGeneration: 1 };
49 await paintProjection({ ...common, remote: true, availability: projectSessionAvailability({ local: readyLocal, remote }) });
50 assert.equal(projection.emptyHero, state === "ready" && hydrated, `${state}/${hydrated} uses remote readiness`);
51 }
52 }
53 const remoteHistoryFailure = { state: "ready" as const, hydrated: false, error: "snapshot failed", surfaceGeneration: 1 };
54 const failedTarget = projectNavigationSurfaceTarget({ activeTabId: "remote", sessionKey: "remote", local: readyLocal, remote: remoteHistoryFailure });
55 assert.equal(failedTarget.hydrating, false, "exhausted remote hydration releases the navigation overlay");
56 assert.equal(failedTarget.hydrateError, "snapshot failed");
57 await paintProjection({ ...common, remote: true, availability: projectSessionAvailability({ remote: remoteHistoryFailure }) });
58 assert.equal(projection.emptyHero, false);
59 for (const partial of [{ transitioning: true }, { sessionActivity: true }, { hydratePlaceholderActive: true, hydratePlaceholderItems: [] }, { sessionHasContent: true }]) {
60 await paintProjection({ ...common, ...partial });
61 assert.equal(projection.emptyHero, false, "navigation, decisions, running turns and content keep the main surface visible");
62 }
63
64 let retries = 0;
65 let rejectRetry!: (error: Error) => void;
66 const onRetry = () => { retries++; return new Promise<void>((_resolve, reject) => { rejectRetry = reject; }); };
67 const availability: SessionAvailability = { kind: "error", source: "connection", detail: "tunnel closed" };
68 const paintBanner = (identity: string, value = availability) => act(async () => root.render(
69 <LocaleProvider><SessionRecoveryBanner key={identity} availability={value} onRetry={onRetry} /></LocaleProvider>,
70 ));
71 await paintBanner("A");
72 const button = document.querySelector<HTMLButtonElement>(".session-recovery .btn--primary")!;
73 await act(async () => { button.click(); button.click(); });
74 assert.equal(retries, 1, "rapid retries share one in-flight action");
75 assert.equal(button.disabled, true);
76 assert.equal(document.querySelector(".session-recovery")?.getAttribute("role"), "status");
77 await act(async () => { rejectRetry(new Error("retry failed")); });
78 assert.equal(button.disabled, false, "failed recovery restores the action");
79 await act(async () => document.querySelector<HTMLButtonElement>("button[aria-controls]")!.click());
80 assert.equal(document.querySelector(".session-recovery__detail")?.textContent, "retry failed");
81 await act(async () => button.click());
82 await paintBanner("B");
83 await act(async () => { rejectRetry(new Error("old session failure")); });
84 assert.equal(document.body.textContent?.includes("old session failure"), false, "late recovery cannot overwrite another session's error");
85 await paintBanner("B", { kind: "ready", source: "history" });
86 assert.equal(document.querySelector(".session-recovery"), null);
87
88 await act(async () => root.render(<LocaleProvider><ChatPaneRegion transitioning={false} t={((key: string) => key) as Translator}
89 imDetail={null} transcript={{ state: failedLocal, items: [], tabId: "local", geometrySessionKey: "local", footerHeight: 140,
90 transcriptHydrating: false, navigationDataReady: true, readOnly: false, controllerReady: true, hydratePlaceholderActive: false,
91 clearContextPending: false, availability: projectSessionAvailability({ local: failedLocal }),
92 rewind: { stateActive: false, committing: false, signal: undefined }, revealSignal: 0, invocationMetadata: undefined,
93 surfaceCommitToken: undefined, liveStore: undefined }} onRetryHistory={async () => { retries++; }}
94 commands={{ onPrompt: noop, onFork: noop, onLoadOlderHistory: async () => false, onSurfacePaintReady: noop }} /></LocaleProvider>));
95 const recovery = document.querySelector(".session-recovery")!;
96 assert.ok(recovery, "actual local chat region renders history recovery");
97 assert.equal(recovery.closest("main"), null, "history retry is outside the main transcript collapse");
98 assert.ok(document.querySelector("main .session-recovery-placeholder"));
99 const beforeRetry = retries;
100 await act(async () => recovery.querySelector<HTMLButtonElement>(".btn--primary")!.click());
101 assert.equal(retries, beforeRetry + 1, "local history recovery calls the owning retry command");
102 await act(async () => root.unmount());
103 console.log("PASS session recovery: source readiness, navigation errors, visible controls, retry coalescing and stale completion isolation");
104 } finally { dom.window.close(); }
105
105 lines Plain Text