import { describe, test, mock, before, after, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; import { createUiDom } from "../ui/helpers/jsdom-test-helpers.mjs"; import { PlaybackService } from "../../js/services/playbackService.js"; describe("PlaybackService", () => { let dom; let document; let window; before(() => { dom = createUiDom(); window = dom.window; document = dom.document; // Mock HTMLMediaElement constants if missing in JSDOM if (!window.HTMLMediaElement) { window.HTMLMediaElement = { HAVE_NOTHING: 0, HAVE_METADATA: 1, HAVE_CURRENT_DATA: 2, HAVE_FUTURE_DATA: 3, HAVE_ENOUGH_DATA: 4, }; } }); after(() => { dom.cleanup(); }); test("Initialization sets defaults and dependencies", () => { const logger = mock.fn(); const service = new PlaybackService({ logger }); assert.ok(service); assert.equal(typeof service.log, "function"); service.log("test"); assert.equal(logger.mock.callCount(), 1); assert.equal(service.urlFirstEnabled, true); }); test("prepareVideoElement respects localStorage and binds listener", () => { const service = new PlaybackService(); const video = document.createElement("video"); // Default: sound on unless the viewer explicitly chose mute. localStorage.removeItem("unmutedAutoplay"); service.prepareVideoElement(video); assert.equal(video.muted, false); assert.equal(video.dataset.autoplayBound, "true"); // Simulate an explicit mute choice. video.muted = true; video.dispatchEvent(new window.Event("volumechange")); assert.equal(localStorage.getItem("unmutedAutoplay"), "false"); // Test respecting stored value const video2 = document.createElement("video"); service.prepareVideoElement(video2); assert.equal(video2.muted, true); // A browser-policy fallback is temporary and must not become a saved mute. localStorage.setItem("unmutedAutoplay", "true"); video2.muted = false; service.muteForAutoplayFallback(video2); assert.equal(localStorage.getItem("unmutedAutoplay"), "true"); }); test("registerUrlPlaybackWatchdogs triggers onFallback on error", () => { const service = new PlaybackService(); const video = document.createElement("video"); const onFallback = mock.fn(); const onSuccess = mock.fn(); service.registerUrlPlaybackWatchdogs(video, { onFallback, onSuccess }); video.dispatchEvent(new window.Event("error")); assert.equal(onFallback.mock.callCount(), 1); assert.equal(onFallback.mock.calls[0].arguments[0], "error"); }); test("registerUrlPlaybackWatchdogs triggers onSuccess on playing", () => { const service = new PlaybackService(); const video = document.createElement("video"); const onFallback = mock.fn(); const onSuccess = mock.fn(); service.registerUrlPlaybackWatchdogs(video, { onFallback, onSuccess }); video.dispatchEvent(new window.Event("playing")); assert.equal(onSuccess.mock.callCount(), 1); }); test("createSession returns a PlaybackSession", () => { const service = new PlaybackService(); const session = service.createSession({ url: "https://example.com/video.mp4" }); assert.ok(session); assert.equal(service.currentSession, session); assert.equal(session.sanitizedUrl, "https://example.com/video.mp4"); }); describe("PlaybackSession Flow", () => { let service; let video; let playMock; let loadMock; let pauseMock; beforeEach(() => { mock.timers.enable({ apis: ['setTimeout'] }); service = new PlaybackService({ logger: () => {}, isValidMagnetUri: () => true, // Allow all magnets }); video = document.createElement("video"); // Mock methods not implemented in JSDOM playMock = mock.fn(async () => {}); loadMock = mock.fn(); pauseMock = mock.fn(); video.play = playMock; video.load = loadMock; video.pause = pauseMock; Object.defineProperty(video, 'readyState', { writable: true, value: window.HTMLMediaElement.HAVE_NOTHING }); Object.defineProperty(video, 'networkState', { writable: true, value: 0 }); }); // spec_correction: the URL probe only exists to decide whether to fail over // to P2P quickly. It used to run for EVERY hosted attempt, but its timeout // (4s) exceeds the playback-start window (3s), so a slow no-cors HEAD (e.g. // archive.org) consumed the whole budget and the URL was never even loaded — // a false "No playable source found." for perfectly playable URL-only // videos. The probe now runs ONLY when there's a magnet to fall over to. // This test therefore uses a DUAL-source video so the probe path is still // exercised; the URL-only skip is covered by the next test. // // test_integrity_note: // change_type: ["spec_correction"] // scenarios: // - id: SCN-probe-success-dual-source // given: "a dual-source (url+magnet) video whose URL probe succeeds" // when: "playback starts" // then: "the probe runs, the URL plays, and torrent is not used" // observable_outcomes: // - "probe called once; URL playback started; playViaWebTorrent not called" // determinism_controls: ["jsdom video element; mocked probe/torrent"] // anti_cheat_rationale: // prevents: ["over-mocking internal logic"] // relaxation: // did_relax_any_assertion: false // if_true_explain_spec_basis: "moved the probe assertion to the dual-source case it now applies to; url-only skip is separately asserted" test("URL Probe Success starts playback (dual-source)", async () => { const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 })); const playViaWebTorrent = mock.fn(); const testSession = service.createSession({ url: "https://example.com/video.mp4", magnet: "magnet:?xt=urn:btih:1111111111111111111111111111111111111111", videoElement: video, probeUrl, playViaWebTorrent, }); const startPromise = testSession.start(); // Allow execute() to proceed past await probeUrl() and install watchdogs await new Promise(resolve => process.nextTick(resolve)); await new Promise(resolve => process.nextTick(resolve)); // Trigger success event video.dispatchEvent(new window.Event("playing")); const result = await startPromise; assert.equal(probeUrl.mock.callCount(), 1); assert.equal(playMock.mock.callCount(), 1); assert.equal(result.source, "url"); assert.equal(playViaWebTorrent.mock.callCount(), 0); }); // The reported bug: a URL-only video (no magnet) must NOT be gated by the // probe — the video element is the arbiter. It plays directly, probe skipped. // // test_integrity_note: // change_type: ["new_tests"] // scenarios: // - id: SCN-url-only-skips-probe // given: "a URL-only video (no magnet) with a playable URL" // when: "playback starts" // then: "the probe is NOT called and the URL plays directly" // observable_outcomes: ["probe callCount 0; URL playback started"] // determinism_controls: ["jsdom video element; mocked probe"] // anti_cheat_rationale: { prevents: ["hard-coded return value"] } // relaxation: { did_relax_any_assertion: false } test("URL-only video skips the probe and plays directly", async () => { const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 })); const playViaWebTorrent = mock.fn(); const testSession = service.createSession({ url: "https://example.com/video.mp4", videoElement: video, probeUrl, playViaWebTorrent, }); const startPromise = testSession.start(); await new Promise(resolve => process.nextTick(resolve)); await new Promise(resolve => process.nextTick(resolve)); video.dispatchEvent(new window.Event("playing")); const result = await startPromise; assert.equal(probeUrl.mock.callCount(), 0, "URL-only: probe not on the critical path"); assert.equal(playMock.mock.callCount(), 1); assert.equal(result.source, "url"); }); test("URL Probe Failure triggers fallback", async () => { const probeUrl = mock.fn(async () => ({ outcome: "bad", status: 404 })); const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" })); const session = service.createSession({ url: "https://example.com/video.mp4", magnet: "magnet:?xt=urn:btih:123", videoElement: video, probeUrl, playViaWebTorrent, }); const result = await session.start(); assert.equal(probeUrl.mock.callCount(), 1); assert.equal(playViaWebTorrent.mock.callCount(), 1); assert.equal(result.source, "torrent"); }); test("Watchdog Stall triggers fallback", async () => { const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 })); const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" })); const session = service.createSession({ url: "https://example.com/video.mp4", magnet: "magnet:?xt=urn:btih:123", videoElement: video, probeUrl, playViaWebTorrent, }); const startPromise = session.start(); // Allow execute() to proceed past await probeUrl() and install watchdogs await new Promise(resolve => process.nextTick(resolve)); await new Promise(resolve => process.nextTick(resolve)); await new Promise(resolve => process.nextTick(resolve)); // Advance time to trigger stall (default 8000ms) mock.timers.tick(10000); const result = await startPromise; assert.equal(probeUrl.mock.callCount(), 1); assert.equal(playMock.mock.callCount(), 1); // It tried to play assert.equal(playViaWebTorrent.mock.callCount(), 1); // Fallback happened assert.equal(result.source, "torrent"); }); afterEach(() => { mock.timers.reset(); }); }); });