| 1 | import assert from "node:assert/strict"; |
| 2 | import { |
| 3 | enqueueProjectTreeArchive, |
| 4 | projectTreeSessionArchiveTargetKey, |
| 5 | projectTreeTopicArchiveTargetKey, |
| 6 | projectTreeTrashingTopics, |
| 7 | runProjectTreeArchiveJob, |
| 8 | } from "../lib/projectTreeArchive"; |
| 9 | import { |
| 10 | invalidateProjectTreeTopicLoads, |
| 11 | projectTreeFolderKeyForSession, |
| 12 | projectTreeWithoutTopics, |
| 13 | } from "../lib/projectTreeTopic"; |
| 14 | import type { ProjectNode } from "../lib/types"; |
| 15 | import { readFileSync } from "node:fs"; |
| 16 | import { dirname, join } from "node:path"; |
| 17 | import { fileURLToPath } from "node:url"; |
| 18 | |
| 19 | function deferred<T>() { |
| 20 | let resolve!: (value: T) => void; |
| 21 | let reject!: (error: unknown) => void; |
| 22 | const promise = new Promise<T>((resolvePromise, rejectPromise) => { |
| 23 | resolve = resolvePromise; |
| 24 | reject = rejectPromise; |
| 25 | }); |
| 26 | return { promise, resolve, reject }; |
| 27 | } |
| 28 | |
| 29 | async function testLatePreArchivePageCannotReinsertTopic() { |
| 30 | const sequences: Record<string, number> = { project: 1 }; |
| 31 | const capturedSequence = sequences.project; |
| 32 | const latePage = deferred<ProjectNode[]>(); |
| 33 | const applied = latePage.promise.then((items) => |
| 34 | sequences.project === capturedSequence ? items : [], |
| 35 | ); |
| 36 | |
| 37 | invalidateProjectTreeTopicLoads(sequences, ["project"]); |
| 38 | latePage.resolve([{ key: "topic-a", kind: "topic", label: "A", topicId: "topic-a" }]); |
| 39 | assert.deepEqual(await applied, []); |
| 40 | } |
| 41 | |
| 42 | async function testPendingTombstonesFilterEveryIncomingPage() { |
| 43 | const incoming: ProjectNode[] = [ |
| 44 | { key: "topic-a", kind: "topic", label: "A", topicId: "topic-a" }, |
| 45 | { key: "topic-b", kind: "topic", label: "B", topicId: "topic-b" }, |
| 46 | { key: "topic-c", kind: "topic", label: "C", topicId: "topic-c" }, |
| 47 | ]; |
| 48 | assert.deepEqual( |
| 49 | projectTreeWithoutTopics(incoming, new Set(["topic-a", "topic-b"])).map((node) => node.topicId), |
| 50 | ["topic-c"], |
| 51 | ); |
| 52 | } |
| 53 | |
| 54 | async function testPostCommitRestoreCanWinWhilePendingIndicatorFinishes() { |
| 55 | const pending = projectTreeTrashingTopics(new Set(), "topic-a", true); |
| 56 | const tombstones = projectTreeTrashingTopics(new Set(), "topic-a", true); |
| 57 | const sequences: Record<string, number> = { project: 2 }; |
| 58 | |
| 59 | // Starting the post-commit canonical load fences every pre-commit response, |
| 60 | // then releases only the tombstone. The visible pending state may remain |
| 61 | // until that load finishes without hiding a legitimate later restore. |
| 62 | invalidateProjectTreeTopicLoads(sequences, ["project"]); |
| 63 | const releasedTombstones = projectTreeTrashingTopics(tombstones, "topic-a", false); |
| 64 | const restored = projectTreeWithoutTopics( |
| 65 | [{ key: "topic-a", kind: "topic", label: "Restored A", topicId: "topic-a" }], |
| 66 | releasedTombstones, |
| 67 | ); |
| 68 | assert.equal(pending.has("topic-a"), true); |
| 69 | assert.equal(restored[0]?.topicId, "topic-a"); |
| 70 | } |
| 71 | |
| 72 | async function testConcurrentArchivesReachBackendSerially() { |
| 73 | const firstGate = deferred<void>(); |
| 74 | const secondGate = deferred<void>(); |
| 75 | const firstStarted = deferred<void>(); |
| 76 | const secondStarted = deferred<void>(); |
| 77 | const calls: string[] = []; |
| 78 | let tail = Promise.resolve(); |
| 79 | |
| 80 | const first = enqueueProjectTreeArchive(tail, async () => { |
| 81 | calls.push("a:start"); |
| 82 | firstStarted.resolve(); |
| 83 | await firstGate.promise; |
| 84 | calls.push("a:end"); |
| 85 | }); |
| 86 | tail = first; |
| 87 | const second = enqueueProjectTreeArchive(tail, async () => { |
| 88 | calls.push("b:start"); |
| 89 | secondStarted.resolve(); |
| 90 | await secondGate.promise; |
| 91 | calls.push("b:end"); |
| 92 | }); |
| 93 | tail = second; |
| 94 | |
| 95 | await firstStarted.promise; |
| 96 | assert.deepEqual(calls, ["a:start"]); |
| 97 | firstGate.resolve(); |
| 98 | await first; |
| 99 | await secondStarted.promise; |
| 100 | assert.deepEqual(calls, ["a:start", "a:end", "b:start"]); |
| 101 | secondGate.resolve(); |
| 102 | await tail; |
| 103 | assert.deepEqual(calls, ["a:start", "a:end", "b:start", "b:end"]); |
| 104 | } |
| 105 | |
| 106 | async function testPendingEndsOnlyAfterCanonicalReload() { |
| 107 | const reloadGate = deferred<void>(); |
| 108 | let pending = true; |
| 109 | const job = runProjectTreeArchiveJob({ |
| 110 | archive: async () => {}, |
| 111 | commit: () => {}, |
| 112 | reload: () => reloadGate.promise, |
| 113 | finishPending: () => { pending = false; }, |
| 114 | recover: async () => {}, |
| 115 | }); |
| 116 | |
| 117 | await Promise.resolve(); |
| 118 | assert.equal(pending, true); |
| 119 | reloadGate.resolve(); |
| 120 | assert.equal(await job, true); |
| 121 | assert.equal(pending, false); |
| 122 | } |
| 123 | |
| 124 | async function testFailedArchiveRestoresVisibilityBeforeRecoveryReload() { |
| 125 | const backendGate = deferred<void>(); |
| 126 | let pending = true; |
| 127 | let tombstones = new Set<string>(); |
| 128 | let recoveryObservedPending: boolean | null = null; |
| 129 | const job = runProjectTreeArchiveJob({ |
| 130 | archive: () => backendGate.promise, |
| 131 | commit: () => { tombstones = projectTreeTrashingTopics(tombstones, "topic-a", true); }, |
| 132 | reload: async () => {}, |
| 133 | finishPending: () => { pending = false; }, |
| 134 | recover: async () => { recoveryObservedPending = pending; }, |
| 135 | }); |
| 136 | |
| 137 | await Promise.resolve(); |
| 138 | assert.equal(pending, true); |
| 139 | assert.equal(tombstones.has("topic-a"), false, "pending backend work must not hide the topic"); |
| 140 | assert.equal(projectTreeWithoutTopics( |
| 141 | [{ key: "topic-a", kind: "topic", label: "A", topicId: "topic-a" }], |
| 142 | tombstones, |
| 143 | ).length, 1); |
| 144 | backendGate.reject(new Error("busy")); |
| 145 | assert.equal(await job, false); |
| 146 | assert.equal(tombstones.has("topic-a"), false, "rejected archives never commit a tombstone"); |
| 147 | assert.equal(recoveryObservedPending, false); |
| 148 | } |
| 149 | |
| 150 | function testArchiveTargetsDoNotCollideAcrossRows() { |
| 151 | assert.notEqual( |
| 152 | projectTreeTopicArchiveTargetKey("project", "/a", "shared"), |
| 153 | projectTreeTopicArchiveTargetKey("project", "/b", "shared"), |
| 154 | "same-id topics in different projects keep independent confirmations", |
| 155 | ); |
| 156 | assert.notEqual( |
| 157 | projectTreeSessionArchiveTargetKey("/a/one.jsonl"), |
| 158 | projectTreeSessionArchiveTargetKey("/a/two.jsonl"), |
| 159 | "sessions in one topic keep independent confirmations", |
| 160 | ); |
| 161 | } |
| 162 | |
| 163 | function testSessionArchiveReloadsItsOwningFolder() { |
| 164 | const tree: ProjectNode[] = [{ |
| 165 | key: "project-a", |
| 166 | kind: "project", |
| 167 | label: "Project A", |
| 168 | children: [{ |
| 169 | key: "topic-a", |
| 170 | kind: "topic", |
| 171 | label: "Topic A", |
| 172 | children: [{ |
| 173 | key: "session-a", |
| 174 | kind: "session", |
| 175 | label: "Session A", |
| 176 | sessionPath: " /sessions/a.jsonl ", |
| 177 | }], |
| 178 | }], |
| 179 | }]; |
| 180 | assert.equal(projectTreeFolderKeyForSession(tree, "/sessions/a.jsonl"), "project-a"); |
| 181 | assert.equal(projectTreeFolderKeyForSession(tree, "/sessions/missing.jsonl"), ""); |
| 182 | } |
| 183 | |
| 184 | function testProjectTreeWiresEveryRaceGuard() { |
| 185 | const source = readFileSync( |
| 186 | join(dirname(fileURLToPath(import.meta.url)), "../components/ProjectTree.tsx"), |
| 187 | "utf8", |
| 188 | ); |
| 189 | const archiveSource = readFileSync( |
| 190 | join(dirname(fileURLToPath(import.meta.url)), "../lib/projectTreeArchive.ts"), |
| 191 | "utf8", |
| 192 | ); |
| 193 | const sessionMenuSource = readFileSync( |
| 194 | join(dirname(fileURLToPath(import.meta.url)), "../components/ProjectTreeSessionArchiveMenu.tsx"), |
| 195 | "utf8", |
| 196 | ); |
| 197 | assert.match(archiveSource, /await archive\(\)[\s\S]*commit\(\)[\s\S]*await reload\(\)/); |
| 198 | assert.match(archiveSource, /commitArchiveTombstone\(topicId\)[\s\S]*invalidateProjectTreeTopicLoads[\s\S]*optimisticallyRemoveTopic\(topicId\)/); |
| 199 | assert.match(source, /projectTreeWithoutTopics\(asArray\(page\.items\), currentArchiveTombstones\(\)\)/); |
| 200 | assert.match(archiveSource, /return previous\.catch\(\(\) => undefined\)\.then\(work\)/); |
| 201 | assert.match(archiveSource, /pendingLoads = targets\.map[\s\S]*onReloadStarted[\s\S]*await Promise\.all\(pendingLoads\)/); |
| 202 | assert.match(archiveSource, /onReloadStarted: \(\) => releaseArchiveTombstone\(topicId\)/); |
| 203 | assert.match(archiveSource, /finishPending: \(\) => endTrashingTopic\(topicId\)/); |
| 204 | assert.match(source, /const topicMenuOpen = menuNodeKey === key/); |
| 205 | assert.match(source, /onContextMenu=\{openTopicMenu\}/); |
| 206 | assert.match(source, /node\.sessionPath \?\? ""/); |
| 207 | assert.match(sessionMenuSource, /disabled: !sessionPath \|\| blocked \|\| busy/); |
| 208 | assert.match(source, /sessionPath=\{sessionPath\} blocked=\{archiveBlocked \|\| topicTrashing\}/); |
| 209 | assert.match(source, /void trashSession\(node\)/); |
| 210 | assert.match(archiveSource, /projectTreeFolderKeyForSession\(treeRef\.current, sessionPath\)[\s\S]*sessionLifecycleFences.archive\(target, receipt\)[\s\S]*optimisticallyRemoveSession\(target\)[\s\S]*refreshRef\.current\(reloadOptions\)/); |
| 211 | } |
| 212 | |
| 213 | await testLatePreArchivePageCannotReinsertTopic(); |
| 214 | await testPendingTombstonesFilterEveryIncomingPage(); |
| 215 | await testPostCommitRestoreCanWinWhilePendingIndicatorFinishes(); |
| 216 | await testConcurrentArchivesReachBackendSerially(); |
| 217 | await testPendingEndsOnlyAfterCanonicalReload(); |
| 218 | await testFailedArchiveRestoresVisibilityBeforeRecoveryReload(); |
| 219 | testArchiveTargetsDoNotCollideAcrossRows(); |
| 220 | testSessionArchiveReloadsItsOwningFolder(); |
| 221 | testProjectTreeWiresEveryRaceGuard(); |
| 222 | console.log("project tree archive race: 9 passed"); |
| 223 |