返回 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 source = { hostId: "local", sourceKey: "legacy", path: "/fixture/legacy.jsonl" };
22 const host = installDesktopHostStub({
23 CheckHistoricalSourceUpdate: async () => ({ sourceKey: "legacy", status: "available", version: "v2", source, retryable: false }),
24 PrepareHistoricalSourceVersion: async () => {
25 if (branchError) throw new Error("import request failed");
26 return branch ? branch.promise : ({ operationId: "version-v2", sourceKey: "legacy", status: "ready", revision: 2,
27 target: { hostId: "local", sessionId: "branch-v2" }, retryable: false });
28 },
29 GetSessionPreparation: async () => {
30 if (polledPreparation) return polledPreparation;
31 throw new Error("terminal preparation must not poll");
32 },
33 CancelSessionPreparation: async (operationId: string) => { cancelled++; return cancellation ? cancellation.promise : { operationId, sourceKey: "legacy", status: "cancelled", revision: 3, retryable: true }; },
34 });
35 const root = createRoot(document.getElementById("root")!);
36 const baseProps = {
37 tab: { id: "base", scope: "global", workspaceRoot: "", workspaceName: "Global", topicId: "base", topicTitle: "Base",
38 label: "Base", ready: true, running: false, sessionId: "base" },
39 navigate: async (intent: { kind: string; ref?: { sessionId: string } }) => { if (intent.ref) opened.push(intent.ref.sessionId); },
40 captureNavigation: () => { const epoch = navigationEpoch; return () => epoch === navigationEpoch; },
41 };
42 setHistoricalPreparation({
43 operationId: "prepare-legacy", status: "queued", retryable: false,
44 session: { scope: "global", title: "Legacy title", topicId: "legacy", source },
45 });
46 await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} /></LocaleProvider>));
47 assert.ok(document.body.textContent?.includes("Importing: Legacy title"));
48 await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Cancel")!.click());
49 assert.equal(cancelled, 1);
50
51 await act(async () => setHistoricalPreparation(null));
52 await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} /></LocaleProvider>));
53 await act(async () => {});
54 assert.ok(document.body.textContent?.includes("Historical source changed. Import as a separate branch."));
55 assert.ok(!document.body.textContent?.includes("Not imported"), "an imported conversation must not be labelled unimported");
56 await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Import and open · Branch")!.click());
57 assert.deepEqual(opened, ["branch-v2"]);
58
59 const importBranchButton = () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Import and open · Branch")!;
60 branch = deferred<SessionPreparationView>();
61 await act(async () => importBranchButton().click());
62 assert.equal(importBranchButton().disabled, true);
63 assert.ok(document.body.textContent?.includes("Importing"));
64 polledPreparation = { operationId: "version-v2", sourceKey: "legacy", status: "failed", revision: 4, errorCode: "import_failed", retryable: true };
65 await act(async () => branch!.resolve({ ...polledPreparation!, status: "preparing", revision: 3 }));
66 await act(async () => { await new Promise(resolve => setTimeout(resolve, 350)); });
67 assert.ok(document.querySelector('[role="alert"]')?.textContent?.includes("Import failed"), "polled failures must be visible");
68 assert.equal(importBranchButton().disabled, false, "failed imports remain retryable");
69 assert.deepEqual(opened, ["branch-v2"], "failed preparation must not navigate");
70
71 branch = deferred<SessionPreparationView>();
72 await act(async () => importBranchButton().click());
73 assert.equal(document.querySelector('[role="alert"]'), null, "retry clears the previous error");
74 await act(async () => branch!.resolve({ ...polledPreparation!, status: "blocked", errorCode: "source_busy" }));
75 assert.ok(document.querySelector('[role="alert"]')?.textContent?.includes("In use by another instance"));
76
77 branchError = true;
78 await act(async () => importBranchButton().click());
79 assert.ok(document.querySelector('[role="alert"]')?.textContent?.includes("Import failed"), "RPC errors must not disappear");
80 branchError = false;
81 branch = undefined;
82 await act(async () => importBranchButton().click());
83 assert.equal(document.querySelector('[role="alert"]'), null);
84 assert.deepEqual(opened, ["branch-v2", "branch-v2"], "retry can open the imported branch");
85
86 const pendingA = { operationId: "prepare-a", status: "preparing", retryable: false, revision: 4,
87 session: { scope: "global", title: "A", source } };
88 await act(async () => setHistoricalPreparation(pendingA));
89 cancellation = deferred<SessionPreparationView>();
90 await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Cancel")!.click());
91 const pendingCancel = [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Cancel")!;
92 assert.equal(pendingCancel.disabled, true, "one operation accepts only one in-flight cancellation");
93 await act(async () => pendingCancel.click());
94 assert.equal(cancelled, 2, "a disabled cancellation control does not submit twice");
95 await act(async () => setHistoricalPreparation({ ...pendingA, operationId: "prepare-b" }));
96 await act(async () => cancellation!.resolve({ operationId: "prepare-a", sourceKey: "legacy", status: "cancelled", revision: 5, retryable: true }));
97 assert.equal(historicalPreparationSnapshot()?.operationId, "prepare-b", "late cancellation cannot replace a newer selection");
98 await act(async () => setHistoricalPreparation(pendingA));
99 await act(async () => reconcileHistoricalPreparation(pendingA, { operationId: "prepare-a", sourceKey: "legacy", status: "cancelled", revision: 3, retryable: true }));
100 assert.equal(historicalPreparationSnapshot()?.status, "preparing", "older revisions cannot regress the task");
101 await act(async () => reconcileHistoricalPreparation(pendingA, { operationId: "prepare-a", sourceKey: "legacy", status: "ready", revision: 6, retryable: false }));
102 assert.equal(historicalPreparationSnapshot()?.status, "preparing", "ready cancellation leaves activation to the navigation owner");
103 await act(async () => setHistoricalPreparation(null));
104 branch = deferred<SessionPreparationView>();
105 await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Import and open · Branch")!.click());
106 navigationEpoch++;
107 await act(async () => branch!.resolve({ operationId: "version-v2", sourceKey: "legacy", status: "ready", revision: 7,
108 target: { hostId: "local", sessionId: "stale-branch" }, retryable: false }));
109 assert.deepEqual(opened, ["branch-v2", "branch-v2"], "pending navigation invalidates a branch open even while the old tab is retained");
110 branch = deferred<SessionPreparationView>();
111 await act(async () => importBranchButton().click());
112 navigationEpoch++;
113 await act(async () => branch!.resolve({ ...polledPreparation!, status: "failed" }));
114 assert.equal(document.querySelector('[role="alert"]'), null, "stale failures cannot replace the newer navigation state");
115 const pendingIntents: unknown[] = [];
116 await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners
117 tab={{ ...baseProps.tab, sessionId: undefined, ready: false, historicalSource: source }}
118 navigate={async intent => { pendingIntents.push(intent); }}
119 /></LocaleProvider>));
120 assert.deepEqual(pendingIntents, [], "restoring a legacy tab never starts preparation automatically");
121 await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click());
122 assert.equal(pendingIntents.length, 1);
123 assert.equal((pendingIntents[0] as { kind: string }).kind, "resume-session", "explicit preparation uses the shared navigation owner");
124 const [canonicalTab] = seedActiveTabMetaList([{ ...baseProps.tab, historicalSource: source }], baseProps.tab);
125 assert.equal(canonicalTab.historicalSource, undefined, "canonical metadata omission clears the old preparation state");
126 await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} tab={canonicalTab} /></LocaleProvider>));
127 assert.equal(document.getElementById("reasonix-prepare-restored-session"), null, "successful activation removes the preparation action");
128 await act(async () => root.unmount());
129 host.uninstall();
130 dom.window.close();
131 console.log("PASS preparation banner, cancellation and source update branch import");
132
132 lines Plain Text