| 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 |