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