返回 DeepSeek-Reasonix
recovery-lineage-dialog-heads.test.tsx
根目录 / desktop / frontend / src / __tests__ / recovery-lineage-dialog-heads.test.tsx
1 import { JSDOM } from "jsdom";
2 import type { AppBindings } from "../lib/bridge";
3 import type { RecoveryCleanupRequest, RecoveryLineageView, RecoveryPreferenceRequest } from "../lib/types";
4 import { installDesktopHostStub } from "./desktopHostStub";
5
6 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { pretendToBeVisual: true, url: "http://localhost/" });
7 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8 globalThis.window = dom.window as unknown as Window & typeof globalThis;
9 globalThis.document = dom.window.document;
10 Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: "en-US" });
11 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
12 globalThis.Node = dom.window.Node;
13 globalThis.Element = dom.window.Element;
14 globalThis.HTMLElement = dom.window.HTMLElement;
15 globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
16 globalThis.HTMLInputElement = dom.window.HTMLInputElement;
17 globalThis.Event = dom.window.Event;
18 globalThis.MouseEvent = dom.window.MouseEvent;
19 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
20 globalThis.localStorage = dom.window.localStorage;
21
22 const chosen: RecoveryPreferenceRequest[] = [];
23 const renamed: Array<{ path: string; headId: string; name: string }> = [];
24 const sessionRenames: string[] = [];
25 const cleanups: RecoveryCleanupRequest[] = [];
26
27 // Two heads of one log share the physical path; only the head id tells them apart.
28 const heads: RecoveryLineageView = {
29 groupId: "log",
30 state: "heads",
31 branchCount: 2,
32 unresolved: 0,
33 cleanupEligible: 1,
34 members: [
35 { path: "/private/log.jsonl", headId: "main", headKind: "main", selected: false, role: "normal", versionKind: "head", canonical: false, turns: 2, open: false, running: false, preview: "shared question", versionNote: "log note", lastActivityAt: 100 },
36 { path: "/private/log.jsonl", headId: "01HEADFORK", headKind: "fork", headName: "alt", selected: true, role: "normal", versionKind: "head", canonical: true, turns: 3, open: true, running: false, preview: "alt question", versionNote: "alt", lastActivityAt: 200 },
37 ],
38 };
39
40 installDesktopHostStub(({
41 main: {
42 App: {
43 ChooseRecoveryBranch: async (request: RecoveryPreferenceRequest) => { chosen.push(request); },
44 GetRecoveryLineage: async () => heads,
45 RenameSessionHead: async (path: string, headId: string, name: string) => { renamed.push({ path, headId, name }); },
46 RenameSession: async (path: string) => { sessionRenames.push(path); },
47 CleanRecoveryLineage: async (request: RecoveryCleanupRequest) => {
48 cleanups.push(request);
49 return { eligible: 1, moved: 1, busy: 0, kept: 0, dryRun: false, items: [{ path: "/private/log.jsonl", headId: "main", status: "retired" }] };
50 },
51 } as Partial<AppBindings> as AppBindings,
52 },
53 }).main.App);
54
55 // React must see the jsdom globals when it loads, or its change-event support
56 // probe fails and typing never reaches onChange.
57 const { default: React, act } = await import("react");
58 const { createRoot } = await import("react-dom/client");
59 const { RecoveryLineageDialog } = await import("../components/RecoveryLineageDialog");
60 const { LocaleProvider } = await import("../lib/i18n");
61 const { ToastProvider } = await import("../lib/toast");
62
63 const root = createRoot(document.getElementById("root")!);
64 await act(async () => {
65 root.render(
66 <LocaleProvider>
67 <ToastProvider>
68 <RecoveryLineageDialog topic={{ scope: "global", topicId: "topic" }} initial={heads} onClose={() => {}} onChanged={() => {}} />
69 </ToastProvider>
70 </LocaleProvider>,
71 );
72 });
73
74 const articles = document.querySelectorAll(".recovery-lineage-dialog__member");
75 if (articles.length !== 2) throw new Error(`expected both heads rendered despite the shared path, got ${articles.length}`);
76 const body = document.body.textContent || "";
77 if (!body.includes("alt question") || !body.includes("shared question")) throw new Error("head previews missing");
78 if (body.includes("/private/") || body.includes("01HEADFORK")) throw new Error("head identity leaked into the dialog");
79 if (!body.includes("2 versions of this conversation")) throw new Error(`heads summary missing: ${body}`);
80 if (!body.includes("original line") || !body.includes("forked here")) throw new Error("head kind labels missing");
81 if (!body.includes("Another version") || !body.includes("alt")) throw new Error("unnamed heads fall back to the version title and named heads show their name");
82 if (body.includes("unique content") || body.includes("Default version")) throw new Error("file-lineage wording leaked into the heads dialog");
83
84 const buttons = () => Array.from(document.querySelectorAll<HTMLButtonElement>("button"));
85 const chooseButtons = buttons().filter((button) => button.textContent?.trim() === "Make this the current version");
86 if (chooseButtons.length !== 1) throw new Error(`only the unselected head may offer the choice, got ${chooseButtons.length}`);
87 await act(async () => { chooseButtons[0].click(); });
88 await act(async () => { await Promise.resolve(); });
89 if (chosen.length !== 1 || chosen[0].headId !== "main" || chosen[0].path !== "/private/log.jsonl") {
90 throw new Error(`choose did not carry the head id: ${JSON.stringify(chosen)}`);
91 }
92
93 const noteButtons = buttons().filter((button) => button.classList.contains("recovery-lineage-dialog__version-note"));
94 if (noteButtons.length !== 2) throw new Error(`expected a note editor per head, got ${noteButtons.length}`);
95 const forkNote = noteButtons.find((button) => button.textContent?.trim() === "alt");
96 if (!forkNote) throw new Error("fork head note button not found");
97 await act(async () => { forkNote.click(); });
98 const editor = () => document.querySelector<HTMLInputElement>(".recovery-lineage-dialog__note-editor input");
99 if (!editor() || document.querySelectorAll(".recovery-lineage-dialog__note-editor").length !== 1) throw new Error("editing one head must open exactly one editor");
100 if (editor()!.value !== "alt") throw new Error(`head note editor must start from the head name, got ${editor()!.value}`);
101 await act(async () => {
102 Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(editor()!, "renamed head");
103 editor()!.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
104 });
105 const save = buttons().find((button) => button.textContent?.trim() === "Save");
106 await act(async () => { save!.click(); });
107 await act(async () => { await Promise.resolve(); });
108 if (renamed.length !== 1 || renamed[0].headId !== "01HEADFORK" || renamed[0].path !== "/private/log.jsonl" || renamed[0].name !== "renamed head") {
109 throw new Error(`head note did not rename the head: ${JSON.stringify(renamed)}`);
110 }
111 if (sessionRenames.length !== 0) throw new Error("head note must not rename the whole session");
112
113 const retire = buttons().find((button) => button.textContent?.trim() === "Remove covered versions (1)");
114 if (!retire) throw new Error("covered-head cleanup button missing");
115 await act(async () => { retire.click(); });
116 await act(async () => { await Promise.resolve(); });
117 if (cleanups.length !== 1 || !cleanups[0].apply || cleanups[0].topicId !== "topic" || cleanups[0].scope !== "global") {
118 throw new Error(`cleanup did not apply to the topic: ${JSON.stringify(cleanups)}`);
119 }
120 const toasts = Array.from(document.querySelectorAll(".toast__text")).map((node) => node.textContent);
121 if (!toasts.includes("1 covered version(s) removed · 0 still in use")) throw new Error(`cleanup outcome not toasted: ${JSON.stringify(toasts)}`);
122
123 await act(async () => root.unmount());
124 console.log(" PASS session version dialog lists log heads by head id");
125
125 lines Plain Text