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