返回 DeepSeek-Reasonix
historical-session-banners.test.tsx
根目录 / desktop / frontend / src / __tests__ / historical-session-banners.test.tsx
1 import assert from "node:assert/strict";
2 import { managementDom } from "../test-support/managementDom";
3 import { installDesktopHostStub } from "./desktopHostStub";
4 import type { SessionPreparationView } from "../generated/desktopContract.generated";
5
6 const dom = managementDom();
7 const { default: React, act } = await import("react");
8 const { createRoot } = await import("react-dom/client");
9 const { LocaleProvider } = await import("../lib/i18n");
10 const { HistoricalSessionBanners } = await import("../components/SessionTakeoverDialog");
11 const { seedActiveTabMetaList } = await import("../lib/tabMetaRefresh");
12 const { setHistoricalPreparation, historicalPreparationSnapshot, reconcileHistoricalPreparation } = await import("../app-runtime/desktopNavigationOwner");
13 function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done; }); return { promise, resolve }; }
14 let cancellation: ReturnType<typeof deferred<SessionPreparationView>> | undefined;
15 let branch: ReturnType<typeof deferred<SessionPreparationView>> | undefined;
16 let branchError = false;
17 let polledPreparation: SessionPreparationView | undefined;
18 let navigationEpoch = 0;
19 let cancelled = 0;
20 const opened: string[] = [];
21 const preparedSelectors: unknown[] = [];
22 let preparedSource: SessionPreparationView | undefined;
23 const source = { hostId: "local", sourceKey: "legacy", path: "/fixture/legacy.jsonl" };
24 const host = installDesktopHostStub({
25 CheckHistoricalSourceUpdate: async () => ({ sourceKey: "legacy", status: "available", version: "v2", source, retryable: false }),
26 PrepareHistoricalSourceVersion: async () => {
27 if (branchError) throw new Error("import request failed");
28 return branch ? branch.promise : ({ operationId: "version-v2", sourceKey: "legacy", status: "ready", revision: 2,
29 target: { hostId: "local", sessionId: "branch-v2" }, retryable: false });
30 },
31 GetSessionPreparation: async () => {
32 if (polledPreparation) return polledPreparation;
33 throw new Error("terminal preparation must not poll");
34 },
35 PrepareSession: async (selector: unknown) => {
36 preparedSelectors.push(selector);
37 if (preparedSource) return preparedSource;
38 return { operationId: "prepare-source", sourceKey: "legacy", status: "ready", revision: 8,
39 target: { hostId: "local", sessionId: "imported-source" }, retryable: false };
40 },
41 CancelSessionPreparation: async (operationId: string) => { cancelled++; return cancellation ? cancellation.promise : { operationId, sourceKey: "legacy", status: "cancelled", revision: 3, retryable: true }; },
42 });
43 const root = createRoot(document.getElementById("root")!);
44 const baseProps = {
45 tab: { id: "base", scope: "global", workspaceRoot: "", workspaceName: "Global", topicId: "base", topicTitle: "Base",
46 label: "Base", ready: true, running: false, sessionId: "base" },
47 navigate: async (intent: { kind: string; ref?: { sessionId: string } }) => { if (intent.ref) opened.push(intent.ref.sessionId); },
48 captureNavigation: () => { const epoch = navigationEpoch; return () => epoch === navigationEpoch; },
49 };
50 setHistoricalPreparation({
51 operationId: "prepare-legacy", status: "queued", retryable: false,
52 session: { scope: "global", title: "Legacy title", topicId: "legacy", source },
53 });
54 await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} /></LocaleProvider>));
55 assert.ok(document.body.textContent?.includes("Importing: Legacy title"));
56 await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Cancel")!.click());
57 assert.equal(cancelled, 1);
58
59 await act(async () => setHistoricalPreparation(null));
60 await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} /></LocaleProvider>));
61 await act(async () => {});
62 assert.ok(document.body.textContent?.includes("Historical source changed. Import as a separate branch."));
63 assert.ok(!document.body.textContent?.includes("Not imported"), "an imported conversation must not be labelled unimported");
64 await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Import and open · Branch")!.click());
65 assert.deepEqual(opened, ["branch-v2"]);
66
67 const importBranchButton = () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Import and open · Branch")!;
68 branch = deferred<SessionPreparationView>();
69 await act(async () => importBranchButton().click());
70 assert.equal(importBranchButton().disabled, true);
71 assert.ok(document.body.textContent?.includes("Importing"));
72 polledPreparation = { operationId: "version-v2", sourceKey: "legacy", status: "failed", revision: 4, errorCode: "import_failed", retryable: true };
73 await act(async () => branch!.resolve({ ...polledPreparation!, status: "preparing", revision: 3 }));
74 await act(async () => { await new Promise(resolve => setTimeout(resolve, 350)); });
75 assert.ok(document.querySelector('[role="alert"]')?.textContent?.includes("Import failed"), "polled failures must be visible");
76 assert.equal(importBranchButton().disabled, false, "failed imports remain retryable");
77 assert.deepEqual(opened, ["branch-v2"], "failed preparation must not navigate");
78
79 branch = deferred<SessionPreparationView>();
80 await act(async () => importBranchButton().click());
81 assert.equal(document.querySelector('[role="alert"]'), null, "retry clears the previous error");
82 await act(async () => branch!.resolve({ ...polledPreparation!, status: "blocked", errorCode: "source_busy" }));
83 assert.ok(document.querySelector('[role="alert"]')?.textContent?.includes("In use by another instance"));
84
85 branchError = true;
86 await act(async () => importBranchButton().click());
87 assert.ok(document.querySelector('[role="alert"]')?.textContent?.includes("Import failed"), "RPC errors must not disappear");
88 branchError = false;
89 branch = undefined;
90 await act(async () => importBranchButton().click());
91 assert.equal(document.querySelector('[role="alert"]'), null);
92 assert.deepEqual(opened, ["branch-v2", "branch-v2"], "retry can open the imported branch");
93
94 const pendingA = { operationId: "prepare-a", status: "preparing", retryable: false, revision: 4,
95 session: { scope: "global", title: "A", source } };
96 await act(async () => setHistoricalPreparation(pendingA));
97 cancellation = deferred<SessionPreparationView>();
98 await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Cancel")!.click());
99 const pendingCancel = [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Cancel")!;
100 assert.equal(pendingCancel.disabled, true, "one operation accepts only one in-flight cancellation");
101 await act(async () => pendingCancel.click());
102 assert.equal(cancelled, 2, "a disabled cancellation control does not submit twice");
103 await act(async () => setHistoricalPreparation({ ...pendingA, operationId: "prepare-b" }));
104 await act(async () => cancellation!.resolve({ operationId: "prepare-a", sourceKey: "legacy", status: "cancelled", revision: 5, retryable: true }));
105 assert.equal(historicalPreparationSnapshot()?.operationId, "prepare-b", "late cancellation cannot replace a newer selection");
106 await act(async () => setHistoricalPreparation(pendingA));
107 await act(async () => reconcileHistoricalPreparation(pendingA, { operationId: "prepare-a", sourceKey: "legacy", status: "cancelled", revision: 3, retryable: true }));
108 assert.equal(historicalPreparationSnapshot()?.status, "preparing", "older revisions cannot regress the task");
109 await act(async () => reconcileHistoricalPreparation(pendingA, { operationId: "prepare-a", sourceKey: "legacy", status: "ready", revision: 6, retryable: false }));
110 assert.equal(historicalPreparationSnapshot()?.status, "preparing", "ready cancellation leaves activation to the navigation owner");
111 await act(async () => setHistoricalPreparation(null));
112 branch = deferred<SessionPreparationView>();
113 await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Import and open · Branch")!.click());
114 navigationEpoch++;
115 await act(async () => branch!.resolve({ operationId: "version-v2", sourceKey: "legacy", status: "ready", revision: 7,
116 target: { hostId: "local", sessionId: "stale-branch" }, retryable: false }));
117 assert.deepEqual(opened, ["branch-v2", "branch-v2"], "pending navigation invalidates a branch open even while the old tab is retained");
118 branch = deferred<SessionPreparationView>();
119 await act(async () => importBranchButton().click());
120 navigationEpoch++;
121 await act(async () => branch!.resolve({ ...polledPreparation!, status: "failed" }));
122 assert.equal(document.querySelector('[role="alert"]'), null, "stale failures cannot replace the newer navigation state");
123 const pendingIntents: unknown[] = [];
124 await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners
125 tab={{ ...baseProps.tab, sessionId: undefined, ready: false, historicalSource: source }}
126 navigate={async intent => { pendingIntents.push(intent); }}
127 /></LocaleProvider>));
128 assert.deepEqual(preparedSelectors, [], "restoring a legacy tab never starts preparation automatically");
129 assert.ok(document.body.textContent?.includes("Import this conversation to keep sending messages"), "the banner states why sending waits");
130 await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click());
131 assert.deepEqual(preparedSelectors, [{ source }], "the action prepares the source instead of reopening it in place");
132 assert.deepEqual(pendingIntents, [{ kind: "canonical-session", ref: { hostId: "local", sessionId: "imported-source" } }],
133 "a prepared source opens as its canonical session");
134 const [canonicalTab] = seedActiveTabMetaList([{ ...baseProps.tab, historicalSource: source }], baseProps.tab);
135 assert.equal(canonicalTab.historicalSource, undefined, "canonical metadata omission clears the old preparation state");
136 await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} tab={canonicalTab} /></LocaleProvider>));
137 assert.equal(document.getElementById("reasonix-prepare-restored-session"), null, "successful activation removes the preparation action");
138 const failingSourceTab = { ...baseProps.tab, sessionId: undefined, ready: false, historicalSource: source };
139 let recoveryOpened = 0;
140 const openFailures: unknown[] = [];
141 preparedSource = { operationId: "prepare-conflict", sourceKey: "legacy", status: "failed", revision: 9, errorCode: "workspace_conflict",
142 errorDetail: "session workspace identity is inconsistent; the session files were left unchanged", retryable: true };
143 await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners tab={failingSourceTab}
144 navigate={async intent => { openFailures.push(intent); throw new Error("open refused: workspace registry busy"); }}
145 openRecoveryDetails={() => { recoveryOpened++; }} /></LocaleProvider>));
146 assert.equal(document.body.textContent?.includes("Review recovery details"), false, "recovery details appear only with a failure");
147 await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click());
148 const failureAlert = () => document.querySelector('[role="alert"]');
149 assert.ok(failureAlert()?.textContent?.includes("records a different project folder"), "the banner names the failure class the host reported");
150 await act(async () => failureAlert()!.querySelector<HTMLButtonElement>(".user-error__toggle")!.click());
151 assert.ok(failureAlert()?.textContent?.includes("session workspace identity is inconsistent"), "the host's failure detail is reachable from the banner");
152 await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Review recovery details")!.click());
153 assert.equal(recoveryOpened, 1, "the promised recovery details open");
154 assert.deepEqual(openFailures, [], "a failed preparation never navigates");
155 preparedSource = { operationId: "prepare-ready", sourceKey: "legacy", status: "ready", revision: 10,
156 target: { hostId: "local", sessionId: "imported-source" }, retryable: false };
157 await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click());
158 assert.ok(failureAlert()?.textContent?.includes("Imported, but the conversation could not be opened"), "an open failure is not reported as an import failure");
159 await act(async () => failureAlert()!.querySelector<HTMLButtonElement>(".user-error__toggle")!.click());
160 assert.ok(failureAlert()?.textContent?.includes("open refused: workspace registry busy"), "the open failure keeps its cause");
161 await act(async () => root.unmount());
162 host.uninstall();
163 dom.window.close();
164 console.log("PASS preparation banner, cancellation and source update branch import");
165
165 lines Plain Text