From 8ede6a8adfe999d8ea1eb1fbfdc5931ab8da5e05 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:29:03 -0500 Subject: [PATCH] Phase 1: preview contracts and pure session core, 61 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/lib/preview/ — framework-free ESM modules, per design §12–14: - contracts.js: versioned manifest/item/source-ref factories; signed URLs structurally excluded from manifests; Apple identity first-class. - lease.js: discriminated MediaLease union; computeSafeForMs derives lifetimes provider-clock-first with fallback/contradiction handling (margins are ADRs backed by Phase 0 soak evidence). - redact.js: URL redaction (host+hash+exp only) and the HTTPS + exact host allowlist gate applied to initial URLs and every redirect. - manifest.js: artist (rank order, exact-id dedupe), album (disc/track sort, context inheritance) and fixture builders — pure, no fetching. - session.js: reducer with session/generation guards on every async action; keyed active item with derived playable indices/cursor; auto-advance under fresh generations; close is terminal. - resolver.js: PreviewResolver contract + scripted fixture resolver with classified failures (stale_lease/not_found/network/…). - transport.js: transport seam + deterministic fake (manual ticks). - focus.js: revision gateway (all main-player entry points; focus- internal tag) and the coordinator — acquisition only via a generation-bound silence ack, restore only with revisions unchanged. tests/preview/*.test.mjs run via `npm test` (node --test): 61 passing, covering the full Phase 1 list in IMPLEMENTATION-PLAN §6 including the exit gate: stale lease/play events are inert, no restoration after any simulated main command, and no component renders anywhere. --- package.json | 5 +- src/lib/preview/contracts.js | 125 ++++++++++++++ src/lib/preview/focus.js | 150 +++++++++++++++++ src/lib/preview/lease.js | 116 +++++++++++++ src/lib/preview/manifest.js | 130 +++++++++++++++ src/lib/preview/redact.js | 64 +++++++ src/lib/preview/resolver.js | 63 +++++++ src/lib/preview/session.js | 277 +++++++++++++++++++++++++++++++ src/lib/preview/transport.js | 82 +++++++++ tests/preview/focus.test.mjs | 169 +++++++++++++++++++ tests/preview/lease.test.mjs | 124 ++++++++++++++ tests/preview/manifest.test.mjs | 122 ++++++++++++++ tests/preview/session.test.mjs | 212 +++++++++++++++++++++++ tests/preview/transport.test.mjs | 85 ++++++++++ 14 files changed, 1722 insertions(+), 2 deletions(-) create mode 100644 src/lib/preview/contracts.js create mode 100644 src/lib/preview/focus.js create mode 100644 src/lib/preview/lease.js create mode 100644 src/lib/preview/manifest.js create mode 100644 src/lib/preview/redact.js create mode 100644 src/lib/preview/resolver.js create mode 100644 src/lib/preview/session.js create mode 100644 src/lib/preview/transport.js create mode 100644 tests/preview/focus.test.mjs create mode 100644 tests/preview/lease.test.mjs create mode 100644 tests/preview/manifest.test.mjs create mode 100644 tests/preview/session.test.mjs create mode 100644 tests/preview/transport.test.mjs diff --git a/package.json b/package.json index eed9337..aa21c54 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,12 @@ "name": "soulstream", "version": "0.1.0", "type": "module", - "description": "SoulStream — music player, downloader and streamer for the Soulseek network", + "description": "SoulStream \u2014 music player, downloader and streamer for the Soulseek network", "scripts": { "dev": "vite", "build": "vite build", - "tauri": "tauri" + "tauri": "tauri", + "test": "node --test tests/preview/*.test.mjs" }, "license": "ISC", "devDependencies": { diff --git a/src/lib/preview/contracts.js b/src/lib/preview/contracts.js new file mode 100644 index 0000000..fea14dd --- /dev/null +++ b/src/lib/preview/contracts.js @@ -0,0 +1,125 @@ +// Versioned data contracts for the preview feature — design §12, schema v1. +// +// Plain frozen objects, framework-free, so the whole session core is +// unit-testable under node:test with no renderer. The reducer and manifest +// builders depend on THIS file's shapes, never on raw CatalogTrack rows. + +export const MANIFEST_SCHEMA_VERSION = 1; + +export const AVAILABILITY = Object.freeze({ + UNRESOLVED: "unresolved", + AVAILABLE: "available", + NOT_PROVIDED: "not_provided", + UNRELEASED: "unreleased", + REGION_UNAVAILABLE: "region_unavailable", + TEMP_FAILED: "temporarily_failed", +}); + +const CONTEXT_KINDS = new Set(["artist", "album", "playlist", "search", "collection"]); +const PROVIDERS = new Set(["deezer", "apple", "local"]); +const CONFIDENCE = new Set(["native", "isrc", "strong_metadata"]); + +/** + * Stable provider identity for one source of a preview. Signed URLs are NOT + * identity (Phase 0: every fetch mints a different URL); the provider track + * id is. Apple rows keep their REAL provider id here — the backend's + * synthetic-id flattening is a Phase 2 extension, and this contract is the + * reason it can't silently regress. + */ +export function makeSourceRef({ provider, providerTrackId, isrc, matchConfidence = "native" }) { + if (!PROVIDERS.has(provider)) throw new Error(`unknown provider ${provider}`); + if (!CONFIDENCE.has(matchConfidence)) throw new Error(`unknown confidence ${matchConfidence}`); + const id = String(providerTrackId ?? ""); + if (!id) throw new Error("providerTrackId required"); + return Object.freeze({ provider, providerTrackId: id, isrc, matchConfidence }); +} + +export function makeContext({ kind, contextKey, title, subtitle, artwork, sourceTrackCount }) { + if (!CONTEXT_KINDS.has(kind)) throw new Error(`unknown context kind ${kind}`); + if (!contextKey) throw new Error("contextKey required"); + return Object.freeze({ + kind, + contextKey: String(contextKey), + title: title ?? "", + subtitle, + artwork, + sourceTrackCount: sourceTrackCount | 0, + }); +} + +/** + * One occurrence in a manifest. `sessionKey` is unique per occurrence (so + * playlist duplicates stay distinct); `recordingKey` ties repeated + * occurrences of the same recording together for shared action state. + */ +export function makeItem({ + sessionKey, + sourceOrdinal, + discNumber, + trackNumber, + recordingKey, + title, + artists, + album, + albumId, + artwork, + fullDurationMs, + explicit, + sources, + initialAvailability, + actions, +}) { + if (!sessionKey) throw new Error("sessionKey required"); + if (!Number.isInteger(sourceOrdinal) || sourceOrdinal < 0) { + throw new Error("sourceOrdinal must be a non-negative integer"); + } + const avail = initialAvailability ?? AVAILABILITY.UNRESOLVED; + if (!Object.values(AVAILABILITY).includes(avail)) { + throw new Error(`unknown availability ${avail}`); + } + return Object.freeze({ + sessionKey: String(sessionKey), + sourceOrdinal, + discNumber, + trackNumber, + recordingKey, + title: title ?? "", + artists: Object.freeze(Array.isArray(artists) ? [...artists] : []), + album, + albumId: albumId != null ? String(albumId) : undefined, + artwork, + fullDurationMs, + explicit, + sources: Object.freeze((sources ?? []).map((s) => (Object.isFrozen(s) ? s : makeSourceRef(s)))), + initialAvailability: avail, + actions: Object.freeze(actions ?? { playFull: true, save: true, addToPlaylist: true, openAlbum: true }), + }); +} + +/** Immutable, versioned manifest. `createdAtMs` is supplied by the caller so + * everything here stays pure and replayable in tests. */ +export function makeManifest({ context, items, createdAtMs }) { + return Object.freeze({ + schemaVersion: MANIFEST_SCHEMA_VERSION, + context, + items: Object.freeze([...items]), + createdAtMs, + }); +} + +/** Sanity check used by tests and by the session boundary in dev builds. */ +export function isValidManifest(m) { + if (!m || m.schemaVersion !== MANIFEST_SCHEMA_VERSION) return false; + if (!m.context || !CONTEXT_KINDS.has(m.context.kind)) return false; + if (!Array.isArray(m.items)) return false; + const keys = new Set(); + for (const it of m.items) { + if (!it.sessionKey || keys.has(it.sessionKey)) return false; // dupes forbidden + keys.add(it.sessionKey); + // No signed URL may ride in a manifest — identity is provider ids. + for (const s of it.sources ?? []) { + if (typeof s.url === "string") return false; + } + } + return true; +} diff --git a/src/lib/preview/focus.js b/src/lib/preview/focus.js new file mode 100644 index 0000000..e9a4505 --- /dev/null +++ b/src/lib/preview/focus.js @@ -0,0 +1,150 @@ +// Audio-focus coordinator and main-transport revision gateway — design §10, +// pure logic over injected transports so every rule is testable without +// audio hardware. The native primitive underneath (Player::pause_acked with +// the desired-paused latch) was proven in Phase 0.4. +// +// Invariants enforced here, verbatim from the design: +// - Focus acquisition can NEVER complete from renderer status alone: it +// requires a silence acknowledgement bound to the captured playback +// generation. A stale/wrong-generation ack leaves preview parked. +// - Main resumes on release only if preview caused the pause AND every +// captured revision (user-command, queue, playback) is unchanged. +// - A boolean resumeMainOnClose does not exist, by construction. +// - Coordinator-issued commands are tagged focus-internal so they do not +// invalidate their own token. + +/** + * Revision gateway: the single object every main-player entry point calls + * through. Each user-visible command family bumps its revision; the + * coordinator's own pause/resume pass `{ focusInternal: true }` and bump + * nothing — that is what keeps the token valid across the pause the + * coordinator itself requested. + */ +export function createRevisionGateway() { + const rev = { userCommand: 0, queue: 0, playback: 0 }; + const bump = (k, opts) => { + if (opts?.focusInternal) return; + rev[k] += 1; + }; + return { + // every ordinary entry point maps to exactly one of these: + userPlay: (o) => (bump("userCommand", o), bump("playback", o)), + userPauseResume: (o) => bump("userCommand", o), + userStop: (o) => (bump("userCommand", o), bump("playback", o)), + userSeekOrScratch: (o) => bump("userCommand", o), + queueJumpOrNextPrev: (o) => (bump("userCommand", o), bump("queue", o), bump("playback", o)), + queueMutation: (o) => bump("queue", o), + autoAdvance: (o) => (bump("queue", o), bump("playback", o)), + nativeSourceChange: (o) => bump("playback", o), + snapshot: () => ({ ...rev }), + changedSince: (snap) => + snap.userCommand !== rev.userCommand || snap.queue !== rev.queue || snap.playback !== rev.playback, + }; +} + +/** + * @param {object} deps + * @param {() => {status: string, trackKey: string|null, positionMs: number, + * audible: boolean, playbackGeneration: number}} deps.mainStatus + * Renderer-side view of the main player. Used for the SNAPSHOT only — + * never as proof of silence. + * @param {(generation: number) => Promise<{generation: number, applied: boolean}>} + * deps.requestSilence + * Asks the native transport for an acknowledged pause (pause_acked). + * The returned generation is the one the ack is bound to. + * @param {(snapshot: object, opts: {focusInternal: true}) => void} deps.resumeMain + * Resumes main playback at the snapshot; tagged focus-internal. + * @param {ReturnType} deps.gateway + * @param {number} [deps.ackTimeoutMs] + */ +export function createFocusCoordinator({ mainStatus, requestSilence, resumeMain, gateway, ackTimeoutMs = 2_000 }) { + let owner = "none"; // none | main | preview + let token = null; + + async function acquirePreview() { + const status = mainStatus(); + const revisions = gateway.snapshot(); + + // Main idle or already paused by the user: nothing to silence, and + // nothing to restore later — preview simply takes focus. + if (!status.audible) { + owner = "preview"; + token = Object.freeze({ + id: `focus-${revisions.userCommand}-${revisions.queue}-${revisions.playback}-${status.playbackGeneration}`, + pausedByPreview: false, + snapshot: null, + revisions, + }); + return { granted: true, token }; + } + + // Main is audible: capture the snapshot, then demand the + // generation-bound acknowledgement. No ack, no focus — a renderer + // status flag or a timeout NEVER substitutes for silence. + const snapshot = Object.freeze({ + trackKey: status.trackKey, + positionMs: status.positionMs, + playbackGeneration: status.playbackGeneration, + }); + let ack = null; + try { + ack = await withTimeout(requestSilence(status.playbackGeneration), ackTimeoutMs); + } catch { + ack = null; + } + const ackValid = + !!ack && ack.applied === true && ack.generation === snapshot.playbackGeneration; + if (!ackValid || gateway.changedSince(revisions)) { + // Stale generation, timeout, or the user acted mid-negotiation: + // preview stays parked; the caller shows the transient wait state. + return { granted: false, token: null }; + } + owner = "preview"; + token = Object.freeze({ + id: `focus-${revisions.userCommand}-${revisions.queue}-${revisions.playback}-${snapshot.playbackGeneration}`, + pausedByPreview: true, + snapshot, + revisions, + }); + return { granted: true, token }; + } + + /** Release focus. Main resumes ONLY when preview caused the pause and no + * revision moved since the snapshot — otherwise audio stays idle. */ + function release(t) { + if (!t || t !== token) return { restored: false, reason: "stale_token" }; + owner = "none"; + token = null; + if (!t.pausedByPreview) return { restored: false, reason: "main_was_not_audible" }; + if (gateway.changedSince(t.revisions)) return { restored: false, reason: "revisions_changed" }; + resumeMain(t.snapshot, { focusInternal: true }); + owner = "main"; + return { restored: true }; + } + + /** Play-full-track handoff: ownership moves to ordinary playback and the + * old snapshot is intentionally discarded — never restored. */ + function handoffToMain() { + owner = "main"; + token = null; + return { restored: false, reason: "handoff" }; + } + + return { + acquirePreview, + release, + handoffToMain, + owner: () => owner, + currentToken: () => token, + }; +} + +function withTimeout(promise, ms) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error("ack timeout")), ms); + promise.then( + (v) => (clearTimeout(t), resolve(v)), + (e) => (clearTimeout(t), reject(e)) + ); + }); +} diff --git a/src/lib/preview/lease.js b/src/lib/preview/lease.js new file mode 100644 index 0000000..42f067d --- /dev/null +++ b/src/lib/preview/lease.js @@ -0,0 +1,116 @@ +// Ephemeral media leases — the discriminated union from design §12. +// +// A lease is the resolver's answer to "how do I play this item RIGHT NOW": +// either a transport-private URL (webview) or an opaque native handle, plus +// a conservative `safeForMs` after which the transport must re-resolve. +// Leases are never persisted, never used as cache identity, and never logged +// in full (see redact.js). + +/** + * Safety margin subtracted from every derived lifetime. ADR (Phase 0): leases + * hard-403 after their signed exp with NO grace observed (206 at +14 min + * against a +14.97 exp, 403 by +17.5). 20 s covers request latency, decoder + * spin-up and one settle-window retry without meaningfully shortening a + * 13-minute lease. + */ +export const LEASE_SAFETY_MARGIN_MS = 20_000; + +/** + * Fallback lifetime when provider time data is missing, malformed or + * contradictory. Deliberately short: Phase 0 never observed a lease younger + * than ~13 min, so 60 s is far inside any real lifetime while still letting + * a clip play out (clips are 30 s) before a forced refresh. + */ +export const LEASE_FALLBACK_SAFE_MS = 60_000; + +/** Longest lifetime we will ever trust, however healthy the inputs look — + * a parse artifact claiming hours must not disable refresh. */ +export const LEASE_MAX_SAFE_MS = 30 * 60_000; + +/** + * Derive `safeForMs` from provider time data, per design §12: + * conservative, provider-clock based, never trusting any single input. + * + * @param {object} t + * @param {number|null} t.expEpochMs parsed signed expiry (provider clock) + * @param {number|null} t.dateHeaderMs provider `Date` header (provider clock) + * @param {number} t.localNowMs local wall clock at receipt + * @returns {number} milliseconds the lease may be used, >= 0 + */ +export function computeSafeForMs({ expEpochMs, dateHeaderMs, localNowMs }) { + const bad = + !Number.isFinite(expEpochMs) || + !Number.isFinite(localNowMs) || + expEpochMs <= 0; + if (bad) return LEASE_FALLBACK_SAFE_MS; + + // Remaining lifetime on the provider's own clock when both ends of it + // are known — immune to local clock skew entirely. + let remaining = null; + if (Number.isFinite(dateHeaderMs) && dateHeaderMs > 0) { + remaining = expEpochMs - dateHeaderMs; + // Contradictory provider data (exp at/before Date, or a lifetime far + // beyond anything the provider actually issues) → fallback. + if (remaining <= 0 || remaining > 2 * LEASE_MAX_SAFE_MS) { + return LEASE_FALLBACK_SAFE_MS; + } + } + + // Local-clock view. If the local clock is skewed AHEAD of the provider, + // this is smaller than `remaining` and correctly wins (we refresh early). + // If skewed BEHIND, `remaining` wins and the local view can't extend it. + const localView = expEpochMs - localNowMs; + if (localView <= 0 && remaining === null) return LEASE_FALLBACK_SAFE_MS; + + const usable = + remaining === null ? localView : Math.min(remaining, localView > 0 ? localView : remaining); + const safe = Math.min(usable, LEASE_MAX_SAFE_MS) - LEASE_SAFETY_MARGIN_MS; + return Math.max(0, safe); +} + +/** Build a native-transport lease (the ratified transport). */ +export function makeNativeLease({ source, opaqueLeaseId, safeForMs, mimeType, clipDurationMs, byteLength, supportsRanges }) { + if (typeof opaqueLeaseId !== "string" || !opaqueLeaseId) { + throw new Error("native lease requires opaqueLeaseId"); + } + return Object.freeze({ + transport: "native", + source, + opaqueLeaseId, + safeForMs, + mimeType, + clipDurationMs, + byteLength, + supportsRanges, + }); +} + +/** Build a webview-transport lease (documented fallback transport). */ +export function makeWebviewLease({ source, url, safeForMs, mimeType, clipDurationMs, byteLength, supportsRanges }) { + if (typeof url !== "string" || !url) { + throw new Error("webview lease requires url"); + } + return Object.freeze({ + transport: "webview", + source, + url, + safeForMs, + mimeType, + clipDurationMs, + byteLength, + supportsRanges, + }); +} + +/** Union validity: exactly one locator, matching the transport tag. */ +export function isValidLease(lease) { + if (!lease || typeof lease !== "object") return false; + if (!Number.isFinite(lease.safeForMs) || lease.safeForMs < 0) return false; + if (lease.transport === "native") { + return typeof lease.opaqueLeaseId === "string" && lease.opaqueLeaseId !== "" && lease.url === undefined; + } + if (lease.transport === "webview") { + return typeof lease.url === "string" && lease.url !== "" && lease.opaqueLeaseId === undefined; + } + return false; +} diff --git a/src/lib/preview/manifest.js b/src/lib/preview/manifest.js new file mode 100644 index 0000000..29feb69 --- /dev/null +++ b/src/lib/preview/manifest.js @@ -0,0 +1,130 @@ +// Manifest builders — design §13. Inputs are the shapes the app ALREADY +// holds (CatalogTrack rows from the artist page / album page); output is the +// immutable PreviewManifest. No fetching happens here, ever: builders are +// pure so the artist adapter can never become a discography crawler. + +import { AVAILABILITY, makeContext, makeItem, makeManifest, makeSourceRef } from "./contracts.js"; + +/** Availability a catalog row can declare up front. The `preview` URL itself + * is treated as one-bit evidence ("a preview exists") and then DISCARDED — + * it expires in minutes and identity is the provider track id. */ +function availabilityFromCatalogTrack(t) { + if (t.unreleased) return AVAILABILITY.UNRELEASED; + if (typeof t.preview === "string" && t.preview.length > 0) return AVAILABILITY.AVAILABLE; + return AVAILABILITY.NOT_PROVIDED; +} + +function itemFromCatalogTrack(t, ordinal, inherit = {}) { + return makeItem({ + sessionKey: `s${ordinal}-dz${t.id}`, + sourceOrdinal: ordinal, + discNumber: t.disk_number ?? undefined, + trackNumber: t.track_no ?? undefined, + recordingKey: `dz:${t.id}`, + title: t.title ?? "", + artists: [t.artist || inherit.artist || ""].filter(Boolean), + album: t.album || inherit.album, + albumId: t.album_id ?? inherit.albumId, + artwork: t.cover ?? inherit.artwork, + fullDurationMs: Number.isFinite(t.duration_secs) ? t.duration_secs * 1000 : undefined, + sources: [makeSourceRef({ provider: "deezer", providerTrackId: t.id, matchConfidence: "native" })], + initialAvailability: availabilityFromCatalogTrack(t), + actions: { playFull: !t.unreleased, save: !t.unreleased, addToPlaylist: !t.unreleased, openAlbum: t.album_id != null || inherit.albumId != null }, + }); +} + +/** + * Artist adapter: the COMPLETE ranked top-track payload (never the five + * rendered rows), rank order preserved, collapsing only exact repeated + * provider ids — repetition adds no ordering meaning in a top list. + */ +export function buildArtistManifest({ artist, popularTracks, createdAtMs }) { + const seen = new Set(); + const items = []; + for (const t of popularTracks ?? []) { + const key = `dz:${t.id}`; + if (seen.has(key)) continue; // exact provider-id dupe only + seen.add(key); + items.push(itemFromCatalogTrack(t, items.length, { artist: artist?.name, artwork: artist?.picture })); + } + return makeManifest({ + context: makeContext({ + kind: "artist", + contextKey: `artist:${artist?.id ?? "unknown"}`, + title: artist?.name ?? "", + subtitle: "Top tracks", + artwork: artist?.picture, + sourceTrackCount: (popularTracks ?? []).length, + }), + items, + createdAtMs, + }); +} + +/** + * Catalog album adapter: disc/track order (then stable response order), + * context metadata inherited by rows whose track endpoint omitted nested + * album data, unavailable tracks preserved in source counts. + */ +export function buildAlbumManifest({ album, tracks, createdAtMs }) { + const rows = (tracks ?? []) + .map((t, i) => ({ t, i })) + .sort((a, b) => + (a.t.disk_number ?? 1) - (b.t.disk_number ?? 1) || + (a.t.track_no ?? 0) - (b.t.track_no ?? 0) || + a.i - b.i + ); + const inherit = { + artist: album?.artist, + album: album?.title, + albumId: album?.id, + artwork: album?.cover, + }; + return makeManifest({ + context: makeContext({ + kind: "album", + contextKey: `album:${album?.id ?? "unknown"}`, + title: album?.title ?? "", + subtitle: album?.artist, + artwork: album?.cover, + sourceTrackCount: (tracks ?? []).length, + }), + items: rows.map(({ t }, ordinal) => itemFromCatalogTrack(t, ordinal, inherit)), + createdAtMs, + }); +} + +/** + * Generic fixture adapter for tests and future surfaces (playlists keep + * duplicates: occurrences get distinct sessionKeys, shared recordingKey). + */ +export function buildFixtureManifest({ kind = "collection", contextKey = "fixture", title = "Fixture", rows, createdAtMs }) { + const items = (rows ?? []).map((r, ordinal) => + makeItem({ + sessionKey: `s${ordinal}-${r.provider ?? "deezer"}${r.providerTrackId}`, + sourceOrdinal: ordinal, + recordingKey: `${r.provider ?? "deezer"}:${r.providerTrackId}`, + title: r.title ?? "", + artists: r.artists ?? [], + album: r.album, + albumId: r.albumId, + artwork: r.artwork, + fullDurationMs: r.fullDurationMs, + explicit: r.explicit, + sources: [ + makeSourceRef({ + provider: r.provider ?? "deezer", + providerTrackId: r.providerTrackId, + isrc: r.isrc, + matchConfidence: r.matchConfidence ?? "native", + }), + ], + initialAvailability: r.initialAvailability ?? AVAILABILITY.AVAILABLE, + }) + ); + return makeManifest({ + context: makeContext({ kind, contextKey, title, sourceTrackCount: items.length }), + items, + createdAtMs, + }); +} diff --git a/src/lib/preview/redact.js b/src/lib/preview/redact.js new file mode 100644 index 0000000..7257f5b --- /dev/null +++ b/src/lib/preview/redact.js @@ -0,0 +1,64 @@ +// URL redaction and provider host policy for the preview feature. +// +// Signed preview URLs are ephemeral secrets: they carry an hmac and expire in +// minutes (Phase 0: 13–15 min, then 403). They must never appear in logs, +// session persistence, or diagnostics. Everything that wants to talk about a +// URL goes through redactUrl() and gets host + a stable hash + the parsed +// expiry — enough to correlate, nothing to replay. + +/** Providers the preview resolver may fetch media from. HTTPS only; exact + * host match — no subdomain wildcards, because the CDN host is a single + * stable name (Phase 0: 71/71 samples on cdnt-preview.dzcdn.net). */ +export const MEDIA_HOST_ALLOWLIST = Object.freeze([ + "cdnt-preview.dzcdn.net", + // Apple preview hosts land here in Phase 2 when Apple identity is + // plumbed through the backend; adding a host is a reviewed change. +]); + +/** Parse the signed expiry out of a preview URL. Deezer uses an Akamai + * `hdnea=exp=~…` token; a bare `exp=` query also matches. + * Returns epoch MILLISECONDS or null — never throws. */ +export function parseSignedExpiryMs(url) { + if (typeof url !== "string") return null; + const m = /(?:^|[?&~])(?:hdnea=)?exp=(\d{9,11})(?:[~&]|$)/.exec(url); + if (!m) return null; + return Number(m[1]) * 1000; +} + +/** FNV-1a — tiny, stable, dependency-free. Not cryptographic and does not + * need to be: the hash only correlates log lines about the same URL. */ +function fnv1a(str) { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(16).padStart(8, "0"); +} + +/** The only representation of a media URL allowed outside the transport: + * `{ host, hash, expMs }`. */ +export function redactUrl(url) { + if (typeof url !== "string" || url.length === 0) { + return { host: null, hash: null, expMs: null }; + } + let host = null; + try { + host = new URL(url).host; + } catch { + /* malformed: host stays null, hash still correlates */ + } + return { host, hash: fnv1a(url), expMs: parseSignedExpiryMs(url) }; +} + +/** True only for an https URL whose host is on the media allowlist. Used for + * the initial URL and re-checked for every redirect target — a redirect off + * the allowlist is a hard failure, not a follow. */ +export function isAllowedMediaUrl(url) { + try { + const u = new URL(url); + return u.protocol === "https:" && MEDIA_HOST_ALLOWLIST.includes(u.host); + } catch { + return false; + } +} diff --git a/src/lib/preview/resolver.js b/src/lib/preview/resolver.js new file mode 100644 index 0000000..a67a022 --- /dev/null +++ b/src/lib/preview/resolver.js @@ -0,0 +1,63 @@ +// PreviewResolver contract (design §14) plus the fixture implementation the +// modal is built against in Phase 2 before the production resolver exists. +// +// Contract: resolve(sourceRef, {generation, bypassCache}) → Promise of a +// discriminated result: +// { ok: true, lease: MediaLease, generation } +// { ok: false, failure: , generation, retryable } +// +// Failure classes mirror what Phase 0 actually observed: +// "stale_lease" — 403 from the CDN (signed exp passed). Retryable once +// with bypassCache: the API mints a fresh URL per fetch. +// "not_provided" — provider answered but has no preview (empty field). +// "not_found" — HTTP-200 DataException body (Deezer's error shape). +// "network" — timeout / transport error. Retryable with backoff. +// "invalid_media" — response wasn't audio/mpeg (e.g. the Akamai HTML +// error page) or failed the decoder sniff. +// The resolver NEVER loops providers and never retries without a cap — +// callers get one bypassCache retry per user intent, then a failure state. + +import { isValidLease, makeNativeLease } from "./lease.js"; + +/** + * Fixture resolver for tests and Phase 2 UI bring-up. `plan` maps + * providerTrackId → either a lease-ish object or {failure, retryable} or a + * function(callCount, opts) returning one of those, so tests can script + * "fail once, then succeed" or artificial latency deterministically. + */ +export function createFixtureResolver(plan, { defaultSafeForMs = 300_000 } = {}) { + const calls = new Map(); // providerTrackId -> count + + return { + async resolve(sourceRef, { generation, bypassCache = false } = {}) { + const id = sourceRef.providerTrackId; + const n = (calls.get(id) ?? 0) + 1; + calls.set(id, n); + + let spec = plan[id]; + if (typeof spec === "function") spec = spec(n, { bypassCache }); + if (spec && typeof spec.then === "function") spec = await spec; + + if (!spec) { + return { ok: false, failure: "not_found", generation, retryable: false }; + } + if (spec.failure) { + return { ok: false, failure: spec.failure, generation, retryable: !!spec.retryable }; + } + const lease = makeNativeLease({ + source: sourceRef, + opaqueLeaseId: spec.opaqueLeaseId ?? `fixture-${id}-${n}`, + safeForMs: spec.safeForMs ?? defaultSafeForMs, + mimeType: "audio/mpeg", + clipDurationMs: spec.clipDurationMs ?? 30_000, + byteLength: spec.byteLength ?? 480_000, + supportsRanges: true, + }); + if (!isValidLease(lease)) throw new Error("fixture produced invalid lease"); + return { ok: true, lease, generation }; + }, + callCount(id) { + return calls.get(id) ?? 0; + }, + }; +} diff --git a/src/lib/preview/session.js b/src/lib/preview/session.js new file mode 100644 index 0000000..154b31f --- /dev/null +++ b/src/lib/preview/session.js @@ -0,0 +1,277 @@ +// Pure preview-session reducer — design §12 runtime state, §9 navigation +// invariants. No timers, no I/O, no Date.now(): every action carries what it +// needs, so the whole machine replays deterministically under node:test. +// +// THE one rule everything else hangs off: any action produced by async work +// (resolver, transport) carries {sessionId, generation}, and the reducer +// drops it unless BOTH match the current state. A late lease from generation +// 3 can never start audio under the cover generation 4 is showing. + +import { AVAILABILITY } from "./contracts.js"; + +export const PHASE = Object.freeze({ + RESOLVING_MANIFEST: "resolving_manifest", + READY: "ready", + RESOLVING_MEDIA: "resolving_media", + BUFFERING: "buffering", + PLAYING: "playing", + PAUSED: "paused", + ENDED: "ended", + ITEM_FAILED: "item_failed", + EMPTY: "empty", + CLOSING: "closing", +}); + +export function createInitialState(sessionId) { + if (!sessionId) throw new Error("sessionId required"); + return Object.freeze({ + sessionId, + phase: PHASE.RESOLVING_MANIFEST, + manifest: null, + itemRuntimeByKey: Object.freeze({}), + activeItemKey: null, + activeManifestIndex: null, + requestGeneration: 0, + desiredPlaying: false, + muted: false, + positionMs: 0, + clipDurationMs: undefined, + focusToken: null, + errorClass: undefined, + }); +} + +// ------------------------------------------------------------ derivations -- + +/** Effective availability: runtime overrides the manifest's initial value. */ +export function effectiveAvailability(state, item) { + return state.itemRuntimeByKey[item.sessionKey]?.availability ?? item.initialAvailability; +} + +/** Manifest indices whose items are currently playable, in manifest order. + * Derived every time — never stored, so it can't drift (design §9). */ +export function playableManifestIndices(state) { + if (!state.manifest) return []; + const out = []; + state.manifest.items.forEach((item, i) => { + if (effectiveAvailability(state, item) === AVAILABILITY.AVAILABLE) out.push(i); + }); + return out; +} + +/** Position of the active item inside the playable list, or null when the + * active item is not currently eligible (e.g. it failed and is being shown + * in its error state). The active item is identified by sessionKey + index — + * never by this cursor (design §9: availability changes recompute the cursor + * AROUND the stable item; they never move the item). */ +export function playableCursor(state) { + if (state.activeManifestIndex == null) return null; + const idx = playableManifestIndices(state).indexOf(state.activeManifestIndex); + return idx === -1 ? null : idx; +} + +function itemAt(state, manifestIndex) { + return state.manifest?.items[manifestIndex] ?? null; +} + +// -------------------------------------------------------------- reducer ---- + +function guard(state, action, { needGeneration }) { + if (action.sessionId !== state.sessionId) return false; + if (state.phase === PHASE.CLOSING) return false; // terminal: nothing lands + if (needGeneration && action.generation !== state.requestGeneration) return false; + return true; +} + +function withRuntime(state, sessionKey, patch) { + const prev = state.itemRuntimeByKey[sessionKey] ?? { + availability: undefined, + mediaPhase: "idle", + errorClass: undefined, + }; + return Object.freeze({ + ...state.itemRuntimeByKey, + [sessionKey]: Object.freeze({ ...prev, ...patch }), + }); +} + +/** Activate the item at `manifestIndex`, bumping the generation — the + * common core of first-pick, step, click and auto-advance. */ +function activate(state, manifestIndex, { keepDesired }) { + const item = itemAt(state, manifestIndex); + if (!item) return state; + return Object.freeze({ + ...state, + phase: PHASE.RESOLVING_MEDIA, + activeItemKey: item.sessionKey, + activeManifestIndex: manifestIndex, + requestGeneration: state.requestGeneration + 1, + desiredPlaying: keepDesired ? state.desiredPlaying : state.desiredPlaying, + positionMs: 0, + clipDurationMs: undefined, + errorClass: undefined, + }); +} + +export function reduce(state, action) { + switch (action.type) { + // ---- session lifecycle ------------------------------------------ + case "MANIFEST_READY": { + if (!guard(state, action, { needGeneration: false })) return state; + const next = Object.freeze({ ...state, manifest: action.manifest }); + const playable = playableManifestIndices(next); + if (playable.length === 0) { + return Object.freeze({ ...next, phase: PHASE.EMPTY }); + } + // Opening takes audio (decision 2026-07-22): the first playable + // item becomes active with listen intent already set; the focus + // coordinator upstream decides when sound may actually start. + return activate( + Object.freeze({ ...next, desiredPlaying: true }), + action.startAtManifestIndex != null && playable.includes(action.startAtManifestIndex) + ? action.startAtManifestIndex + : playable[0], + { keepDesired: true } + ); + } + case "CLOSE": { + if (action.sessionId !== state.sessionId) return state; + return Object.freeze({ ...state, phase: PHASE.CLOSING, desiredPlaying: false, focusToken: null }); + } + + // ---- navigation (user intent, current-generation by definition) -- + case "STEP": { + if (!guard(state, action, { needGeneration: false })) return state; + const playable = playableManifestIndices(state); + if (playable.length === 0) return state; + const cur = playableCursor(state); + let target; + if (cur != null) { + target = cur + action.dir; + } else { + // Active item isn't eligible (failed): move to the next + // eligible MANIFEST ordinal in the step direction (design §9). + const from = state.activeManifestIndex ?? -1; + target = + action.dir > 0 + ? playable.findIndex((i) => i > from) + : (() => { + let last = -1; + playable.forEach((i, k) => { + if (i < from) last = k; + }); + return last; + })(); + } + if (target < 0 || target >= playable.length) return state; // no wrap + return activate(state, playable[target], { keepDesired: true }); + } + case "ACTIVATE_KEY": { + if (!guard(state, action, { needGeneration: false })) return state; + const idx = state.manifest?.items.findIndex((it) => it.sessionKey === action.sessionKey) ?? -1; + if (idx === -1) return state; + const item = state.manifest.items[idx]; + if (effectiveAvailability(state, item) !== AVAILABILITY.AVAILABLE) return state; + if (idx === state.activeManifestIndex) return state; + return activate(state, idx, { keepDesired: true }); + } + case "REPLAY": { + if (!guard(state, action, { needGeneration: false })) return state; + if (state.activeManifestIndex == null) return state; + return Object.freeze({ + ...activate(state, state.activeManifestIndex, { keepDesired: true }), + desiredPlaying: true, + }); + } + case "SET_DESIRED_PLAYING": { + if (!guard(state, action, { needGeneration: false })) return state; + return Object.freeze({ ...state, desiredPlaying: !!action.value }); + } + case "SET_MUTED": { + if (!guard(state, action, { needGeneration: false })) return state; + return Object.freeze({ ...state, muted: !!action.value }); + } + case "FOCUS_SET": { + if (!guard(state, action, { needGeneration: false })) return state; + return Object.freeze({ ...state, focusToken: action.token ?? null }); + } + + // ---- async completions (generation-gated) ------------------------ + case "RESOLVE_STARTED": { + if (!guard(state, action, { needGeneration: true })) return state; + if (action.sessionKey !== state.activeItemKey) return state; + return Object.freeze({ + ...state, + itemRuntimeByKey: withRuntime(state, action.sessionKey, { mediaPhase: "resolving" }), + }); + } + case "LEASE_READY": { + if (!guard(state, action, { needGeneration: true })) return state; + if (action.sessionKey !== state.activeItemKey) return state; + return Object.freeze({ + ...state, + phase: state.desiredPlaying ? PHASE.BUFFERING : PHASE.READY, + clipDurationMs: action.clipDurationMs ?? state.clipDurationMs, + itemRuntimeByKey: withRuntime(state, action.sessionKey, { mediaPhase: "ready" }), + }); + } + case "MEDIA_PLAYING": { + if (!guard(state, action, { needGeneration: true })) return state; + return Object.freeze({ ...state, phase: PHASE.PLAYING }); + } + case "MEDIA_PAUSED": { + if (!guard(state, action, { needGeneration: true })) return state; + return Object.freeze({ ...state, phase: PHASE.PAUSED }); + } + case "MEDIA_POSITION": { + if (!guard(state, action, { needGeneration: true })) return state; + return Object.freeze({ ...state, positionMs: action.positionMs }); + } + case "MEDIA_ENDED": { + if (!guard(state, action, { needGeneration: true })) return state; + // Auto-advance (decision 2026-07-22): roll into the next playable + // item under the SAME generation rules — activate() bumps, so any + // straggler event from the finished clip is stale by construction. + const playable = playableManifestIndices(state); + const cur = playableCursor(state); + if (cur != null && cur + 1 < playable.length) { + return activate(state, playable[cur + 1], { keepDesired: true }); + } + return Object.freeze({ ...state, phase: PHASE.ENDED, desiredPlaying: false }); + } + case "ITEM_FAILED": { + if (!guard(state, action, { needGeneration: true })) return state; + const runtime = withRuntime(state, action.sessionKey, { + availability: action.availability ?? AVAILABILITY.TEMP_FAILED, + mediaPhase: "failed", + errorClass: action.errorClass, + }); + const isActive = action.sessionKey === state.activeItemKey; + return Object.freeze({ + ...state, + itemRuntimeByKey: runtime, + // A failed ACTIVE item stays visible in its error state + // (design §9) — no auto-skip, the user retries or steps. + phase: isActive ? PHASE.ITEM_FAILED : state.phase, + errorClass: isActive ? action.errorClass : state.errorClass, + }); + } + + // ---- runtime availability (metadata, NOT generation-gated) ------- + case "AVAILABILITY_CHANGED": { + if (!guard(state, action, { needGeneration: false })) return state; + // Only itemRuntimeByKey changes: a newly available EARLIER item + // must never move the current card (design §9) — and it can't, + // because the active item is keyed, not cursor-positioned. + return Object.freeze({ + ...state, + itemRuntimeByKey: withRuntime(state, action.sessionKey, { + availability: action.availability, + }), + }); + } + + default: + return state; + } +} diff --git a/src/lib/preview/transport.js b/src/lib/preview/transport.js new file mode 100644 index 0000000..d747744 --- /dev/null +++ b/src/lib/preview/transport.js @@ -0,0 +1,82 @@ +// Preview transport interface — the seam between the pure session core and +// whatever actually makes sound. Phase 0 ratified the NATIVE transport; this +// file pins the contract and provides the deterministic fake that Phase 2 +// builds the modal against before the native backend lands. +// +// Contract (all methods take {sessionId, generation} and the transport +// stamps them onto every event it emits — the reducer's guard does the rest): +// +// load(lease, ctx) accept a MediaLease; emit LEASE-side effects only +// play(ctx) request audibility (focus must already be held) +// pause(ctx) park; position is retained +// stop(ctx) discard media; a later play needs a fresh load +// dispose() teardown at session close — must be idempotent +// +// Events are dispatched as reducer actions: MEDIA_PLAYING, MEDIA_PAUSED, +// MEDIA_POSITION, MEDIA_ENDED, ITEM_FAILED. A transport NEVER dispatches +// with a generation other than the one the command carried — stale +// suppression is the reducer's job, honest stamping is the transport's. + +import { isValidLease } from "./lease.js"; + +/** + * Deterministic fake: no timers — the test (or storybook harness) calls + * `tick(ms)` to advance playback. Emits exactly what the native transport + * will emit, so swapping it out changes no session code. + */ +export function createFixtureTransport(dispatch) { + let current = null; // { lease, sessionId, generation, sessionKey, positionMs, playing } + let disposed = false; + + const emit = (type, extra = {}) => { + if (!current) return; + dispatch({ + type, + sessionId: current.sessionId, + generation: current.generation, + ...extra, + }); + }; + + return { + load(lease, { sessionId, generation, sessionKey }) { + if (disposed) throw new Error("transport disposed"); + if (!isValidLease(lease)) { + current = { sessionId, generation, sessionKey, positionMs: 0, playing: false }; + emit("ITEM_FAILED", { sessionKey, errorClass: "invalid_media" }); + current = null; + return; + } + current = { lease, sessionId, generation, sessionKey, positionMs: 0, playing: false }; + }, + play() { + if (!current) return; + current.playing = true; + emit("MEDIA_PLAYING"); + }, + pause() { + if (!current) return; + current.playing = false; + emit("MEDIA_PAUSED"); + }, + stop() { + current = null; + }, + dispose() { + disposed = true; + current = null; + }, + /** Advance fake playback; emits POSITION then ENDED at the clip end. */ + tick(ms) { + if (!current || !current.playing) return; + const clip = current.lease?.clipDurationMs ?? 30_000; + current.positionMs = Math.min(current.positionMs + ms, clip); + emit("MEDIA_POSITION", { positionMs: current.positionMs }); + if (current.positionMs >= clip) { + current.playing = false; + emit("MEDIA_ENDED"); + } + }, + _debugState: () => (current ? { ...current } : null), + }; +} diff --git a/tests/preview/focus.test.mjs b/tests/preview/focus.test.mjs new file mode 100644 index 0000000..959c6ad --- /dev/null +++ b/tests/preview/focus.test.mjs @@ -0,0 +1,169 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { createFocusCoordinator, createRevisionGateway } from "../../src/lib/preview/focus.js"; +import { createFixtureResolver } from "../../src/lib/preview/resolver.js"; + +function harness({ audible = true, ackGeneration, ackApplied = true, ackDelayMs = 0, ackHangs = false } = {}) { + const gateway = createRevisionGateway(); + const resumed = []; + const status = { + status: audible ? "playing" : "idle", + trackKey: "track-A", + positionMs: 102_000, + audible, + playbackGeneration: 7, + }; + const coord = createFocusCoordinator({ + mainStatus: () => ({ ...status }), + requestSilence: (gen) => + ackHangs + ? new Promise(() => {}) + : new Promise((res) => + setTimeout(() => res({ generation: ackGeneration ?? gen, applied: ackApplied }), ackDelayMs) + ), + resumeMain: (snapshot, opts) => resumed.push({ snapshot, opts }), + gateway, + ackTimeoutMs: 50, + }); + return { coord, gateway, resumed, status }; +} + +// ------------------------------------------------------------ acquisition -- + +test("main idle: focus granted without any silence negotiation, nothing to restore", async () => { + const { coord, resumed } = harness({ audible: false }); + const { granted, token } = await coord.acquirePreview(); + assert.ok(granted); + assert.equal(token.pausedByPreview, false); + const rel = coord.release(token); + assert.equal(rel.restored, false); + assert.equal(rel.reason, "main_was_not_audible"); + assert.equal(resumed.length, 0); +}); + +test("main audible: granted only through a generation-bound acknowledgement", async () => { + const { coord } = harness({ audible: true }); + const { granted, token } = await coord.acquirePreview(); + assert.ok(granted); + assert.equal(token.pausedByPreview, true); + assert.equal(token.snapshot.trackKey, "track-A"); + assert.equal(token.snapshot.positionMs, 102_000); +}); + +test("stale/wrong-generation ack leaves preview parked", async () => { + const { coord } = harness({ audible: true, ackGeneration: 3 }); // main is at 7 + const { granted, token } = await coord.acquirePreview(); + assert.equal(granted, false); + assert.equal(token, null); +}); + +test("ack that never arrives (timeout) leaves preview parked — renderer status is not proof", async () => { + const { coord } = harness({ audible: true, ackHangs: true }); + const { granted } = await coord.acquirePreview(); + assert.equal(granted, false); +}); + +test("ack applied=false is not silence", async () => { + const { coord } = harness({ audible: true, ackApplied: false }); + const { granted } = await coord.acquirePreview(); + assert.equal(granted, false); +}); + +test("user command DURING negotiation invalidates the grant", async () => { + const { coord, gateway } = harness({ audible: true, ackDelayMs: 20 }); + const p = coord.acquirePreview(); + gateway.userSeekOrScratch(); // user scrubbed while we negotiated + const { granted } = await p; + assert.equal(granted, false); +}); + +// -------------------------------------------------------------- release ---- + +test("clean release restores main at the snapshot, tagged focus-internal", async () => { + const { coord, gateway, resumed } = harness({ audible: true }); + const { token } = await coord.acquirePreview(); + const rel = coord.release(token); + assert.ok(rel.restored); + assert.equal(resumed.length, 1); + assert.equal(resumed[0].snapshot.trackKey, "track-A"); + assert.equal(resumed[0].snapshot.positionMs, 102_000); + assert.equal(resumed[0].opts.focusInternal, true); + // and the focus-internal resume did not invalidate anything + assert.equal(gateway.changedSince(token.revisions), false); +}); + +for (const [name, bump] of [ + ["user pause/resume", (g) => g.userPauseResume()], + ["user starts another track", (g) => g.userPlay()], + ["queue jump / next / previous", (g) => g.queueJumpOrNextPrev()], + ["queue mutation", (g) => g.queueMutation()], + ["seek/scratch", (g) => g.userSeekOrScratch()], + ["stop", (g) => g.userStop()], + ["auto-advance", (g) => g.autoAdvance()], + ["native source change", (g) => g.nativeSourceChange()], +]) { + test(`no restoration after ${name}`, async () => { + const { coord, gateway, resumed } = harness({ audible: true }); + const { token } = await coord.acquirePreview(); + bump(gateway); + const rel = coord.release(token); + assert.equal(rel.restored, false); + assert.equal(rel.reason, "revisions_changed"); + assert.equal(resumed.length, 0); + }); +} + +test("focus-internal commands do not invalidate their own token", async () => { + const { coord, gateway } = harness({ audible: true }); + const { token } = await coord.acquirePreview(); + gateway.userPauseResume({ focusInternal: true }); // the coordinator's own pause + gateway.userPlay({ focusInternal: true }); + assert.equal(gateway.changedSince(token.revisions), false); + assert.ok(coord.release(token).restored); +}); + +test("play-full handoff never restores the old track", async () => { + const { coord, resumed } = harness({ audible: true }); + await coord.acquirePreview(); + const h = coord.handoffToMain(); + assert.equal(h.restored, false); + assert.equal(h.reason, "handoff"); + assert.equal(resumed.length, 0); + assert.equal(coord.owner(), "main"); +}); + +test("a stale token cannot release anything", async () => { + const { coord } = harness({ audible: true }); + const { token } = await coord.acquirePreview(); + coord.release(token); + const again = coord.release(token); + assert.equal(again.restored, false); + assert.equal(again.reason, "stale_token"); +}); + +// ------------------------------------------------------------- resolver ---- + +test("fixture resolver: scripted fail-once-then-succeed with bypassCache", async () => { + const src = { provider: "deezer", providerTrackId: "42", matchConfidence: "native" }; + const resolver = createFixtureResolver({ + 42: (n) => (n === 1 ? { failure: "stale_lease", retryable: true } : { opaqueLeaseId: "op-fresh" }), + }); + const first = await resolver.resolve(src, { generation: 5 }); + assert.equal(first.ok, false); + assert.equal(first.failure, "stale_lease"); + assert.ok(first.retryable); + const second = await resolver.resolve(src, { generation: 5, bypassCache: true }); + assert.ok(second.ok); + assert.equal(second.lease.transport, "native"); + assert.equal(second.lease.opaqueLeaseId, "op-fresh"); + assert.equal(resolver.callCount("42"), 2); +}); + +test("fixture resolver: unknown id classifies as not_found, not an exception", async () => { + const resolver = createFixtureResolver({}); + const r = await resolver.resolve({ provider: "deezer", providerTrackId: "999", matchConfidence: "native" }, { generation: 1 }); + assert.equal(r.ok, false); + assert.equal(r.failure, "not_found"); + assert.equal(r.retryable, false); +}); diff --git a/tests/preview/lease.test.mjs b/tests/preview/lease.test.mjs new file mode 100644 index 0000000..bec5ced --- /dev/null +++ b/tests/preview/lease.test.mjs @@ -0,0 +1,124 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + computeSafeForMs, + makeNativeLease, + makeWebviewLease, + isValidLease, + LEASE_FALLBACK_SAFE_MS, + LEASE_SAFETY_MARGIN_MS, + LEASE_MAX_SAFE_MS, +} from "../../src/lib/preview/lease.js"; +import { redactUrl, parseSignedExpiryMs, isAllowedMediaUrl } from "../../src/lib/preview/redact.js"; + +const SRC = Object.freeze({ provider: "deezer", providerTrackId: "1", matchConfidence: "native" }); +const NOW = 1_784_760_000_000; +const MIN = 60_000; + +// ---------------------------------------------------------- safeForMs ----- + +test("valid exp + Date: provider-clock lifetime minus margin", () => { + const safe = computeSafeForMs({ expEpochMs: NOW + 14 * MIN, dateHeaderMs: NOW, localNowMs: NOW }); + assert.equal(safe, 14 * MIN - LEASE_SAFETY_MARGIN_MS); +}); + +test("local clock AHEAD of provider: the shorter local view wins", () => { + const safe = computeSafeForMs({ + expEpochMs: NOW + 14 * MIN, + dateHeaderMs: NOW, + localNowMs: NOW + 10 * MIN, // local thinks only 4 min remain + }); + assert.equal(safe, 4 * MIN - LEASE_SAFETY_MARGIN_MS); +}); + +test("local clock BEHIND provider: local view cannot extend the lease", () => { + const safe = computeSafeForMs({ + expEpochMs: NOW + 14 * MIN, + dateHeaderMs: NOW, + localNowMs: NOW - 60 * MIN, // local view claims 74 min + }); + assert.equal(safe, 14 * MIN - LEASE_SAFETY_MARGIN_MS); +}); + +test("missing exp → short capped fallback", () => { + assert.equal(computeSafeForMs({ expEpochMs: null, dateHeaderMs: NOW, localNowMs: NOW }), LEASE_FALLBACK_SAFE_MS); +}); + +test("malformed exp → fallback", () => { + assert.equal(computeSafeForMs({ expEpochMs: NaN, dateHeaderMs: NOW, localNowMs: NOW }), LEASE_FALLBACK_SAFE_MS); +}); + +test("contradictory: exp before Date → fallback", () => { + assert.equal( + computeSafeForMs({ expEpochMs: NOW - MIN, dateHeaderMs: NOW, localNowMs: NOW }), + LEASE_FALLBACK_SAFE_MS + ); +}); + +test("contradictory: absurd multi-hour lifetime → fallback", () => { + assert.equal( + computeSafeForMs({ expEpochMs: NOW + 6 * 60 * MIN, dateHeaderMs: NOW, localNowMs: NOW }), + LEASE_FALLBACK_SAFE_MS + ); +}); + +test("missing Date but sane local view → local view minus margin, capped", () => { + const safe = computeSafeForMs({ expEpochMs: NOW + 14 * MIN, dateHeaderMs: null, localNowMs: NOW }); + assert.equal(safe, 14 * MIN - LEASE_SAFETY_MARGIN_MS); + const capped = computeSafeForMs({ expEpochMs: NOW + 90 * MIN, dateHeaderMs: null, localNowMs: NOW }); + assert.equal(capped, LEASE_MAX_SAFE_MS - LEASE_SAFETY_MARGIN_MS); +}); + +test("already expired with no Date → fallback (refresh immediately-ish, never negative)", () => { + assert.equal( + computeSafeForMs({ expEpochMs: NOW - MIN, dateHeaderMs: null, localNowMs: NOW }), + LEASE_FALLBACK_SAFE_MS + ); +}); + +// ------------------------------------------------------ union validity ---- + +test("discriminated union: native carries exactly an opaque id", () => { + const lease = makeNativeLease({ source: SRC, opaqueLeaseId: "op-1", safeForMs: 1000 }); + assert.ok(isValidLease(lease)); + assert.equal(lease.url, undefined); + assert.ok(!isValidLease({ ...lease, url: "https://x" })); +}); + +test("discriminated union: webview carries exactly a url", () => { + const lease = makeWebviewLease({ source: SRC, url: "https://cdnt-preview.dzcdn.net/a.mp3", safeForMs: 1000 }); + assert.ok(isValidLease(lease)); + assert.equal(lease.opaqueLeaseId, undefined); + assert.ok(!isValidLease({ ...lease, opaqueLeaseId: "x" })); +}); + +test("constructors refuse the wrong locator", () => { + assert.throws(() => makeNativeLease({ source: SRC, safeForMs: 1 })); + assert.throws(() => makeWebviewLease({ source: SRC, safeForMs: 1 })); +}); + +// ------------------------------------------------------------ redaction --- + +test("redactUrl yields host + hash + exp, never the url", () => { + const url = "https://cdnt-preview.dzcdn.net/api/x.mp3?hdnea=exp=1784761537~acl=/x*~hmac=deadbeef"; + const r = redactUrl(url); + assert.equal(r.host, "cdnt-preview.dzcdn.net"); + assert.equal(r.expMs, 1784761537000); + assert.match(r.hash, /^[0-9a-f]{8}$/); + assert.ok(!JSON.stringify(r).includes("hmac")); +}); + +test("parseSignedExpiryMs handles bare exp params and garbage", () => { + assert.equal(parseSignedExpiryMs("https://h/x?exp=1784761537"), 1784761537000); + assert.equal(parseSignedExpiryMs("https://h/x"), null); + assert.equal(parseSignedExpiryMs(null), null); +}); + +test("host policy: https + exact allowlist, redirects re-checked by the same gate", () => { + assert.ok(isAllowedMediaUrl("https://cdnt-preview.dzcdn.net/a.mp3")); + assert.ok(!isAllowedMediaUrl("http://cdnt-preview.dzcdn.net/a.mp3")); // scheme + assert.ok(!isAllowedMediaUrl("https://evil.dzcdn.net/a.mp3")); // host + assert.ok(!isAllowedMediaUrl("https://cdnt-preview.dzcdn.net.evil.com/a.mp3")); // suffix trick + assert.ok(!isAllowedMediaUrl("not a url")); +}); diff --git a/tests/preview/manifest.test.mjs b/tests/preview/manifest.test.mjs new file mode 100644 index 0000000..472f54d --- /dev/null +++ b/tests/preview/manifest.test.mjs @@ -0,0 +1,122 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { AVAILABILITY, isValidManifest } from "../../src/lib/preview/contracts.js"; +import { buildArtistManifest, buildAlbumManifest, buildFixtureManifest } from "../../src/lib/preview/manifest.js"; + +const T0 = 1_700_000_000_000; + +const catalogTrack = (id, over = {}) => ({ + id, + title: `Track ${id}`, + artist: "Röyksopp", + album: "Melody A.M.", + album_id: 9, + cover: `cover-${id}`, + duration_secs: 219, + preview: `https://cdnt-preview.dzcdn.net/x-${id}.mp3?hdnea=exp=1784761537~hmac=aa`, + track_no: 1, + disk_number: 1, + unreleased: false, + ...over, +}); + +test("artist manifest: rank order kept, exact provider-id dupes collapse", () => { + const m = buildArtistManifest({ + artist: { id: 480, name: "Röyksopp", picture: "pic" }, + popularTracks: [catalogTrack(1), catalogTrack(2), catalogTrack(1), catalogTrack(3)], + createdAtMs: T0, + }); + assert.equal(m.items.length, 3); // dupe of id 1 collapsed + assert.deepEqual(m.items.map((i) => i.sources[0].providerTrackId), ["1", "2", "3"]); + assert.deepEqual(m.items.map((i) => i.sourceOrdinal), [0, 1, 2]); + assert.equal(m.context.kind, "artist"); + assert.equal(m.context.sourceTrackCount, 4); // source count is truthful + assert.ok(isValidManifest(m)); +}); + +test("artist manifest: same title, different provider ids do NOT collapse", () => { + const m = buildArtistManifest({ + artist: { id: 480, name: "Röyksopp" }, + popularTracks: [catalogTrack(1, { title: "Same" }), catalogTrack(2, { title: "Same" })], + createdAtMs: T0, + }); + assert.equal(m.items.length, 2); +}); + +test("availability from catalog rows: preview/empty/unreleased", () => { + const m = buildArtistManifest({ + artist: { id: 1, name: "A" }, + popularTracks: [ + catalogTrack(1), + catalogTrack(2, { preview: "" }), + catalogTrack(3, { preview: undefined }), + catalogTrack(4, { unreleased: true }), + ], + createdAtMs: T0, + }); + assert.deepEqual( + m.items.map((i) => i.initialAvailability), + [AVAILABILITY.AVAILABLE, AVAILABILITY.NOT_PROVIDED, AVAILABILITY.NOT_PROVIDED, AVAILABILITY.UNRELEASED] + ); + // unreleased rows lose their mutating actions + assert.equal(m.items[3].actions.playFull, false); + assert.equal(m.items[3].actions.save, false); +}); + +test("no signed URL survives into the manifest", () => { + const m = buildArtistManifest({ + artist: { id: 1, name: "A" }, + popularTracks: [catalogTrack(1)], + createdAtMs: T0, + }); + const json = JSON.stringify(m); + assert.ok(!json.includes("hdnea"), "signed URL leaked into manifest"); + assert.ok(!json.includes("cdnt-preview"), "media host leaked into manifest"); +}); + +test("album manifest: disc/track sort with stable fallback + context inheritance", () => { + const m = buildAlbumManifest({ + album: { id: 77, title: "The Wall", artist: "Pink Floyd", cover: "wall.jpg" }, + tracks: [ + catalogTrack(21, { disk_number: 2, track_no: 1, album: "", album_id: null, cover: null }), + catalogTrack(11, { disk_number: 1, track_no: 1 }), + catalogTrack(12, { disk_number: 1, track_no: 2 }), + catalogTrack(13, { disk_number: null, track_no: null }), // sorts by response order within disc 1 + ], + createdAtMs: T0, + }); + assert.deepEqual(m.items.map((i) => i.sources[0].providerTrackId), ["13", "11", "12", "21"]); + const disc2 = m.items[3]; + assert.equal(disc2.album, "The Wall"); // inherited + assert.equal(disc2.albumId, "77"); + assert.equal(disc2.artwork, "wall.jpg"); + assert.equal(m.context.sourceTrackCount, 4); +}); + +test("fixture manifest: playlist duplicates keep distinct sessionKeys, shared recordingKey", () => { + const m = buildFixtureManifest({ + kind: "playlist", + rows: [ + { providerTrackId: "9", title: "Song" }, + { providerTrackId: "8", title: "Other" }, + { providerTrackId: "9", title: "Song" }, // duplicate occurrence + ], + createdAtMs: T0, + }); + assert.equal(m.items.length, 3); + assert.notEqual(m.items[0].sessionKey, m.items[2].sessionKey); + assert.equal(m.items[0].recordingKey, m.items[2].recordingKey); + assert.ok(isValidManifest(m)); +}); + +test("fixture manifest: apple identity is a first-class source ref", () => { + const m = buildFixtureManifest({ + rows: [{ provider: "apple", providerTrackId: "ap-123", isrc: "USX9P1234567", matchConfidence: "isrc", title: "X" }], + createdAtMs: T0, + }); + const src = m.items[0].sources[0]; + assert.equal(src.provider, "apple"); + assert.equal(src.providerTrackId, "ap-123"); + assert.equal(src.matchConfidence, "isrc"); +}); diff --git a/tests/preview/session.test.mjs b/tests/preview/session.test.mjs new file mode 100644 index 0000000..469f623 --- /dev/null +++ b/tests/preview/session.test.mjs @@ -0,0 +1,212 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { AVAILABILITY } from "../../src/lib/preview/contracts.js"; +import { buildFixtureManifest } from "../../src/lib/preview/manifest.js"; +import { + createInitialState, + reduce, + PHASE, + playableManifestIndices, + playableCursor, +} from "../../src/lib/preview/session.js"; + +const T0 = 1_700_000_000_000; +const SID = "sess-1"; + +/** rows: a=available, x=not provided — e.g. "aaxa" */ +function manifestOf(pattern) { + return buildFixtureManifest({ + rows: [...pattern].map((c, i) => ({ + providerTrackId: String(i + 1), + title: `T${i + 1}`, + initialAvailability: c === "a" ? AVAILABILITY.AVAILABLE : AVAILABILITY.NOT_PROVIDED, + })), + createdAtMs: T0, + }); +} + +function opened(pattern) { + const s0 = createInitialState(SID); + return reduce(s0, { type: "MANIFEST_READY", sessionId: SID, manifest: manifestOf(pattern) }); +} + +const key = (state, i) => state.manifest.items[i].sessionKey; + +// ------------------------------------------------------------ lifecycle --- + +test("manifest ready: first playable becomes active with listen intent, generation bumps", () => { + const s = opened("xaa"); + assert.equal(s.phase, PHASE.RESOLVING_MEDIA); + assert.equal(s.activeManifestIndex, 1); // index 0 is not playable + assert.equal(s.requestGeneration, 1); + assert.equal(s.desiredPlaying, true); // take-audio-on-open decision +}); + +test("empty manifest → empty phase, no active item", () => { + const s = opened("xx"); + assert.equal(s.phase, PHASE.EMPTY); + assert.equal(s.activeItemKey, null); +}); + +test("close is terminal: nothing lands afterwards", () => { + let s = opened("aa"); + s = reduce(s, { type: "CLOSE", sessionId: SID }); + assert.equal(s.phase, PHASE.CLOSING); + const after = reduce(s, { type: "MEDIA_PLAYING", sessionId: SID, generation: s.requestGeneration }); + assert.equal(after.phase, PHASE.CLOSING); + const stepped = reduce(s, { type: "STEP", sessionId: SID, dir: 1 }); + assert.equal(stepped.activeManifestIndex, s.activeManifestIndex); +}); + +test("session replacement: actions from the old session id are ignored", () => { + const old = opened("aa"); + const fresh = reduce(createInitialState("sess-2"), { + type: "MANIFEST_READY", + sessionId: "sess-2", + manifest: manifestOf("aa"), + }); + const poked = reduce(fresh, { type: "STEP", sessionId: SID, dir: 1 }); // old session id + assert.equal(poked, fresh); + assert.notEqual(old.sessionId, fresh.sessionId); +}); + +// ------------------------------------------------------------ navigation -- + +test("step skips unavailable ordinals and never wraps", () => { + let s = opened("axa"); // playable indices [0, 2] + assert.equal(s.activeManifestIndex, 0); + s = reduce(s, { type: "STEP", sessionId: SID, dir: 1 }); + assert.equal(s.activeManifestIndex, 2); // skipped 1 + const end = reduce(s, { type: "STEP", sessionId: SID, dir: 1 }); + assert.equal(end.activeManifestIndex, 2); // no wrap + const back = reduce(s, { type: "STEP", sessionId: SID, dir: -1 }); + assert.equal(back.activeManifestIndex, 0); + const start = reduce(back, { type: "STEP", sessionId: SID, dir: -1 }); + assert.equal(start.activeManifestIndex, 0); // no wrap at the front either +}); + +test("every activation bumps the generation", () => { + let s = opened("aaa"); + const g1 = s.requestGeneration; + s = reduce(s, { type: "STEP", sessionId: SID, dir: 1 }); + assert.equal(s.requestGeneration, g1 + 1); + s = reduce(s, { type: "ACTIVATE_KEY", sessionId: SID, sessionKey: key(s, 0) }); + assert.equal(s.requestGeneration, g1 + 2); +}); + +test("activating an unavailable or already-active card is refused", () => { + const s = opened("ax"); + assert.equal(reduce(s, { type: "ACTIVATE_KEY", sessionId: SID, sessionKey: key(s, 1) }), s); + assert.equal(reduce(s, { type: "ACTIVATE_KEY", sessionId: SID, sessionKey: key(s, 0) }), s); +}); + +// -------------------------------------------------- ordinal/cursor maths -- + +test("playable indices and cursor reflect manifest order and availability", () => { + const s = opened("axaxa"); + assert.deepEqual(playableManifestIndices(s), [0, 2, 4]); + assert.equal(playableCursor(s), 0); + const at4 = reduce(reduce(s, { type: "STEP", sessionId: SID, dir: 1 }), { type: "STEP", sessionId: SID, dir: 1 }); + assert.equal(at4.activeManifestIndex, 4); + assert.equal(playableCursor(at4), 2); +}); + +test("a newly available EARLIER item recomputes the cursor but never moves the active card", () => { + let s = opened("xaa"); + assert.equal(s.activeManifestIndex, 1); + assert.equal(playableCursor(s), 0); + s = reduce(s, { + type: "AVAILABILITY_CHANGED", + sessionId: SID, + sessionKey: key(s, 0), + availability: AVAILABILITY.AVAILABLE, + }); + assert.equal(s.activeManifestIndex, 1); // card did not move + assert.equal(s.activeItemKey, key(s, 1)); + assert.equal(playableCursor(s), 1); // cursor recomputed around it + assert.deepEqual(playableManifestIndices(s), [0, 1, 2]); +}); + +test("a failed ACTIVE item stays visible in its error state; stepping leaves it", () => { + let s = opened("aaa"); + const activeKey = s.activeItemKey; + s = reduce(s, { + type: "ITEM_FAILED", + sessionId: SID, + generation: s.requestGeneration, + sessionKey: activeKey, + errorClass: "stale_lease", + }); + assert.equal(s.phase, PHASE.ITEM_FAILED); + assert.equal(s.activeItemKey, activeKey); // still the visible card + assert.equal(playableCursor(s), null); // not currently eligible + // stepping forward finds the next eligible MANIFEST ordinal + const stepped = reduce(s, { type: "STEP", sessionId: SID, dir: 1 }); + assert.equal(stepped.activeManifestIndex, 1); +}); + +// ------------------------------------------------------ stale rejection --- + +test("a lease from an older generation cannot touch the newer item", () => { + let s = opened("aaa"); + const staleGen = s.requestGeneration; + const staleKey = s.activeItemKey; + s = reduce(s, { type: "STEP", sessionId: SID, dir: 1 }); // gen bumped + const poked = reduce(s, { + type: "LEASE_READY", + sessionId: SID, + generation: staleGen, + sessionKey: staleKey, + clipDurationMs: 30_000, + }); + assert.equal(poked, s); // dropped entirely +}); + +test("a stale MEDIA_PLAYING cannot flip the phase", () => { + let s = opened("aa"); + const staleGen = s.requestGeneration; + s = reduce(s, { type: "STEP", sessionId: SID, dir: 1 }); + const poked = reduce(s, { type: "MEDIA_PLAYING", sessionId: SID, generation: staleGen }); + assert.equal(poked.phase, s.phase); +}); + +test("current-generation media flow: lease → buffering → playing → position", () => { + let s = opened("aa"); + const g = s.requestGeneration; + s = reduce(s, { type: "LEASE_READY", sessionId: SID, generation: g, sessionKey: s.activeItemKey, clipDurationMs: 30_000 }); + assert.equal(s.phase, PHASE.BUFFERING); // desiredPlaying was true + assert.equal(s.clipDurationMs, 30_000); + s = reduce(s, { type: "MEDIA_PLAYING", sessionId: SID, generation: g }); + assert.equal(s.phase, PHASE.PLAYING); + s = reduce(s, { type: "MEDIA_POSITION", sessionId: SID, generation: g, positionMs: 12_000 }); + assert.equal(s.positionMs, 12_000); +}); + +// ---------------------------------------------------------- auto-advance -- + +test("clip end auto-advances to the next playable item under a fresh generation", () => { + let s = opened("axa"); + const g = s.requestGeneration; + s = reduce(s, { type: "MEDIA_PLAYING", sessionId: SID, generation: g }); + s = reduce(s, { type: "MEDIA_ENDED", sessionId: SID, generation: g }); + assert.equal(s.activeManifestIndex, 2); // skipped the unavailable ordinal + assert.equal(s.requestGeneration, g + 1); // stale events from the old clip die + assert.equal(s.desiredPlaying, true); // keeps playing + assert.equal(s.phase, PHASE.RESOLVING_MEDIA); +}); + +test("clip end on the LAST playable item stops there (Replay state)", () => { + let s = opened("aa"); + s = reduce(s, { type: "STEP", sessionId: SID, dir: 1 }); + const g = s.requestGeneration; + s = reduce(s, { type: "MEDIA_ENDED", sessionId: SID, generation: g }); + assert.equal(s.phase, PHASE.ENDED); + assert.equal(s.desiredPlaying, false); + assert.equal(s.activeManifestIndex, 1); // stays on the last card + // Replay restarts the same item with a new generation + const r = reduce(s, { type: "REPLAY", sessionId: SID }); + assert.equal(r.activeManifestIndex, 1); + assert.equal(r.requestGeneration, g + 1); + assert.equal(r.desiredPlaying, true); +}); diff --git a/tests/preview/transport.test.mjs b/tests/preview/transport.test.mjs new file mode 100644 index 0000000..8636b38 --- /dev/null +++ b/tests/preview/transport.test.mjs @@ -0,0 +1,85 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { AVAILABILITY } from "../../src/lib/preview/contracts.js"; +import { buildFixtureManifest } from "../../src/lib/preview/manifest.js"; +import { createInitialState, reduce, PHASE } from "../../src/lib/preview/session.js"; +import { createFixtureTransport } from "../../src/lib/preview/transport.js"; +import { makeNativeLease } from "../../src/lib/preview/lease.js"; + +const SID = "sess-t"; +const T0 = 1_700_000_000_000; +const SRC = { provider: "deezer", providerTrackId: "1", matchConfidence: "native" }; + +function openSession() { + const manifest = buildFixtureManifest({ + rows: [ + { providerTrackId: "1", title: "One", initialAvailability: AVAILABILITY.AVAILABLE }, + { providerTrackId: "2", title: "Two", initialAvailability: AVAILABILITY.AVAILABLE }, + ], + createdAtMs: T0, + }); + return reduce(createInitialState(SID), { type: "MANIFEST_READY", sessionId: SID, manifest }); +} + +test("end-to-end against the fake transport: load → play → ticks → auto-advance", () => { + let state = openSession(); + const transport = createFixtureTransport((action) => { + state = reduce(state, action); + }); + + const lease = makeNativeLease({ source: SRC, opaqueLeaseId: "op-1", safeForMs: 300_000, clipDurationMs: 30_000 }); + state = reduce(state, { + type: "LEASE_READY", sessionId: SID, generation: state.requestGeneration, + sessionKey: state.activeItemKey, clipDurationMs: 30_000, + }); + transport.load(lease, { sessionId: SID, generation: state.requestGeneration, sessionKey: state.activeItemKey }); + transport.play(); + assert.equal(state.phase, PHASE.PLAYING); + + transport.tick(12_000); + assert.equal(state.positionMs, 12_000); + + transport.tick(18_000); // reaches clip end → MEDIA_ENDED → auto-advance + assert.equal(state.activeManifestIndex, 1); + assert.equal(state.phase, PHASE.RESOLVING_MEDIA); + assert.equal(state.desiredPlaying, true); +}); + +test("events after a step carry the old generation and are inert", () => { + let state = openSession(); + const transport = createFixtureTransport((action) => { + state = reduce(state, action); + }); + const lease = makeNativeLease({ source: SRC, opaqueLeaseId: "op-1", safeForMs: 300_000 }); + const genAtLoad = state.requestGeneration; + transport.load(lease, { sessionId: SID, generation: genAtLoad, sessionKey: state.activeItemKey }); + transport.play(); + assert.equal(state.phase, PHASE.PLAYING); + + // user steps: the reducer bumps the generation… + state = reduce(state, { type: "STEP", sessionId: SID, dir: 1 }); + const after = state; + // …but the un-stopped transport keeps ticking with the OLD generation + transport.tick(5_000); + assert.equal(state.positionMs, after.positionMs, "stale position must not land"); + assert.equal(state.phase, PHASE.RESOLVING_MEDIA); +}); + +test("invalid lease surfaces ITEM_FAILED(invalid_media) instead of playing", () => { + let state = openSession(); + const transport = createFixtureTransport((action) => { + state = reduce(state, action); + }); + const bogus = { transport: "native", safeForMs: 100 }; // no opaqueLeaseId + transport.load(bogus, { sessionId: SID, generation: state.requestGeneration, sessionKey: state.activeItemKey }); + assert.equal(state.phase, PHASE.ITEM_FAILED); + assert.equal(state.errorClass, "invalid_media"); +}); + +test("dispose is terminal and idempotent", () => { + const transport = createFixtureTransport(() => {}); + transport.dispose(); + transport.dispose(); + assert.throws(() => transport.load({}, { sessionId: SID, generation: 1, sessionKey: "k" })); +});