| 1 | import assert from "node:assert/strict"; |
| 2 | import { coalescesQuery, invalidateSharedQuery, maybeShare, resetQueryCoalescing, shareQuery } from "../lib/queryCoalesce"; |
| 3 | |
| 4 | function deferred<T>() { |
| 5 | let resolve!: (v: T) => void; |
| 6 | let reject!: (e: unknown) => void; |
| 7 | const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; }); |
| 8 | return { promise, resolve, reject }; |
| 9 | } |
| 10 | |
| 11 | async function identicalCallsInFlightShareOneAnswer() { |
| 12 | resetQueryCoalescing(); |
| 13 | let runs = 0; |
| 14 | const d = deferred<string>(); |
| 15 | const run = () => { runs++; return d.promise; }; |
| 16 | const a = shareQuery("ContextPanel", ["tab-1"], run); |
| 17 | const b = shareQuery("ContextPanel", ["tab-1"], run); |
| 18 | d.resolve("panel"); |
| 19 | assert.equal(await a, "panel"); |
| 20 | assert.equal(await b, "panel"); |
| 21 | assert.equal(runs, 1, "a duplicate inside the window must not reach the backend"); |
| 22 | } |
| 23 | |
| 24 | async function differentArgumentsStayIndependent() { |
| 25 | resetQueryCoalescing(); |
| 26 | let runs = 0; |
| 27 | const run = async () => { runs++; return runs; }; |
| 28 | await shareQuery("MetaForTab", ["tab-1"], run); |
| 29 | await shareQuery("MetaForTab", ["tab-2"], run); |
| 30 | assert.equal(runs, 2, "another tab is another question"); |
| 31 | } |
| 32 | |
| 33 | async function mutationsInvalidateInflightAnswers() { |
| 34 | resetQueryCoalescing(); |
| 35 | const stale = deferred<string>(); |
| 36 | const first = shareQuery("MetaForTab", ["tab-1"], () => stale.promise); |
| 37 | await maybeShare("NewSessionForTab", ["tab-1"], async () => undefined); |
| 38 | const fresh = await shareQuery("MetaForTab", ["tab-1"], async () => "fresh"); |
| 39 | assert.equal(fresh, "fresh", "a post-mutation query must not inherit the old session request"); |
| 40 | stale.resolve("stale"); |
| 41 | assert.equal(await first, "stale"); |
| 42 | } |
| 43 | |
| 44 | // A stale answer is worse than a slow one: a failure must not be inherited. |
| 45 | async function rejectionIsNotSharedOnward() { |
| 46 | resetQueryCoalescing(); |
| 47 | let runs = 0; |
| 48 | const first = shareQuery("ListTabs", [], async () => { runs++; throw new Error("boom"); }); |
| 49 | await assert.rejects(first); |
| 50 | const second = await shareQuery("ListTabs", [], async () => { runs++; return "ok"; }); |
| 51 | assert.equal(second, "ok"); |
| 52 | assert.equal(runs, 2, "the next caller retries instead of inheriting the failure"); |
| 53 | } |
| 54 | |
| 55 | async function invalidationStartsAFreshRead() { |
| 56 | resetQueryCoalescing(); |
| 57 | let runs = 0; |
| 58 | const run = async () => ++runs; |
| 59 | assert.equal(await shareQuery("MetaForTab", ["tab-1"], run), 1); |
| 60 | invalidateSharedQuery("MetaForTab", ["tab-1"]); |
| 61 | assert.equal(await shareQuery("MetaForTab", ["tab-1"], run), 2); |
| 62 | } |
| 63 | |
| 64 | async function settledTabListsAreNotShared() { |
| 65 | resetQueryCoalescing(); |
| 66 | let runs = 0; |
| 67 | const run = async () => ++runs; |
| 68 | assert.equal(await shareQuery("ListTabs", [], run), 1); |
| 69 | assert.equal(await shareQuery("ListTabs", [], run), 2, "tab mutations must be visible after the in-flight burst"); |
| 70 | } |
| 71 | |
| 72 | // Anything that mutates or starts work must never be collapsed. |
| 73 | function onlyReadOnlyQueriesAreCoalesced() { |
| 74 | for (const readOnly of ["ListProjectTree", "ContextPanel", "MetaForTab", "BalanceForTab"]) { |
| 75 | assert.equal(coalescesQuery(readOnly), true, `${readOnly} should coalesce`); |
| 76 | } |
| 77 | for (const mutating of ["Send", "SetActiveTab", "OpenGlobalTab", "Cancel", "ApproveTool"]) { |
| 78 | assert.equal(coalescesQuery(mutating), false, `${mutating} must never be coalesced`); |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | const tests: Array<[string, () => unknown]> = [ |
| 83 | ["identical in-flight calls share one answer", identicalCallsInFlightShareOneAnswer], |
| 84 | ["different arguments stay independent", differentArgumentsStayIndependent], |
| 85 | ["mutations invalidate in-flight answers", mutationsInvalidateInflightAnswers], |
| 86 | ["a rejection is not shared onward", rejectionIsNotSharedOnward], |
| 87 | ["external state boundaries invalidate settled answers", invalidationStartsAFreshRead], |
| 88 | ["settled tab lists do not cross mutations", settledTabListsAreNotShared], |
| 89 | ["only read-only queries are coalesced", onlyReadOnlyQueriesAreCoalesced], |
| 90 | ]; |
| 91 | |
| 92 | let failed = 0; |
| 93 | for (const [name, fn] of tests) { |
| 94 | try { |
| 95 | await fn(); |
| 96 | console.log(`ok ${name}`); |
| 97 | } catch (err) { |
| 98 | failed++; |
| 99 | console.error(`FAIL ${name}\n ${(err as Error).message}`); |
| 100 | } |
| 101 | } |
| 102 | if (failed > 0) process.exit(1); |
| 103 |