| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | import "./app-memory-workers.test.mjs"; |
| 4 | import { attributeRetention, BASELINE_NOT_SETTLED_REASON, evidenceIntegrity, retainedCohorts, screeningBlockers, summarizeHeap, TRANSIENT_EXCURSION_REASON } from "./app-memory-evidence.mjs"; |
| 5 | |
| 6 | const sample = (ids, roundTrips) => ({ phase: "full", roundTrips, lifecycle: { |
| 7 | liveRenderTokenIds: ids, liveRenderTokens: ids.length, |
| 8 | activeOperations: 0, activeSubscriptions: 6, invariantViolations: 0, overflow: false, |
| 9 | } }); |
| 10 | test("one measured parser worker is independent of DOM listener retention", () => { |
| 11 | const values = [0, 1, 0, 1].map((count, index) => ({ |
| 12 | ...sample([1, index + 2], index * 32), |
| 13 | dom: { nodes: 6750, jsEventListeners: 359 + count * 3 }, |
| 14 | workers: count ? [["error", "error", "message"]] : [], |
| 15 | })); |
| 16 | assert.deepEqual(screeningBlockers(attributeRetention(values).reasons), []); |
| 17 | values.at(-1).dom.jsEventListeners++; |
| 18 | assert.ok(screeningBlockers(attributeRetention(values).reasons).includes("post-gc-dom-or-listener-drift")); |
| 19 | }); |
| 20 | test("worker accumulation and extra listeners still block screening", () => { |
| 21 | const baseline = { ...sample([1, 2], 0), dom: { nodes: 6750, jsEventListeners: 359 }, workers: [] }; |
| 22 | for (const workers of [[["message"], ["message"]], [["message", "message"]], [["error", "error", "error"]], [["resize"]]]) { |
| 23 | const tail = { ...sample([1, 3], 32), dom: { nodes: 6750, jsEventListeners: 365 }, workers }; |
| 24 | assert.ok(screeningBlockers(attributeRetention([baseline, tail]).reasons).includes("worker-population-drift")); |
| 25 | } |
| 26 | }); |
| 27 | test("deliberately retained cohorts remain detectable even when totals are constant", () => { |
| 28 | const samples = [sample([1, 2], 0), sample([3, 4], 32), sample([3, 5], 64), sample([3, 6], 96)]; |
| 29 | assert.equal(evidenceIntegrity(samples), true); |
| 30 | assert.deepEqual(retainedCohorts(samples).at(-1).retainedPostBaseline, [3]); |
| 31 | }); |
| 32 | test("probe overflow, duplicate IDs and cleanup underflow invalidate evidence", () => { |
| 33 | for (const mutation of [{ overflow: true }, { invariantViolations: 1 }, { activeSubscriptions: -1 }, { liveRenderTokenIds: [1, 1] }]) { |
| 34 | const value = sample([1, 2], 0); |
| 35 | Object.assign(value.lifecycle, mutation); |
| 36 | assert.equal(evidenceIntegrity([value]), false); |
| 37 | } |
| 38 | }); |
| 39 | test("stable owner counters alone cannot establish whole-App retention attribution", () => { |
| 40 | const samples = [ |
| 41 | { ...sample([1, 2], 0), dom: { nodes: 6000, jsEventListeners: 500 } }, |
| 42 | { ...sample([1, 3], 32), dom: { nodes: 6024, jsEventListeners: 512 } }, |
| 43 | { ...sample([1, 4], 64), dom: { nodes: 6024, jsEventListeners: 512 } }, |
| 44 | { ...sample([1, 5], 96), dom: { nodes: 6024, jsEventListeners: 512 } }, |
| 45 | ]; |
| 46 | assert.equal(attributeRetention(samples).status, "needs-attribution"); |
| 47 | assert.ok(attributeRetention(samples).reasons.includes("heap-retainer-and-control-evidence-required")); |
| 48 | }); |
| 49 | test("a stable tail cannot hide growth earlier in the post-GC sequence", () => { |
| 50 | const samples = [6000, 6024, 6100, 6100, 6100].map((nodes, index) => ({ |
| 51 | ...sample([1, index + 2], index * 32), dom: { nodes, jsEventListeners: 500 }, |
| 52 | })); |
| 53 | assert.ok(attributeRetention(samples).reasons.includes("post-gc-dom-or-listener-drift")); |
| 54 | }); |
| 55 | test("missing or non-finite native counters are invalid evidence", () => { |
| 56 | for (const dom of [undefined, {}, { nodes: NaN, jsEventListeners: 500 }, { nodes: 6000, jsEventListeners: -1 }]) { |
| 57 | const samples = Array.from({ length: 4 }, (_, index) => ({ ...sample([1, index + 2], index * 32), dom })); |
| 58 | assert.ok(attributeRetention(samples).reasons.includes("invalid-native-counters")); |
| 59 | } |
| 60 | }); |
| 61 | test("subscriptions retained after a round trip require attribution", () => { |
| 62 | const samples = Array.from({ length: 4 }, (_, index) => ({ |
| 63 | ...sample([1, index + 2], index * 32), dom: { nodes: 6000, jsEventListeners: 500 }, |
| 64 | })); |
| 65 | for (const retained of samples.slice(1)) retained.lifecycle.activeSubscriptions++; |
| 66 | assert.ok(attributeRetention(samples).reasons.includes("subscription-population-drift")); |
| 67 | }); |
| 68 | test("persistent post-baseline cohorts remain a qualification blocker", () => { |
| 69 | const samples = [ |
| 70 | { ...sample([1, 2], 0), dom: { nodes: 6000, jsEventListeners: 500 } }, |
| 71 | { ...sample([1, 3], 32), dom: { nodes: 6024, jsEventListeners: 512 } }, |
| 72 | { ...sample([1, 3], 64), dom: { nodes: 6024, jsEventListeners: 512 } }, |
| 73 | { ...sample([1, 3], 96), dom: { nodes: 6024, jsEventListeners: 512 } }, |
| 74 | ]; |
| 75 | assert.equal(attributeRetention(samples).status, "needs-attribution"); |
| 76 | }); |
| 77 | test("the automated gate blocks on screening failures, not the offline attribution duty", () => { |
| 78 | const clean = Array.from({ length: 4 }, (_, index) => ({ |
| 79 | ...sample([1, index + 2], index * 32), dom: { nodes: 6024, jsEventListeners: 512 }, |
| 80 | })); |
| 81 | assert.deepEqual(screeningBlockers(attributeRetention(clean).reasons), []); |
| 82 | const drift = [6000, 6024, 6100, 6100].map((nodes, index) => ({ |
| 83 | ...sample([1, index + 2], index * 32), dom: { nodes, jsEventListeners: 500 }, |
| 84 | })); |
| 85 | assert.deepEqual(screeningBlockers(attributeRetention(drift).reasons), ["post-gc-dom-or-listener-drift"]); |
| 86 | assert.deepEqual(screeningBlockers(["missing-attribution"]), ["missing-attribution"]); |
| 87 | }); |
| 88 | test("a mid-sequence excursion that fully returns to baseline is an observation, not a blocker", () => { |
| 89 | // The real Linux/Chromium soak shows 614 listeners at phase transitions |
| 90 | // settling back to the 512 baseline; freed counters are not retention. |
| 91 | const samples = Array.from({ length: 21 }, (_, index) => ({ |
| 92 | ...sample([1, index + 2], index * 32), |
| 93 | dom: { nodes: 6049, jsEventListeners: index === 6 || index === 13 ? 614 : 512 }, |
| 94 | })); |
| 95 | const result = attributeRetention(samples); |
| 96 | assert.ok(result.reasons.includes(TRANSIENT_EXCURSION_REASON)); |
| 97 | assert.deepEqual(screeningBlockers(result.reasons), []); |
| 98 | }); |
| 99 | test("a displaced final tail remains a persistent blocker", () => { |
| 100 | const samples = Array.from({ length: 21 }, (_, index) => ({ |
| 101 | ...sample([1, index + 2], index * 32), |
| 102 | dom: { nodes: 6049, jsEventListeners: index === 20 ? 614 : 512 }, |
| 103 | })); |
| 104 | const result = attributeRetention(samples); |
| 105 | assert.ok(result.reasons.includes("post-gc-dom-or-listener-drift")); |
| 106 | assert.deepEqual(screeningBlockers(result.reasons), ["post-gc-dom-or-listener-drift"]); |
| 107 | }); |
| 108 | test("a settled population below baseline records release instead of retention", () => { |
| 109 | // A real shard settled from 346 to 343 listeners with stable nodes, |
| 110 | // subscriptions, operations, and render cohorts. Fewer live listeners |
| 111 | // cannot be evidence of retained listeners. |
| 112 | const samples = Array.from({ length: 21 }, (_, index) => ({ |
| 113 | ...sample([1, index + 2], index * 32), |
| 114 | dom: { nodes: 6724, jsEventListeners: index === 5 || index === 6 ? 456 : 346 }, |
| 115 | })); |
| 116 | samples.push({ ...sample([1, 23], 512), phase: "settled", dom: { nodes: 6724, jsEventListeners: 343 } }); |
| 117 | const result = attributeRetention(samples); |
| 118 | assert.ok(result.reasons.includes(TRANSIENT_EXCURSION_REASON)); |
| 119 | assert.deepEqual(screeningBlockers(result.reasons), []); |
| 120 | }); |
| 121 | test("an explicit settled tail sample is the authoritative resting state", () => { |
| 122 | // Round 5 CI data: the blip can land on the final round checkpoint; the |
| 123 | // quiescent sample after it proves recovery, so this must not block. |
| 124 | const blipBeforeSettled = Array.from({ length: 21 }, (_, index) => ({ |
| 125 | ...sample([1, index + 2], index * 32), |
| 126 | dom: { nodes: 6049, jsEventListeners: index === 20 ? 614 : 512 }, |
| 127 | })); |
| 128 | blipBeforeSettled.push({ ...sample([1, 23], 512), phase: "settled", dom: { nodes: 6049, jsEventListeners: 512 } }); |
| 129 | const recovered = attributeRetention(blipBeforeSettled); |
| 130 | assert.ok(recovered.reasons.includes(TRANSIENT_EXCURSION_REASON)); |
| 131 | assert.deepEqual(screeningBlockers(recovered.reasons), []); |
| 132 | // A settled tail that stays displaced is still a persistent blocker. |
| 133 | const stuck = blipBeforeSettled.map((sample_) => ({ ...sample_ })); |
| 134 | stuck[21] = { ...stuck[21], dom: { nodes: 6049, jsEventListeners: 614 } }; |
| 135 | assert.deepEqual(screeningBlockers(attributeRetention(stuck).reasons), ["post-gc-dom-or-listener-drift"]); |
| 136 | }); |
| 137 | test("an unsettled baseline is reported instead of being judged as displacement", () => { |
| 138 | // Round 5 CI data: the baseline sample itself caught the cleanup blip (616), |
| 139 | // every later reading rested at 514, and the gate misreported the series as |
| 140 | // persistent drift. |
| 141 | const samples = Array.from({ length: 21 }, (_, index) => ({ |
| 142 | ...sample([1, index + 2], index * 32), |
| 143 | dom: { nodes: 6049, jsEventListeners: index === 0 ? 616 : 512 }, |
| 144 | })); |
| 145 | samples[0] = { ...samples[0], baselineStable: false, baselineReadings: [{ nodes: 6049, jsEventListeners: 616 }, { nodes: 6049, jsEventListeners: 512 }] }; |
| 146 | samples.push({ ...sample([1, 23], 512), phase: "settled", dom: { nodes: 6049, jsEventListeners: 512 } }); |
| 147 | const result = attributeRetention(samples); |
| 148 | assert.ok(result.reasons.includes(BASELINE_NOT_SETTLED_REASON)); |
| 149 | assert.deepEqual(screeningBlockers(result.reasons), [BASELINE_NOT_SETTLED_REASON]); |
| 150 | }); |
| 151 | test("an unsettled baseline still blocks sustained growth away from a low point", () => { |
| 152 | // 616 -> 400 -> 450 -> 500 -> 550 ends below the baseline but keeps growing; |
| 153 | // skipping the displacement check must not hide it. |
| 154 | const listeners = [616, 400, 450, 500, 550]; |
| 155 | const samples = listeners.map((value, index) => ({ |
| 156 | ...sample([1, index + 2], index * 32), |
| 157 | dom: { nodes: 6049, jsEventListeners: value }, |
| 158 | })); |
| 159 | samples[0] = { ...samples[0], baselineStable: false }; |
| 160 | const result = attributeRetention(samples); |
| 161 | assert.ok(result.reasons.includes("post-gc-dom-or-listener-drift")); |
| 162 | assert.deepEqual(screeningBlockers(result.reasons), [BASELINE_NOT_SETTLED_REASON, "post-gc-dom-or-listener-drift"]); |
| 163 | }); |
| 164 | test("a settled baseline keeps the displacement check", () => { |
| 165 | const samples = Array.from({ length: 21 }, (_, index) => ({ |
| 166 | ...sample([1, index + 2], index * 32), |
| 167 | dom: { nodes: 6049, jsEventListeners: index === 0 ? 512 : 514 }, |
| 168 | })); |
| 169 | samples[0] = { ...samples[0], baselineStable: true }; |
| 170 | samples.push({ ...sample([1, 23], 512), phase: "settled", dom: { nodes: 6049, jsEventListeners: 514 } }); |
| 171 | assert.deepEqual(screeningBlockers(attributeRetention(samples).reasons), ["post-gc-dom-or-listener-drift"]); |
| 172 | }); |
| 173 | test("native objects are not automatically detached DOM", () => { |
| 174 | const heap = { snapshot: { meta: { |
| 175 | node_fields: ["type", "name", "id", "self_size", "detachedness"], |
| 176 | node_types: [["native", "code"], [], [], [], []], |
| 177 | } }, strings: ["HTMLDivElement", "compiled function"], |
| 178 | nodes: [0, 0, 1, 64, 1, 0, 0, 2, 64, 2, 1, 1, 3, 128, 0] }; |
| 179 | const result = summarizeHeap(heap); |
| 180 | assert.equal(result.categories.native.count, 2); |
| 181 | assert.equal(result.categories.code.selfBytes, 128); |
| 182 | assert.deepEqual(result.detached.HTMLDivElement.ids, [2]); |
| 183 | heap.snapshot.meta.node_fields[4] = "unknown"; |
| 184 | assert.equal(summarizeHeap(heap).detachednessAvailable, false); |
| 185 | assert.deepEqual(summarizeHeap(heap).detached, {}); |
| 186 | }); |
| 187 |