返回 DeepSeek-Reasonix
history-recovery-copies.test.tsx
根目录 / desktop / frontend / src / __tests__ / history-recovery-copies.test.tsx
1 // Run: tsx src/__tests__/history-recovery-copies.test.tsx
2 //
3 // Recovery-copy bulk actions in HistoryPanel: the history view sweeps idle
4 // copies into the trash (skipping current/open ones), the trash view purges
5 // them, and both flows keep normal sessions untouched.
6
7 import { JSDOM } from "jsdom";
8 import { registerHooks } from "node:module";
9 import React from "react";
10 import { act } from "react";
11 import { createRoot } from "react-dom/client";
12 import type { SessionMeta } from "../lib/types";
13
14 // HistoryPanel transitively imports Welcome's SVG wordmark; tsx has no asset
15 // loader, so redirect .svg specifiers to an empty-string module stub, the way
16 // Vite would default-export a URL.
17 registerHooks({
18 resolve(specifier, context, nextResolve) {
19 if (specifier.endsWith(".svg")) {
20 return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
21 }
22 return nextResolve(specifier, context);
23 },
24 });
25
26 let passed = 0;
27 let failed = 0;
28
29 function ok(value: boolean, label: string) {
30 if (value) {
31 process.stdout.write(` PASS ${label}\n`);
32 passed += 1;
33 } else {
34 process.stdout.write(` FAIL ${label}\n`);
35 failed += 1;
36 }
37 }
38
39 function eq(actual: unknown, expected: unknown, label: string) {
40 if (JSON.stringify(actual) === JSON.stringify(expected)) ok(true, label);
41 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
42 }
43
44 function flushTimers(ms = 0): Promise<void> {
45 return new Promise((resolve) => setTimeout(resolve, ms));
46 }
47
48 function installDom() {
49 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
50 pretendToBeVisual: true,
51 url: "http://localhost/",
52 });
53 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
54 globalThis.window = dom.window as unknown as Window & typeof globalThis;
55 globalThis.document = dom.window.document;
56 Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: "en-US" });
57 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
58 globalThis.Node = dom.window.Node;
59 globalThis.Element = dom.window.Element;
60 globalThis.HTMLElement = dom.window.HTMLElement;
61 globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
62 globalThis.Event = dom.window.Event;
63 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
64 globalThis.MouseEvent = dom.window.MouseEvent;
65 globalThis.localStorage = dom.window.localStorage;
66 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
67 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
68 globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
69 return dom;
70 }
71
72 const now = 1_750_000_000_000;
73
74 function session(overrides: Partial<SessionMeta> & { path: string }): SessionMeta {
75 return {
76 preview: "session preview",
77 turns: 3,
78 createdAt: now - 3_600_000,
79 lastActivityAt: now,
80 modTime: now,
81 current: false,
82 open: false,
83 ...overrides,
84 };
85 }
86
87 async function renderPanel(props: Record<string, unknown>) {
88 const { HistoryPanel } = await import("../components/HistoryPanel");
89 const { LocaleProvider } = await import("../lib/i18n");
90 const rootEl = document.getElementById("root");
91 if (!rootEl) throw new Error("missing root");
92 const root = createRoot(rootEl);
93 await act(async () => {
94 root.render(
95 <LocaleProvider>
96 <HistoryPanel
97 running={false}
98 onResume={() => {}}
99 onPreview={async () => []}
100 onDelete={() => {}}
101 onRename={() => {}}
102 onClose={() => {}}
103 {...props}
104 />
105 </LocaleProvider>,
106 );
107 await flushTimers(30);
108 });
109 return root;
110 }
111
112 function findButton(text: string): HTMLButtonElement | undefined {
113 return Array.from(document.querySelectorAll("button")).find((b) => b.textContent?.trim() === text) as
114 | HTMLButtonElement
115 | undefined;
116 }
117
118 async function click(button: HTMLButtonElement) {
119 await act(async () => {
120 button.click();
121 await flushTimers(20);
122 });
123 }
124
125 console.log("\nhistory panel recovery-copy bulk actions");
126
127 // History view: the sweep button trashes idle recovery copies only.
128 {
129 const dom = installDom();
130 const deleted: string[][] = [];
131 const purged: string[][] = [];
132 const root = await renderPanel({
133 kind: "history",
134 sessions: [
135 session({ path: "/s/normal.jsonl" }),
136 session({ path: "/s/continued-recovery-0123456789abcdef.jsonl", title: "continued recovery kept", recovered: true }),
137 session({ path: "/s/idle-recovery-0123456789abcdef.jsonl", recovered: true, recoveryCopy: true }),
138 session({ path: "/s/open-recovery-0123456789abcdef.jsonl", recovered: true, recoveryCopy: true, open: true }),
139 session({ path: "/s/current-recovery-0123456789abcdef.jsonl", recovered: true, recoveryCopy: true, current: true }),
140 ],
141 onDeleteMany: (paths: string[]) => deleted.push(paths),
142 onPurgeAll: (paths: string[]) => purged.push(paths),
143 onPurgeRecoveryCopies: (paths: string[]) => purged.push(paths),
144 });
145
146 const sweep = findButton("Trash recovery copies");
147 ok(Boolean(sweep), "history view shows the trash-recovery-copies button");
148 if (sweep) {
149 await click(sweep); // arm
150 const confirm = findButton("Confirm trash copies");
151 ok(Boolean(confirm), "sweep arms into a confirm step");
152 if (confirm) await click(confirm);
153 }
154 eq(deleted, [["/s/idle-recovery-0123456789abcdef.jsonl"]], "sweep trashes only idle recovery copies");
155 ok(document.body.textContent?.includes("continued recovery kept"), "continued recovery remains in normal history");
156 eq(purged, [], "history sweep never purges");
157
158 await act(async () => {
159 root.unmount();
160 });
161 dom.window.close();
162 }
163
164 // History view: no idle copies → no sweep button.
165 {
166 const dom = installDom();
167 const root = await renderPanel({
168 kind: "history",
169 sessions: [
170 session({ path: "/s/normal.jsonl" }),
171 session({ path: "/s/current-recovery-0123456789abcdef.jsonl", recovered: true, recoveryCopy: true, current: true }),
172 ],
173 onDeleteMany: () => {},
174 });
175 ok(!findButton("Trash recovery copies"), "history view hides the sweep button when every copy is live");
176 await act(async () => {
177 root.unmount();
178 });
179 dom.window.close();
180 }
181
182 // Trash view: clear copies purges recovery copies, empty trash stays separate.
183 {
184 const dom = installDom();
185 const purged: string[][] = [];
186 const emptied: string[][] = [];
187 const root = await renderPanel({
188 kind: "trash",
189 sessions: [
190 session({ path: "/t/normal.jsonl", deletedAt: now }),
191 session({ path: "/t/continued-recovery-0123456789abcdef.jsonl", deletedAt: now, recovered: true }),
192 session({ path: "/t/a-recovery-0123456789abcdef.jsonl", deletedAt: now, recovered: true, recoveryCopy: true }),
193 session({ path: "/t/b-recovery-0123456789abcdef.jsonl", deletedAt: now, recovered: true, recoveryCopy: true }),
194 ],
195 onRestore: () => {},
196 onPurge: () => {},
197 onPurgeAll: (paths: string[]) => emptied.push(paths),
198 onPurgeRecoveryCopies: (paths: string[]) => purged.push(paths),
199 });
200
201 const clear = findButton("Clear copies");
202 ok(Boolean(clear), "trash view shows the clear-copies button");
203 if (clear) {
204 await click(clear); // arm
205 const confirm = findButton("Confirm clear copies");
206 ok(Boolean(confirm), "clear copies arms into a confirm step");
207 if (confirm) await click(confirm);
208 }
209 eq(
210 purged,
211 [["/t/a-recovery-0123456789abcdef.jsonl", "/t/b-recovery-0123456789abcdef.jsonl"]],
212 "clear copies purges every trashed recovery copy and nothing else",
213 );
214 eq(emptied, [], "clear copies does not invoke the unguarded empty-trash action");
215
216 await act(async () => {
217 root.unmount();
218 });
219 dom.window.close();
220 }
221
222 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
223 if (failed > 0) process.exit(1);
224
224 lines Plain Text