import "./test-helpers/setup-localstorage.mjs"; import test from "node:test"; import assert from "node:assert/strict"; import { JSDOM } from "jsdom"; import { torrentClient } from "../js/webtorrent.js"; import { attachHealthBadges } from "../js/gridHealth.js"; import { FIVE_MINUTES_MS, setCardLivenessPolicy, getCardLivenessPolicy } from "../js/constants.js"; import { readTorrentProbeFromStorage } from "../js/utils/storage.js"; const healthy = { healthy: true, peers: 1, reason: "peer", hasProbeTrackers: true }; let nextHash = 1; const magnet = () => "magnet:?xt=urn:btih:" + (nextHash++).toString(16).padStart(40, "0"); // A task-turn boundary drains the queue's promise continuations and badge writes; // no wall-clock delay, polling or live network is involved. const flush = () => new Promise((resolve) => setImmediate(resolve)); function fixture(t) { const dom = new JSDOM("", { url: "https://example.invalid" }); Object.defineProperty(dom.window, "innerHeight", { value: 20 }); const previousPolicy = getCardLivenessPolicy(); const saved = new Map( [ "window", "document", "HTMLElement", "localStorage", "IntersectionObserver", "__BITVID_CARD_LIVENESS_POLICY__" ].map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]) ); Object.assign(globalThis, { window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement, localStorage: dom.window.localStorage }); const observers = []; class Observer { constructor(callback) { this.callback = callback; this.cards = []; observers.push(this); } observe(card) { this.cards.push(card); } takeRecords() { return []; } disconnect() { this.disconnected = true; } show() { // Intentionally allow late delivery after disconnect to test stale entries. this.callback( this.cards.map((target, index) => ({ target, isIntersecting: true, intersectionRatio: 1, intersectionRect: { top: index * 20, width: 100, height: 20 } })) ); } } globalThis.IntersectionObserver = Observer; const calls = []; const disposals = []; let active = 0; let peakActive = 0; t.mock.method(torrentClient, "probePeers", (source, options) => { active += 1; peakActive = Math.max(peakActive, active); let resolve; const promise = new Promise((done) => { resolve = done; }); const call = { source, options, settled: false, finish(result = healthy) { if (call.settled) return; call.settled = true; active -= 1; resolve(result); } }; calls.push(call); return promise; }); setCardLivenessPolicy("show-pending"); t.after(async () => { disposals.forEach((dispose) => dispose()); // Disposing removes queued owners; only the controlled active calls remain. calls.forEach((call) => call.finish()); await flush(); setCardLivenessPolicy(previousPolicy); dom.window.close(); for (const [key, descriptor] of saved) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else delete globalThis[key]; } }); function feed(sources, options = {}) { const container = document.createElement("section"); const cards = sources.map((source) => { const card = document.createElement("article"); card.className = "card"; card.dataset.videoId = source; card.dataset.magnet = source; if (options.hidden) { card.hidden = true; card.dataset.urlHealthUrl = encodeURIComponent( "https://media.example.invalid/video.mp4" ); card.dataset.urlHealthState = "checking"; } const badge = document.createElement("span"); badge.className = "torrent-health-badge"; card.appendChild(badge); container.appendChild(card); return card; }); document.body.appendChild(container); const dispose = attachHealthBadges(container, options); disposals.push(dispose); return { container, cards, dispose, observer: observers.at(-1) }; } function saturate() { const blockers = feed(Array.from({ length: 4 }, magnet)); blockers.observer.show(); assert.equal( calls.length, 4, "current four-probe concurrency must be saturated" ); return blockers; } function reattach(feed) { const dispose = attachHealthBadges(feed.container); disposals.push(dispose); return { ...feed, dispose, observer: observers.at(-1) }; } return { feed, reattach, saturate, calls, get peakActive() { return peakActive; } }; } test("last feed owner disposal prevents the fifth queued probe from starting", async (t) => { const f = fixture(t); const feed = f.feed(Array.from({ length: 5 }, magnet)); feed.observer.show(); assert.equal(f.calls.length, 4); feed.dispose(); feed.container.remove(); assert.equal(feed.observer.disconnected, true); f.calls.forEach((call) => call.finish()); await flush(); assert.equal(f.calls.length, 4, "obsolete queued source must never start"); }); test("disposing one of two queued source owners preserves the live owner", async (t) => { const f = fixture(t); f.saturate(); const source = magnet(); const disposedUpdates = []; const first = f.feed([source], { onUpdate: (event) => disposedUpdates.push(event.state) }); const second = f.feed([source]); first.observer.show(); second.observer.show(); assert.equal(f.calls.length, 4); first.dispose(); // Keep DOM connected: stale writes must be guarded by ownership. disposedUpdates.length = 0; f.calls[0].finish(); await flush(); assert.equal( f.calls.length, 5, "remaining live owner must retain the shared queued probe" ); assert.equal(f.calls[4].source, source); f.calls[4].finish(); await flush(); assert.equal(second.cards[0].dataset.streamHealthState, "healthy"); assert.equal(first.cards[0].dataset.streamHealthState, "checking"); assert.deepEqual( disposedUpdates, [], "disposed registration must receive no later badge notifications" ); }); test("disposal from a checking callback prevents probe submission", async (t) => { const f = fixture(t); const feed = f.feed([magnet()], { onUpdate: ({ state }) => { if (state === "checking") feed.dispose(); } }); feed.observer.show(); await flush(); assert.equal( f.calls.length, 0, "checking notification must not submit work after synchronous disposal" ); }); test("shared badge listener stays deduplicated until its last registration disposes", async (t) => { const f = fixture(t); const updates = []; const listener = (event) => updates.push(event); const first = f.feed([magnet()], { onUpdate: listener }); const live = f.feed([magnet()], { onUpdate: listener }); first.observer.show(); assert.equal( updates.length, 1, "same callback must receive each update once across registrations" ); first.dispose(); updates.length = 0; live.observer.show(); assert.equal( updates.length, 1, "disposing one registration must retain the shared callback" ); f.calls[1].finish(); await flush(); assert.deepEqual( updates.map((event) => event.state), ["checking", "healthy"] ); live.dispose(); updates.length = 0; const other = f.feed([magnet()]); other.observer.show(); assert.equal( updates.length, 0, "last listener owner disposal must stop later notifications" ); }); test("cancelled source can be re-requested before old settlement without cache poison or duplicate work", async (t) => { const f = fixture(t); f.saturate(); const source = magnet(); const first = f.feed([source]); first.observer.show(); first.dispose(); const replacement = f.feed([source]); replacement.observer.show(); // New inflight record before cancelled promise settles. await flush(); assert.equal( readTorrentProbeFromStorage(source.slice(-40)), null, "cancelled job must not cache an unhealthy result" ); assert.equal(replacement.cards[0].dataset.streamHealthState, "checking"); const sharedReplacement = f.feed([source]); sharedReplacement.observer.show(); // Must still find the replacement inflight record. f.calls.forEach((call) => call.finish()); await flush(); assert.equal( f.calls.filter((call) => call.source === source).length, 1, "old finalizer must not delete newer inflight work" ); f.calls.at(-1).finish(); await flush(); for (const feed of [replacement, sharedReplacement]) { assert.equal(feed.cards[0].dataset.streamHealthState, "healthy"); } const cached = f.feed([source]); cached.observer.show(); assert.equal(cached.cards[0].dataset.streamHealthState, "healthy"); assert.equal( f.calls.length, 5, "successful replacement must retain normal cache reuse" ); }); test("queue cancellation preserves stable priority and concurrency of four", async (t) => { const f = fixture(t); f.saturate(); const [first, low, high, cancelled] = Array.from({ length: 4 }, magnet); const batch = f.feed([first, low]); batch.observer.show(); // First gets baseline, low gets baseline minus one. const newer = f.feed([high]); newer.observer.show(); // Baseline: follows the earlier equal-priority first. const obsolete = f.feed([cancelled]); obsolete.observer.show(); obsolete.dispose(); for (const [slot, expected] of [ [0, first], [4, high], [5, low] ]) { f.calls[slot].finish(); await flush(); assert.equal(f.calls.at(-1).source, expected); } assert.equal( f.peakActive, 4, "cancellation must not free active slots prematurely" ); assert.deepEqual( f.calls.slice(4).map((call) => call.source), [first, high, low] ); assert.equal( f.calls.some((call) => call.source === cancelled), false ); }); test("healthy hidden card probes eagerly and reveals without an intersection", async (t) => { const f = fixture(t); setCardLivenessPolicy("hide-all"); const source = magnet() + "&tr=wss%3A%2F%2Ftracker.example.invalid"; const feed = f.feed([source], { hidden: true }); assert.equal( f.calls.length, 1, "hidden card must eagerly probe on registration" ); assert.equal(feed.cards[0].hidden, true); assert.deepEqual(f.calls[0].options.urlList, [ "https://media.example.invalid/video.mp4" ]); assert.equal( f.calls[0].source, source, "magnet bytes must reach the probe unchanged" ); f.calls[0].finish(); await flush(); assert.equal(feed.cards[0].dataset.streamHealthState, "healthy"); assert.equal(feed.cards[0].hidden, false); assert.equal(feed.cards[0].dataset.sourceVisibility, "visible"); }); test("active probe remains shared while disposed owners and late observer entries stay inert", async (t) => { const f = fixture(t); const source = magnet(); const first = f.feed([source]); first.observer.show(); first.dispose(); const liveUpdates = []; const live = f.feed([source], { onUpdate: (event) => liveUpdates.push(event) }); first.observer.show(); // An already-delivered entry may arrive after disconnect. live.observer.show(); assert.equal( liveUpdates.some((event) => event.card === first.cards[0]), false, "late observer entries must not emit disposed-card badge events" ); assert.equal( f.calls.length, 1, "already active probe must remain deduplicated" ); f.calls[0].finish(); await flush(); assert.equal(first.cards[0].dataset.streamHealthState, "checking"); assert.equal(live.cards[0].dataset.streamHealthState, "healthy"); }); test("repeated old disposal cannot cancel a reattached live registration", async (t) => { const f = fixture(t); f.saturate(); const source = magnet(); const old = f.feed([source]); old.observer.show(); old.dispose(); const renewed = f.reattach(old); renewed.observer.show(); old.dispose(); f.calls[0].finish(); await flush(); assert.equal( f.calls.length, 5, "old disposer must not unregister the new queued owner" ); f.calls[4].finish(); await flush(); assert.equal(renewed.cards[0].dataset.streamHealthState, "healthy"); }); test("disposed active completion remains cached for a later live owner", async (t) => { const f = fixture(t); const source = magnet(); const old = f.feed([source]); old.observer.show(); old.dispose(); f.calls[0].finish(); await flush(); assert.equal(old.cards[0].dataset.streamHealthState, "checking"); const live = f.feed([source]); live.observer.show(); assert.equal( f.calls.length, 1, "ownerless active completion must retain its healthy cache result" ); assert.equal(live.cards[0].dataset.streamHealthState, "healthy"); }); test("completed healthy and negative probes retain existing cache TTL behavior", async (t) => { const f = fixture(t); let time = 1_000_000; t.mock.method(Date, "now", () => time); const sources = [magnet(), magnet()]; const first = f.feed(sources); first.observer.show(); f.calls[0].finish(); f.calls[1].finish({ healthy: false, peers: 0, hasProbeTrackers: true, reason: "timeout" }); await flush(); const cached = f.feed(sources); cached.observer.show(); assert.deepEqual( cached.cards.map((card) => card.dataset.streamHealthState), ["healthy", "unhealthy"] ); assert.equal(f.calls.length, 2); time += FIVE_MINUTES_MS; cached.observer.show(); assert.equal( f.calls.length, 2, "existing inclusive TTL boundary must remain cached" ); time += 1; const expired = f.feed(sources); expired.observer.show(); assert.equal( f.calls.length, 4, "both healthy and negative entries must expire under the existing TTL" ); });