返回 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, 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
120 lines Plain Text