返回 DeepSeek-Reasonix
composer-history.test.ts
根目录 / desktop / frontend / src / __tests__ / composer-history.test.ts
1 // Run: tsx src/__tests__/composer-history.test.ts
2 //
3 // Tests for composerHistory.ts (backend-backed prompt history with nonce caching).
4
5 import { invalidateCache, snapshot, pushHistory, clearHistory, loadOlder } from "../lib/composerHistory";
6 import type { PromptHistoryEntry, PromptHistoryResult } from "../lib/types";
7 import { installDesktopHostStub } from "./desktopHostStub";
8
9 let passed = 0;
10 let failed = 0;
11
12 function eq(a: unknown, b: unknown, label: string) {
13 if (a === b) {
14 process.stdout.write(` PASS ${label}\n`);
15 passed += 1;
16 } else {
17 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
18 failed += 1;
19 }
20 }
21
22 // Ensure window exists so bridge's realApp() check works.
23 function ensureWindow() {
24 if (typeof window === "undefined") {
25 (globalThis as Record<string, unknown>).window = {} as Window & typeof globalThis;
26 }
27 }
28
29 // Install a mock ScanPromptHistory on the desktop host stub so the bridge proxy finds it.
30 function setMock(
31 mock: (
32 nonce: string,
33 ) => Promise<
34 | { entries: PromptHistoryEntry[] | null; nonce?: string }
35 | PromptHistoryResult
36 | PromptHistoryEntry[]
37 | [PromptHistoryEntry[] | null, string]
38 | { "0"?: PromptHistoryEntry[] | null; "1"?: string; [key: string]: unknown }
39 | null
40 >,
41 ) {
42 ensureWindow();
43 installDesktopHostStub({ ScanPromptHistory: mock });
44 invalidateCache();
45 }
46
47 async function testSnapshotSupportsArrayResult() {
48 setMock(async () => [{ text: "array 1", at: Date.now(), sessionPath: "/mock/a.jsonl", turn: 0 }]);
49 const entries = await snapshot();
50 eq(entries.length, 1, "array result is supported");
51 eq(entries[0].text, "array 1", "array result text");
52 }
53
54 async function testSnapshotSupportsTupleResult() {
55 setMock(async () => [[{ text: "tuple 1", at: Date.now(), sessionPath: "/mock/a.jsonl", turn: 0 }], "tuple-nonce"]);
56 const entries = await snapshot();
57 eq(entries.length, 1, "tuple result is supported");
58 eq(entries[0].text, "tuple 1", "tuple result text");
59 }
60
61 async function testSnapshotSupportsMapTupleResult() {
62 setMock(async () => ({
63 0: [{ text: "map tuple 1", at: Date.now(), sessionPath: "/mock/a.jsonl", turn: 0 }],
64 1: "map-tuple-nonce",
65 }));
66 const entries = await snapshot();
67 eq(entries.length, 1, "map tuple result is supported");
68 eq(entries[0].text, "map tuple 1", "map tuple result text");
69 }
70
71 async function testSnapshotSupportsNullResult() {
72 setMock(async () => null);
73 const entries = await snapshot();
74 eq(entries.length, 0, "null result is supported");
75 }
76
77 async function testTupleCacheHitRespectsTupleNonce() {
78 let callCount = 0;
79 setMock(async (nonce) => {
80 callCount++;
81 if (nonce === "tuple-hit") {
82 return [null, "tuple-hit"];
83 }
84 return [[{ text: "tuple cache", at: Date.now(), sessionPath: "/mock/a.jsonl", turn: 0 }], "tuple-hit"];
85 });
86
87 const r1 = await snapshot();
88 eq(r1.length, 1, "tuple first call returns entry");
89 const r2 = await snapshot();
90 eq(r2.length, 1, "tuple cache hit returns cached entry");
91 eq(r2[0].text, "tuple cache", "tuple cache hit keeps cached text");
92 eq(callCount, 1, "snapshot reuses loaded tape entries");
93 }
94
95 // --- Test 1: snapshot returns entries from backend ---
96 async function testSnapshotReturnsEntries() {
97 setMock(async (_nonce) => ({
98 entries: [{ text: "hello", at: Date.now(), sessionPath: "/mock/a.jsonl", turn: 0 }],
99 nonce: "n1",
100 }));
101 const entries = await snapshot();
102 eq(entries.length, 1, "returns 1 entry");
103 eq(entries[0].text, "hello", "correct text");
104 }
105
106 // --- Test 2: cache hit reuses previous nonce ---
107 async function testCacheHit() {
108 let callCount = 0;
109 setMock(async (nonce) => {
110 callCount++;
111 if (nonce === "cached-nonce") {
112 return { entries: null, nonce: "cached-nonce" };
113 }
114 return { entries: [{ text: "hello", at: Date.now(), sessionPath: "/mock/a.jsonl", turn: 0 }], nonce: "cached-nonce" };
115 });
116
117 const r1 = await snapshot();
118 eq(r1.length, 1, "first call returns entry");
119 eq(callCount, 1, "first call hits backend");
120
121 // Second call: same nonce → backend returns nil (cache hit).
122 const r2 = await snapshot();
123 eq(r2.length, 1, "cache hit returns cached entry");
124 eq(r2[0].text, "hello", "cache hit keeps cached text");
125 eq(callCount, 1, "second snapshot reads loaded tape entries");
126
127 // After invalidate: resets nonce to "" → backend returns fresh.
128 invalidateCache();
129 const r3 = await snapshot();
130 eq(r3.length, 1, "after invalidate re-fetches");
131 eq(callCount, 2, "after invalidate, backend called");
132 }
133
134 async function testLoadOlderUsesCursor() {
135 const seenCursors: string[] = [];
136 setMock(async (request) => {
137 const parsed = JSON.parse(request || "{}") as { cursor?: string; limit?: number };
138 seenCursors.push(parsed.cursor ?? "");
139 if (!parsed.cursor) {
140 return {
141 entries: [{ text: "page 1", at: 2, sessionPath: "/mock/a.jsonl", turn: 1 }],
142 nonce: "tape",
143 olderCursor: "cursor-2",
144 hasOlder: true,
145 };
146 }
147 return {
148 entries: [{ text: "page 2", at: 1, sessionPath: "/mock/b.jsonl", turn: 0 }],
149 nonce: "tape",
150 olderCursor: "",
151 hasOlder: false,
152 };
153 });
154
155 const first = await loadOlder();
156 const second = await loadOlder();
157 const all = await snapshot();
158 eq(first[0].text, "page 1", "first page text");
159 eq(second[0].text, "page 2", "second page text");
160 eq(all.length, 2, "snapshot contains loaded tape pages");
161 eq(seenCursors[0], "", "first request has empty cursor");
162 eq(seenCursors[1], "cursor-2", "second request uses older cursor");
163 }
164
165 // --- Test 2.5: first ArrowUp press should recall newest entry first ---
166 async function testFirstArrowUpIsMostRecent() {
167 setMock(async (nonce) => {
168 if (nonce === "u1") {
169 return { entries: null, nonce: "u1" };
170 }
171 return {
172 entries: [
173 { text: "newest", at: 3000, sessionPath: "/mock/a.jsonl", turn: 0 },
174 { text: "middle", at: 2000, sessionPath: "/mock/a.jsonl", turn: 1 },
175 { text: "oldest", at: 1000, sessionPath: "/mock/a.jsonl", turn: 2 },
176 ],
177 nonce: "u1",
178 };
179 });
180
181 const first = await snapshot();
182 eq(first.length, 3, "first ArrowUp press sees 3 history entries");
183 eq(first[0].text, "newest", "first recalled prompt is the newest entry");
184
185 const second = await snapshot();
186 eq(second.length, 3, "cache hit returns cached history");
187 eq(second[0].text, "newest", "cache hit keeps newest first");
188
189 invalidateCache();
190 const third = await snapshot();
191 eq(third.length, 3, "after invalidation first ArrowUp can still recall again");
192 eq(third[0].text, "newest", "after invalidation still recalls newest first");
193 }
194
195 // --- Test 3: snapshot returns empty on error ---
196 async function testSnapshotError() {
197 setMock(async (_nonce) => { throw new Error("backend failed"); });
198 const entries = await snapshot();
199 eq(entries.length, 0, "empty on error");
200 }
201
202 // --- Test 4: invalidateCache resets nonce to "" ---
203 async function testInvalidateResetsNonce() {
204 const seenNonces: string[] = [];
205 setMock(async (request) => {
206 const parsed = JSON.parse(request || "{}") as { nonce?: string };
207 seenNonces.push(parsed.nonce ?? "");
208 return { entries: [{ text: "msg", at: Date.now(), sessionPath: "/mock/a.jsonl", turn: 0 }], nonce: "server-nonce", olderCursor: "", hasOlder: false };
209 });
210
211 invalidateCache();
212 await loadOlder();
213 eq(seenNonces.length, 1, "calls backend once after invalidate");
214 eq(seenNonces[0], "", "nonce is '' after invalidate");
215 }
216
217 // --- Test 5: pushHistory and clearHistory are no-ops ---
218 async function testNoopFunctions() {
219 // These should not throw.
220 pushHistory("some text");
221 clearHistory();
222 eq(true, true, "push/clear do not throw");
223 }
224
225 // --- main ----------------------------------------------------------------
226
227 console.log("\ncomposerHistory");
228
229 (async () => {
230 const tests: [string, () => Promise<void>][] = [
231 ["returns entries from backend", testSnapshotReturnsEntries],
232 ["cache hit reuses nonce", testCacheHit],
233 ["loadOlder follows cursor", testLoadOlderUsesCursor],
234 ["first ArrowUp press recalls newest entry first", testFirstArrowUpIsMostRecent],
235 ["supports array return shape", testSnapshotSupportsArrayResult],
236 ["supports tuple return shape", testSnapshotSupportsTupleResult],
237 ["supports map tuple result", testSnapshotSupportsMapTupleResult],
238 ["supports null result", testSnapshotSupportsNullResult],
239 ["supports tuple cache-hit result", testTupleCacheHitRespectsTupleNonce],
240 ["returns empty on error", testSnapshotError],
241 ["invalidate resets nonce", testInvalidateResetsNonce],
242 ["push/clear are no-ops", testNoopFunctions],
243 ];
244
245 for (const [name, fn] of tests) {
246 try {
247 await fn();
248 } catch (e) {
249 process.stdout.write(` FAIL ${name} threw: ${e}\n`);
250 failed++;
251 }
252 }
253
254 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
255 if (failed > 0) process.exit(1);
256 })();
257
257 lines TYPESCRIPT