diff --git a/TEST_INTEGRITY.md b/TEST_INTEGRITY.md index 9e29df29..b707d1df 100644 --- a/TEST_INTEGRITY.md +++ b/TEST_INTEGRITY.md @@ -760,3 +760,80 @@ test_integrity_note: ``` Duplicate wrapper and unconditional listener release variants each fail SCN-shared-badge-listener-lifetime. Removing the post-checking membership guard fails SCN-checking-callback-disposal (actual starts1 vs expected0). These3 additional variants exit1 with1 failed/0 cancelled and are reverted; total14 killed variants for11 lifecycle scenarios. Original source/priority/observer expectations remain unchanged. + + +## client-reliability-4 — queued URL media ownership + +```yaml +test_integrity_note: + change_type: ["new_tests"] + scenarios: + - id: SCN-url-last-owner + given: "four cards, three active media and one queued" + when: "last registration is disposed and an active slot frees" + then: "queued promise settles null, no cache entry or obsolete media start" + - id: SCN-url-shared-owner + given: "two feeds share a queued event/source" + when: "one feed disposes" + then: "exactly one media probe serves the live badge; disposed connected badge stays checking" + - id: SCN-url-last-shared-owner + given: "two feeds share a queued event/source" + when: "both feeds dispose" + then: "queued promise settles null and never starts media" + - id: SCN-url-cancel-rerequest + given: "cancelled queued event/source has pending promise continuations" + when: "a new feed requests the same source immediately" + then: "new inflight record survives old settlement and live badge becomes healthy" + - id: SCN-url-cancel-mirror + given: "queued primary has a mirror" + when: "last owner disposes" + then: "cancelled work settles without mirror HEAD/media or stale UI/cache" + - id: SCN-url-registration-reuse + given: "hidden old feed queues an eager callback" + when: "dispose, reuse container, deliver old callback and old disposer" + then: "only the new event reaches onCheck and starts IO" + - id: SCN-url-active-cache + given: "active media belongs to a connected card" + when: "dispose its registration and complete active media" + then: "active result caches healthy, disposed badge stays checking, new card uses cache" + - id: SCN-url-hidden-reveal + given: "URL-only card is hidden pending health" + when: "eager media confirmation succeeds" + then: "card becomes visible with healthy badge" + - id: SCN-url-media-limit + given: "three active media probes plus FIFO queued work" + when: "slot releases while a new card enters" + then: "existing queued source runs first; active media stays three" + - id: SCN-url-mirror-order + given: "primary and two mirrors exist" + when: "primary errors then first mirror succeeds" + then: "primary then first mirror are requested; second mirror is untouched" + - id: SCN-url-legacy-callback + given: "connected badge lacks observer ownership" + when: "legacy caller invokes controller" + then: "media settles and healthy badge paints" + - id: SCN-url-cross-controller + given: "shared real inflight cache and two controllers" + when: "creator feed disposes while other feed remains" + then: "queued shared source starts once and other controller paints healthy" + observable_outcomes: + - "HEAD and native media IO starts, promise settlement, real cache/inflight records, connected badge and card visibility" + - "Observer callback emissions after disposal and container reuse" + determinism_controls: + - "jsdom cards, controllable IntersectionObserver, explicit loadeddata/error events and stubbed fetch boundary; real observer/controller/cache" + - "Unique event/source keys and task-turn drain; no live traffic, arbitrary sleeps, retries or relaxed assertions" + anti_cheat_rationale: + prevents: + - "hard-coded healthy or cancelled results" + - "testing a copied queue instead of real controller and observer" + - "cancelling shared live work or caching cancellation as offline" + - "stale connected DOM callbacks hidden by isConnected guards" + - "retry/sleep-based flake masking" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +All existing assertions preserved. `tests/url-health-lifecycle.test.mjs` adds12 scenarios. Mutations are temporary edits restored before gates. `python3 /tmp/item4-mutants.py` final run:13/13 variants naturally exit1 with named assertion failures (no timeouts); logs `/tmp/item4-mutant-*.log`, results `/tmp/item4-mutants.json`. No production test runner/gate changes. + +Coverage grouping: no-unregister→last owner; cancel-shared→shared owner/cross-controller; reuse-cancelled-inflight→immediate retry/new inflight; cache-cancellation→last/shared owner/retry/mirror cache outcomes; mirror-fallthrough→cancelled primary; grouped stale guards→shared/active disposed UI (remove both redundant owner guards together; two equivalent variants); eager-callback-stale→registration reuse; no-eager-reveal→hidden reveal; concurrency-four→three-media cap/FIFO; reverse-mirrors→mirror ordering; break-legacy-consumer→callback compatibility; controller-local-records→shared cache ownership across controllers. Initial mutation pass exposed callback-emission and first-source gaps; new assertions were strengthened, then all mutants rejected. Initial no-unregister full-file run failed assertions but hung in another scenario awaiting its intentionally broken promise; final targeted last-owner variant naturally exits1. No timeout is counted as successful mutant evidence. diff --git a/decisions/DECISIONS_2026-10-20_03-24-00_client-reliability-4.md b/decisions/DECISIONS_2026-10-20_03-24-00_client-reliability-4.md new file mode 100644 index 00000000..86e042bd --- /dev/null +++ b/decisions/DECISIONS_2026-10-20_03-24-00_client-reliability-4.md @@ -0,0 +1,7 @@ +# client-reliability-4 implementation + +- Existing observer unregister boundary owns per-badge lifecycle tokens, read by controller via WeakMap. Existing app/feed callback arguments unchanged; direct legacy controller consumers remain live until settlement. +- Module-scoped promise→ownership WeakMap mirrors shared real inflight cache across controllers. Last-owner release only cancels queued media jobs. Already-running media/cache behavior remains; no active HEAD/media/HLS/torrent abort. +- Queue resumes reserve a slot synchronously to preserve three-media cap. Cancellation settles with an explicit internal outcome propagated through mirrors and omitted from health cache. Existing state-cache promise identity guard preserves immediate re-request. +- Guards bind UI/RAF work to captured registration; late observer callbacks/eager microtasks and stale disposer cannot affect reused registrations. +- No app/shared observer/cache-module or policy/TTL edits. Scope/read-only predecessor evidence at77d486fb; dispatch implementation routing merged before code. diff --git a/docs/TODO.md b/docs/TODO.md index 2071b8d7..602ef97b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -16,6 +16,8 @@ ## client-reliability +- 2026-10-20: item4 routed implementation in URL observer/controller complete locally;12 lifecycle scenarios and13 reverted mutants, focused/lint/build pass. Queued last-owner work settles without cache/mirror/stale UI; shared owners, immediate retry and hidden reveal covered. Remaining: native browser smoke/full merged-tree gate/normal landing/closure; active abort/fallback/CPU unverified. + - 2026-10-20: client-reliability-4 investigation at4c54858c reproduces queued URL media start after feed disposal: active3/queued1 before and after disposal, then1 obsolete start(expected0); shared live-owner control deduplicates and receives healthy badge. Strict assertion exits1, diagnostic/control naturally exit0. No product/tests changed; awaiting dispatch fix routing. Active abort/browser/fallback/CPU unverified. Evidence: test_logs/TEST_LOG_2026-10-20_03-10-00_client-reliability-4.md. - 2026-10-19: client-reliability-1 assigned: reproduce/classify and fix profile-modal suite hang without weakening assertions. Initial evidence: three isolated 60s timeouts (one clean environment), full baseline stopped at file 220/425; read docs/agents/prompts/client-reliability.md and claim the item. Scope excludes app.js and unrelated subsystems. diff --git a/js/ui/urlHealthController.js b/js/ui/urlHealthController.js index b31f007a..afae005a 100644 --- a/js/ui/urlHealthController.js +++ b/js/ui/urlHealthController.js @@ -1,17 +1,21 @@ +import { getUrlHealthBadgeOwner } from "../urlHealthObserver.js"; import { SHORT_TIMEOUT_MS } from "../constants.js"; import { isHlsUrl, prefersNativeHls, - probeHlsManifest, + probeHlsManifest } from "../services/hlsPlayback.js"; +// Inflight cache is shared, so ownership also spans controller instances. +const urlProbeRecords = new WeakMap(); + export default class UrlHealthController { constructor({ state = {}, utils = {}, logger = {}, constants = {}, - callbacks = {}, + callbacks = {} } = {}) { this.state = state; this.utils = utils; @@ -44,7 +48,13 @@ export default class UrlHealthController { `; } - updateUrlHealthBadge(badgeEl, state, videoId) { + updateUrlHealthBadge( + badgeEl, + state, + videoId, + owner = getUrlHealthBadgeOwner(badgeEl) + ) { + if (owner && !owner.active) return; if (!badgeEl) { return; } @@ -61,7 +71,7 @@ export default class UrlHealthController { if (typeof requestAnimationFrame === "function") { requestAnimationFrame(() => { if (badgeEl.isConnected) { - this.updateUrlHealthBadge(badgeEl, state, videoId); + this.updateUrlHealthBadge(badgeEl, state, videoId, owner); } }); } @@ -74,18 +84,19 @@ export default class UrlHealthController { offline: "❌ CDN", unknown: "⚠️ CDN", timeout: "⚠️ CDN timed out", - checking: "⏳ CDN", + checking: "⏳ CDN" }; const message = - state?.message || - fallbackMessages[status] || - fallbackMessages.checking; + state?.message || fallbackMessages[status] || fallbackMessages.checking; const hadCompactMargin = badgeEl.classList.contains("mt-sm") || badgeEl.classList.contains("mt-3"); badgeEl.dataset.urlHealthState = status; const cardEl = badgeEl.closest(".card[data-video-id]"); - if (cardEl && typeof this.utils.updateVideoCardSourceVisibility === "function") { + if ( + cardEl && + typeof this.utils.updateVideoCardSourceVisibility === "function" + ) { cardEl.dataset.urlHealthState = status; this.utils.updateVideoCardSourceVisibility(cardEl); } @@ -104,7 +115,7 @@ export default class UrlHealthController { offline: "critical", unknown: "neutral", timeout: "neutral", - checking: "neutral", + checking: "neutral" }; const variant = variantMap[status]; if (variant) { @@ -129,7 +140,7 @@ export default class UrlHealthController { message, lastCheckedAt: Number.isFinite(state?.lastCheckedAt) ? state.lastCheckedAt - : undefined, + : undefined }); } } @@ -139,6 +150,8 @@ export default class UrlHealthController { return; } + const owner = getUrlHealthBadgeOwner(badgeEl) || { active: true }; + if (!owner.active) return; const eventId = video.id; const trimmedUrl = typeof url === "string" ? url.trim() : ""; if (!trimmedUrl) { @@ -157,6 +170,8 @@ export default class UrlHealthController { this.updateUrlHealthBadge(badgeEl, { status: "checking" }, eventId); + if (!owner.active) return; + // Try the primary url plus any additional NIP-71 source mirrors, so one dead // host doesn't mark a video offline when an alternate plays. const sources = Array.isArray(video?.sources) ? video.sources : []; @@ -164,7 +179,9 @@ export default class UrlHealthController { const seenUrls = new Set(); for (const candidate of [ trimmedUrl, - ...sources.map((s) => (s && typeof s.url === "string" ? s.url.trim() : "")), + ...sources.map((s) => + s && typeof s.url === "string" ? s.url.trim() : "" + ) ]) { if (candidate && !seenUrls.has(candidate)) { seenUrls.add(candidate); @@ -179,14 +196,21 @@ export default class UrlHealthController { ? getInFlight(eventId, trimmedUrl, probeOptions) : null; - if (existingProbe) { + const existingRecord = existingProbe && urlProbeRecords.get(existingProbe); + if (existingProbe && !existingRecord?.cancelled) { + const release = existingRecord + ? this.retainUrlProbe(existingRecord, owner) + : () => {}; + existingProbe .then((entry) => { - if (entry) { - this.updateUrlHealthBadge(badgeEl, entry, eventId); + release(); + if (entry && owner.active) { + this.updateUrlHealthBadge(badgeEl, entry, eventId, owner); } }) .catch((err) => { + release(); this.logger.warn( `[urlHealth] cached probe promise rejected for ${trimmedUrl}:`, err @@ -195,8 +219,14 @@ export default class UrlHealthController { return; } - const probePromise = this.probeUrlList(candidateUrls, probeOptions) + const record = { owners: new Set(), cancelled: false, cancelQueued: null }; + const release = this.retainUrlProbe(record, owner); + const probePromise = this.probeUrlList(candidateUrls, { + ...probeOptions, + lifecycle: record + }) .then((result) => { + if (record.cancelled || result?.outcome === "cancelled") return null; const outcome = result?.outcome || "error"; let entry; @@ -205,17 +235,17 @@ export default class UrlHealthController { } else if (outcome === "opaque" || outcome === "unknown") { entry = { status: "unknown", - message: "⚠️ CDN", + message: "⚠️ CDN" }; } else if (outcome === "timeout") { entry = { status: "timeout", - message: "⚠️ CDN timed out", + message: "⚠️ CDN timed out" }; } else { entry = { status: "offline", - message: "❌ CDN", + message: "❌ CDN" }; } @@ -236,10 +266,11 @@ export default class UrlHealthController { return entry; }) .catch((err) => { + if (record.cancelled) return null; this.logger.warn(`[urlHealth] probe failed for ${trimmedUrl}:`, err); const entry = { status: "offline", - message: "❌ CDN", + message: "❌ CDN" }; if (typeof this.state.storeUrlHealth === "function") { return this.state.storeUrlHealth(eventId, trimmedUrl, entry); @@ -247,6 +278,7 @@ export default class UrlHealthController { return entry; }); + urlProbeRecords.set(probePromise, record); if (typeof this.state.setInFlightUrlProbe === "function") { this.state.setInFlightUrlProbe( eventId, @@ -258,11 +290,13 @@ export default class UrlHealthController { probePromise .then((entry) => { - if (entry) { - this.updateUrlHealthBadge(badgeEl, entry, eventId); + release(); + if (entry && owner.active) { + this.updateUrlHealthBadge(badgeEl, entry, eventId, owner); } }) .catch((err) => { + release(); this.logger.warn( `[urlHealth] probe promise rejected post-cache for ${trimmedUrl}:`, err @@ -270,8 +304,22 @@ export default class UrlHealthController { }); } - async probeUrlWithVideoElement(url, timeoutMs) { - const defaultTimeout = this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS; + retainUrlProbe(record, owner) { + record.owners.add(owner); + const onDispose = () => { + record.owners.delete(owner); + if (!record.owners.size) record.cancelQueued?.(); + }; + owner.releases?.add(onDispose); + return () => { + record.owners.delete(owner); + owner.releases?.delete(onDispose); + }; + } + + async probeUrlWithVideoElement(url, timeoutMs, lifecycle) { + const defaultTimeout = + this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS; const effectiveTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : defaultTimeout; @@ -290,20 +338,49 @@ export default class UrlHealthController { const defaultTimeout = this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS; const effective = - Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : defaultTimeout; + Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : defaultTimeout; const outcome = await probeHlsManifest(trimmed, effective); return { outcome }; } + if (lifecycle && !lifecycle.owners.size) { + lifecycle.cancelled = true; + return { outcome: "cancelled" }; + } if (this.activeVideoProbes >= this.MAX_CONCURRENT_VIDEO_PROBES) { - await new Promise((resolve) => { - this.videoProbeQueue.push(resolve); + const acquired = await new Promise((resolve) => { + const job = { + resume: () => { + if (lifecycle) lifecycle.cancelQueued = null; + // Reserve the slot synchronously, before another caller can use it. + this.activeVideoProbes++; + resolve(true); + } + }; + this.videoProbeQueue.push(job); + if (lifecycle) { + lifecycle.cancelQueued = () => { + const index = this.videoProbeQueue.indexOf(job); + if (index < 0) return; + this.videoProbeQueue.splice(index, 1); + lifecycle.cancelled = true; + lifecycle.cancelQueued = null; + resolve(false); + }; + } }); + if (!acquired) return { outcome: "cancelled" }; + } else { + this.activeVideoProbes++; } - this.activeVideoProbes++; - try { + if (lifecycle && !lifecycle.owners.size) { + lifecycle.cancelled = true; + return { outcome: "cancelled" }; + } return await new Promise((resolve) => { const video = document.createElement("video"); let settled = false; @@ -369,9 +446,7 @@ export default class UrlHealthController { this.activeVideoProbes--; if (this.videoProbeQueue.length > 0) { const next = this.videoProbeQueue.shift(); - if (typeof next === "function") { - next(); - } + next.resume(); } } } @@ -396,6 +471,7 @@ export default class UrlHealthController { continue; } const outcome = result?.outcome; + if (outcome === "cancelled") return result; if (outcome === "ok") { return { outcome: "ok", url }; } @@ -413,7 +489,8 @@ export default class UrlHealthController { } const confirmPlayable = options?.confirmPlayable === true; - const defaultTimeout = this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS; + const defaultTimeout = + this.constants.URL_PROBE_TIMEOUT_MS || SHORT_TIMEOUT_MS; const urlHealthConstants = this.constants.urlHealthConstants || {}; const confirmWithVideoElement = async () => { @@ -431,7 +508,8 @@ export default class UrlHealthController { try { const result = await this.probeUrlWithVideoElement( trimmed, - timeoutMs + timeoutMs, + options.lifecycle ); if (result && result.outcome) { return result; @@ -473,8 +551,8 @@ export default class UrlHealthController { method: "HEAD", mode: "no-cors", cache: "no-store", - signal: controller ? controller.signal : undefined, - }), + signal: controller ? controller.signal : undefined + }) ]; if (Number.isFinite(defaultTimeout) && defaultTimeout > 0) { @@ -543,7 +621,7 @@ export default class UrlHealthController { } return { outcome: "bad", - status: response.status, + status: response.status }; } @@ -552,7 +630,7 @@ export default class UrlHealthController { if (playbackCheck.outcome === "ok") { return { ...playbackCheck, - status: response.status, + status: response.status }; } return playbackCheck; @@ -560,7 +638,7 @@ export default class UrlHealthController { return { outcome: "ok", - status: response.status, + status: response.status }; } } diff --git a/js/urlHealthObserver.js b/js/urlHealthObserver.js index f6036e33..3127204d 100644 --- a/js/urlHealthObserver.js +++ b/js/urlHealthObserver.js @@ -9,6 +9,13 @@ import { cardNeedsEagerLivenessProbe } from "./utils/cardSourceVisibility.js"; const ROOT_MARGIN = getLivenessProbePrefetchMargin(); const THRESHOLD = 0.25; +// Badge ownership crosses the existing callback boundary without requiring +// callers to forward a new argument through the app/feed controllers. +const badgeOwners = new WeakMap(); +export function getUrlHealthBadgeOwner(badgeEl) { + return badgeOwners.get(badgeEl); +} + const urlCardObserver = createCardObserver({ rootMargin: ROOT_MARGIN, threshold: THRESHOLD, @@ -17,27 +24,44 @@ const urlCardObserver = createCardObserver({ return false; } const isIntersecting = Boolean(entry.isIntersecting); - const ratio = typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; + const ratio = + typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; return isIntersecting && ratio > 0; }, - createState: () => ({ onCheck: null }), + createState: () => ({ onCheck: null, active: true, owners: new Map() }), // Hide-until-verified cards start `display:none`, so the IntersectionObserver // never fires for them — kick the URL probe eagerly on register so they can be // verified and revealed (the alternative is they stay hidden forever). onCardRegister: ({ card, state }) => { + const badge = card.querySelector("[data-url-health-state]"); + const owner = { active: true, releases: new Set() }; + state.owners.set(card, owner); + if (badge) badgeOwners.set(badge, owner); if (cardNeedsEagerLivenessProbe(card)) { // attachUrlHealthBadges sets state.onCheck right AFTER observe() returns, so // defer one microtask to let that wiring land before we probe. queueMicrotask(() => triggerUrlCheck(card, state)); } }, + onCardUnregister: ({ card, state }) => { + const owner = state.owners.get(card); + if (!owner) return; + owner.active = false; + for (const release of owner.releases) release(); + owner.releases.clear(); + state.owners.delete(card); + }, onCardVisible: ({ card, state }) => { triggerUrlCheck(card, state); - }, + } }); function triggerUrlCheck(card, state) { - if (!state || typeof state.onCheck !== "function") { + if ( + !state?.active || + !state.owners.get(card)?.active || + typeof state.onCheck !== "function" + ) { return; } @@ -82,5 +106,12 @@ export function attachUrlHealthBadges(container, onCheck) { if (state && typeof onCheck === "function") { state.onCheck = onCheck; } - return () => urlCardObserver.disconnect(container); + return () => { + if (!state?.active) return; + state.active = false; + // A stale disposer must never disconnect a newer registration. + if (urlCardObserver.getState(container) === state) { + urlCardObserver.disconnect(container); + } + }; } diff --git a/test_logs/TEST_LOG_2026-10-20_03-24-00_client-reliability-4.md b/test_logs/TEST_LOG_2026-10-20_03-24-00_client-reliability-4.md new file mode 100644 index 00000000..701742d3 --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-20_03-24-00_client-reliability-4.md @@ -0,0 +1,9 @@ +# client-reliability-4 precommit gates + +- `env -u NODE_TEST_CONTEXT node tests/url-health-lifecycle.test.mjs`: exit0,12/12 scenarios,0 failures/cancellations/skips. Inherited NODE_TEST_CONTEXT=child-v8 makes --test output misleading; direct self-executing test file yields named scenarios. No runner edit. +- `env -u NODE_TEST_CONTEXT node scripts/run-unit-tests.mjs url-health health-disposal`: exit0,4 files. New lifecycle12, URL controller4, multi-source4, view health disposal2 =22 scenarios. +- `python3 /tmp/item4-mutants.py`: final13/13 restored mutants naturally exit1 with named assertion failures, no timeout counted. Coverage in TEST_INTEGRITY.md; initial surviving callback/mirror variants led to stronger assertions before final run. +- `npm run lint`: sandbox exit2 at git grep EPERM; same command with approved subprocess access exits0,11 stages. No lint relaxation. +- `npm run build`: exit0, including deploy-artifact verification; pre-existing Browserslist age warning, dependencies unchanged. +- Browser ephemeral server initially denied listen EPERM; `node /tmp/item4-browser.mjs` with approved local access exits0: Chromium+Firefox each0 obsolete starts, healthy shared badge/disposed badge checking, hidden reveal, hosted/torrent source-health visibility pass,0 external requests/page errors. Owned loopback server/browser closed; preview3000 untouched. Full merged-tree suite and normal queue/durable ancestry still required. +- No CPU/RSS or actual playback/live peers/fallback qualification. Only external IO virtualized; browser cache boundary uses in-memory service virtualization, unit regression uses actual state/cache module. diff --git a/tests/url-health-lifecycle.test.mjs b/tests/url-health-lifecycle.test.mjs new file mode 100644 index 00000000..1ea32a56 --- /dev/null +++ b/tests/url-health-lifecycle.test.mjs @@ -0,0 +1,438 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +// Resolve real cache dependencies with installed Node packages before DOM setup. +import * as cache from "../js/state/cache.js"; +import UrlHealthController from "../js/ui/urlHealthController.js"; +import { attachUrlHealthBadges } from "../js/urlHealthObserver.js"; +import { + setCardLivenessPolicy, + getCardLivenessPolicy +} from "../js/constants.js"; +import { updateVideoCardSourceVisibility } from "../js/utils/cardSourceVisibility.js"; + +const flush = () => new Promise((resolve) => setImmediate(resolve)); +let sequence = 0; +function fixture(t) { + const dom = new JSDOM("", { + url: "https://example.invalid" + }); + const names = [ + "window", + "document", + "HTMLElement", + "localStorage", + "fetch", + "IntersectionObserver", + "requestAnimationFrame" + ]; + const saved = names.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 policy = getCardLivenessPolicy(); + setCardLivenessPolicy("show-pending"); + const observers = [], + media = [], + heads = [], + promises = [], + warnings = [], + registrations = [], + visibility = [], + checks = []; + const id = (name) => `url-lifecycle-${++sequence}-${name}`; + const url = (name) => `https://media.invalid/${name}.mp4`; + globalThis.IntersectionObserver = class { + constructor(callback) { + this.callback = callback; + this.cards = []; + observers.push(this); + } + observe(card) { + this.cards.push(card); + } + takeRecords() { + return []; + } + disconnect() { + this.disconnected = true; + } + show() { + this.callback( + this.cards.map((target) => ({ + target, + isIntersecting: true, + intersectionRatio: 1 + })) + ); + } + }; + globalThis.fetch = async (source, options) => { + heads.push({ source, options }); + return { ok: true, status: 200, type: "basic" }; + }; + dom.window.HTMLMediaElement.prototype.load = function () { + if (this.getAttribute("src") && !media.includes(this)) media.push(this); + }; + dom.window.HTMLMediaElement.prototype.pause = function () {}; + const controller = new UrlHealthController({ + state: { + ...cache, + setInFlightUrlProbe(...args) { + promises.push(args[2]); + return cache.setInFlightUrlProbe(...args); + } + }, + logger: { + warn(...args) { + warnings.push(args); + } + }, + utils: { + updateVideoCardSourceVisibility(card) { + visibility.push(card); + updateVideoCardSourceVisibility(card); + } + } + }); + function feed( + videos, + { container = document.createElement("section") } = {} + ) { + container.replaceChildren(); + for (const video of videos) { + const card = document.createElement("article"); + card.className = "card"; + card.dataset.videoId = video.id; + card.dataset.urlHealthEventId = video.id; + card.dataset.urlHealthUrl = encodeURIComponent(video.url); + card.dataset.urlHealthState = "checking"; + card.dataset.streamHealthState = "unsupported"; + const badge = document.createElement("span"); + badge.dataset.urlHealthState = "checking"; + card.append(badge); + container.append(card); + updateVideoCardSourceVisibility(card); + } + document.body.append(container); + const registration = { container, videos }; + registration.dispose = attachUrlHealthBadges( + container, + ({ badgeEl, url, eventId }) => { + checks.push(eventId); + controller.handleUrlHealthBadge({ + video: videos.find((v) => v.id === eventId), + url, + badgeEl + }); + } + ); + registration.observer = observers.at(-1); + registration.cards = [...container.children]; + registration.badges = registration.cards.map( + (card) => card.firstElementChild + ); + registrations.push(registration); + return registration; + } + const video = (name) => { + const key = id(name); + return { id: key, url: url(key) }; + }; + const done = (element, event = "loadeddata") => + element.dispatchEvent(new dom.window.Event(event)); + async function finishAll() { + // Event-driven drain: each iteration completes the currently created media. + // No wall-clock delay or retry of a failed assertion. + let handled = 0; + while (handled < media.length) { + for (const element of media.slice(handled)) { + done(element); + handled++; + } + await flush(); + } + await Promise.all(promises); + await flush(); + } + t.after(async () => { + for (const registration of registrations) registration.dispose(); + await finishAll(); + dom.window.close(); + setCardLivenessPolicy(policy); + for (const [key, descriptor] of saved) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globalThis[key]; + } + }); + return { + dom, + controller, + media, + heads, + promises, + warnings, + visibility, + checks, + feed, + video, + done, + finishAll + }; +} +async function saturated(f) { + const videos = ["a", "b", "c", "queued"].map(f.video); + const feed = f.feed(videos); + feed.observer.show(); + await flush(); + assert.equal(f.media.length, 3); + assert.equal(f.heads.length, 4); + return { ...feed, videos }; +} + +test("last feed disposal settles queued URL work without an obsolete media start or negative cache", async (t) => { + const f = fixture(t), + old = await saturated(f), + queuedPromise = f.promises[3]; + old.dispose(); + old.container.remove(); + await flush(); + f.done(f.media[0]); + await flush(); + assert.equal(f.media.length, 3); + assert.equal(await queuedPromise, null); + assert.equal( + cache.getCachedUrlHealth(old.videos[3].id, old.videos[3].url), + null + ); + assert.equal( + cache.getInFlightUrlProbe(old.videos[3].id, old.videos[3].url, { + confirmPlayable: true + }), + null + ); + await f.finishAll(); + assert.equal(f.controller.activeVideoProbes, 0); + assert.equal(f.warnings.length, 0); +}); + +test("a queued shared URL survives disposal of one owner and updates only the live badge", async (t) => { + const f = fixture(t), + old = await saturated(f), + live = f.feed([old.videos[3]]); + live.observer.show(); + await flush(); + assert.equal(f.promises.length, 4); + assert.equal(f.heads.length, 4); + old.dispose(); // Keep DOM connected: stale badge guard must not rely on isConnected. + f.done(f.media[0]); + await flush(); + assert.equal(f.media.length, 4); + f.done(f.media[3]); + await flush(); + assert.equal(live.badges[0].dataset.urlHealthState, "healthy"); + assert.equal(old.badges[3].dataset.urlHealthState, "checking"); +}); + +test("disposing the final shared owner cancels queued work", async (t) => { + const f = fixture(t), + old = await saturated(f), + live = f.feed([old.videos[3]]); + live.observer.show(); + await flush(); + old.dispose(); + live.dispose(); + await flush(); + f.done(f.media[0]); + await flush(); + assert.equal(f.media.length, 3); + assert.equal(await f.promises[3], null); +}); + +test("same source can re-request immediately after cancellation without old settlement deleting its inflight record", async (t) => { + const f = fixture(t), + old = await saturated(f), + v = old.videos[3]; + old.dispose(); + const live = f.feed([v]); + live.observer.show(); // Deliberately before cancelled promise continuations. + const replacement = f.promises[4]; + assert.ok(replacement); + assert.notEqual(replacement, f.promises[3]); + await flush(); + assert.equal( + cache.getInFlightUrlProbe(v.id, v.url, { confirmPlayable: true }), + replacement + ); + assert.equal(cache.getCachedUrlHealth(v.id, v.url), null); + f.done(f.media[0]); + await flush(); + assert.equal(f.media.length, 4); + f.done(f.media[3]); + await flush(); + assert.equal(live.badges[0].dataset.urlHealthState, "healthy"); +}); + +test("cancelled queued primary does not fall through to a mirror or update a connected disposed badge", async (t) => { + const f = fixture(t), + busy = f.feed(["a", "b", "c"].map(f.video)); + busy.observer.show(); + await flush(); + const v = f.video("mirrored"); + v.sources = [{ url: "https://mirror.invalid/alternate.mp4" }]; + const old = f.feed([v]); + old.observer.show(); + await flush(); + old.dispose(); + await flush(); + assert.equal(f.heads.length, 4); + assert.equal(await f.promises[3], null); + assert.equal(old.badges[0].dataset.urlHealthState, "checking"); + await f.finishAll(); + assert.equal(f.media.length, 3); + assert.equal(f.heads.length, 4); +}); + +test("disposal blocks queued eager microtasks and late observer callbacks, including after container reuse", async (t) => { + const f = fixture(t); + setCardLivenessPolicy("hide-all"); + const old = f.feed([f.video("old")]); + old.dispose(); + const live = f.feed([f.video("new")], { container: old.container }); + old.dispose(); + old.observer.show(); + await flush(); + assert.deepEqual(f.checks, [live.videos[0].id]); + assert.equal(f.heads.length, 1); + assert.equal(f.heads[0].source, live.videos[0].url); + assert.equal(f.media.length, 1); + f.done(f.media[0]); + await flush(); + assert.equal(live.badges[0].dataset.urlHealthState, "healthy"); +}); + +test("active media retain prior result/cache behavior while disposed connected badges stay unchanged", async (t) => { + const f = fixture(t), + v = f.video("active"), + old = f.feed([v]); + old.observer.show(); + await flush(); + old.dispose(); + assert.equal(f.media[0].src, v.url); + f.done(f.media[0]); + await flush(); + assert.equal(cache.getCachedUrlHealth(v.id, v.url).status, "healthy"); + assert.equal(old.badges[0].dataset.urlHealthState, "checking"); + const live = f.feed([v]); + live.observer.show(); + await flush(); + assert.equal(live.badges[0].dataset.urlHealthState, "healthy"); + assert.equal(f.heads.length, 1); +}); + +test("hidden URL-only card probes eagerly and reveals after healthy media confirmation", async (t) => { + const f = fixture(t); + setCardLivenessPolicy("hide-all"); + const live = f.feed([f.video("hidden")]); + assert.equal(live.cards[0].hidden, true); + await flush(); + assert.equal(f.heads.length, 1); + assert.equal(f.media.length, 1); + f.done(f.media[0]); + await flush(); + assert.equal(live.cards[0].hidden, false); + assert.equal(live.cards[0].dataset.sourceVisibility, "visible"); +}); + +test("three-media FIFO limit holds while a freed slot and a newly visible card compete", async (t) => { + const f = fixture(t), + old = await saturated(f); + const extra = f.feed([f.video("extra")]); + f.done(f.media[0]); + extra.observer.show(); + await flush(); + assert.equal(f.media.length, 4); + assert.equal(f.media[3].src, old.videos[3].url); + assert.equal(f.controller.activeVideoProbes, 3); + f.done(f.media[1]); + await flush(); + assert.equal(f.media.length, 5); + assert.equal(f.media[4].src, extra.videos[0].url); + assert.equal(f.controller.activeVideoProbes, 3); +}); + +test("live mirrors stay ordered and stop after first playable source", async (t) => { + const f = fixture(t), + v = f.video("primary"); + v.sources = [ + { url: "https://mirror.invalid/one.mp4" }, + { url: "https://mirror.invalid/two.mp4" } + ]; + const live = f.feed([v]); + live.observer.show(); + await flush(); + assert.equal(f.heads.length, 1); + assert.equal(f.heads[0].source, v.url); + f.done(f.media[0], "error"); + await flush(); + assert.equal(f.heads.length, 2); + assert.equal(f.heads[1].source, v.sources[0].url); + f.done(f.media[1]); + await flush(); + assert.equal(f.heads.length, 2); + assert.equal(live.badges[0].dataset.urlHealthState, "healthy"); +}); + +test("legacy direct callback consumers without observer ownership still settle and paint", async (t) => { + const f = fixture(t), + v = f.video("legacy"), + badge = document.createElement("span"); + document.body.append(badge); + f.controller.handleUrlHealthBadge({ video: v, url: v.url, badgeEl: badge }); + await flush(); + f.done(f.media[0]); + await flush(); + assert.equal(badge.dataset.urlHealthState, "healthy"); +}); + +test("shared inflight ownership also survives a second controller using the same cache", async (t) => { + const f = fixture(t), + old = await saturated(f), + v = old.videos[3]; + const controller = new UrlHealthController({ + state: cache, + logger: { warn() {} } + }); + // Preserve the production observer callback shape while selecting another controller. + const container = document.createElement("section"), + card = document.createElement("article"), + badge = document.createElement("span"); + card.className = "card"; + card.dataset.videoId = v.id; + card.dataset.urlHealthEventId = v.id; + card.dataset.urlHealthUrl = encodeURIComponent(v.url); + badge.dataset.urlHealthState = "checking"; + card.append(badge); + container.append(card); + document.body.append(container); + const dispose = attachUrlHealthBadges(container, ({ badgeEl, url }) => + controller.handleUrlHealthBadge({ video: v, url, badgeEl }) + ); + t.after(dispose); + // The fixture observer class records new observations on the returned object. + controller.handleUrlHealthBadge({ video: v, url: v.url, badgeEl: badge }); + old.dispose(); + f.done(f.media[0]); + await flush(); + assert.equal(f.media.length, 4); + f.done(f.media[3]); + await flush(); + assert.equal(badge.dataset.urlHealthState, "healthy"); + assert.equal(f.heads.length, 4); +});