import { describe, it, expect, vi } from "vitest"; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import pino from "pino"; import { RustLibrespotBackend, type RustLibrespotBackendDeps } from "./rust-librespot.js"; const log = pino({ level: "silent" }); /** ChildProcess stand-in with real Readable/Writable pipes so stdout->stdin piping works. */ function makeFakeChild() { const child: any = new EventEmitter(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.stdin = new PassThrough(); child.kill = vi.fn(); return child; } function makeConnect() { return { getDevices: vi.fn(async () => [{ id: "dev1", name: "Test Bot", is_active: false }]), findDeviceByName: vi.fn(async () => "dev1"), transfer: vi.fn(async () => {}), play: vi.fn(async () => {}), pause: vi.fn(async () => {}), resume: vi.fn(async () => {}), seek: vi.fn(async () => {}), getPlaybackState: vi.fn(async () => null as any), }; } function makeOAuth() { return { getAccessToken: vi.fn(async () => "tok-123" as string | null), isAuthorized: () => true, }; } function makeHarness( over: { connect?: any; oauth?: any; deps?: Partial } = {}, ) { const calls: string[] = []; const librespotChild = makeFakeChild(); const ffmpegChild = makeFakeChild(); const spawn = vi.fn((cmd: string, ..._rest: any[]) => { const isLibrespot = cmd.includes("librespot"); calls.push(`spawn:${isLibrespot ? "librespot" : cmd}`); return isLibrespot ? librespotChild : ffmpegChild; }); const mkdirSync = vi.fn(); const connect = over.connect ?? makeConnect(); const oauth = over.oauth ?? makeOAuth(); const backend = new RustLibrespotBackend({ deviceName: "Test Bot", bitrate: 320, cacheDir: "/tmp/cache", oauth: oauth as any, connect: connect as any, logger: log, deps: { spawn: spawn as any, mkdirSync: mkdirSync as any, findBinary: () => "/bin/librespot", // C1: pin ffmpeg so arg-array assertions stay stable while prod uses getFfmpegCommand(). ffmpegCommand: "ffmpeg", sleep: async () => {}, readyPollIntervalMs: 1, readyTimeoutMs: 100, // huge so the background setInterval never fires; tests drive pollState() directly. statePollIntervalMs: 10_000_000, ...over.deps, }, }); return { backend, calls, spawn, mkdirSync, connect, oauth, librespotChild, ffmpegChild }; } describe("RustLibrespotBackend.start", () => { it("spawns librespot with the pipe/stdout arg set and the OAuth access token", async () => { const h = makeHarness(); await h.backend.start(); expect(h.spawn).toHaveBeenCalledWith( "/bin/librespot", [ "--name", "Test Bot", "--backend", "pipe", "--bitrate", "320", "--format", "S16", "--cache", "/tmp/cache", "--device-type", "speaker", "--access-token", "tok-123", ], expect.anything(), ); // NO --device (=> stdout) and NO --passthrough (=> decoded PCM, not Ogg). const args = h.spawn.mock.calls.find((c) => String(c[0]).includes("librespot"))![1] as string[]; expect(args).not.toContain("--device"); expect(args).not.toContain("--passthrough"); h.backend.stop(); }); it("spawns ffmpeg (reader) before librespot (writer) with the exact 44100->48000 s16le args", async () => { const h = makeHarness(); await h.backend.start(); const ffmpegArgs = h.spawn.mock.calls.find((c) => c[0] === "ffmpeg")![1] as string[]; expect(ffmpegArgs).toEqual([ "-f", "s16le", "-ar", "44100", "-ac", "2", "-i", "pipe:0", "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1", ]); const ffmpegIdx = h.calls.indexOf("spawn:ffmpeg"); const librespotIdx = h.calls.indexOf("spawn:librespot"); expect(ffmpegIdx).toBeGreaterThanOrEqual(0); expect(librespotIdx).toBeGreaterThan(ffmpegIdx); h.backend.stop(); }); it("getPcmStream() returns the ffmpeg stdout Readable", async () => { const h = makeHarness(); await h.backend.start(); expect(h.backend.getPcmStream()).toBe(h.ffmpegChild.stdout); h.backend.stop(); }); it("emits 'ready' and reports isReady() true once our device appears in getDevices()", async () => { const h = makeHarness(); const ready = vi.fn(); h.backend.on("ready", ready); await h.backend.start(); expect(h.connect.getDevices).toHaveBeenCalled(); expect(ready).toHaveBeenCalledTimes(1); expect(h.backend.isReady()).toBe(true); h.backend.stop(); }); it("keeps polling getDevices() until the device name appears", async () => { const h = makeHarness(); h.connect.getDevices .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ id: "other", name: "Someone else", is_active: true }]) .mockResolvedValue([{ id: "dev1", name: "Test Bot", is_active: false }]); await h.backend.start(); expect(h.connect.getDevices).toHaveBeenCalledTimes(3); expect(h.backend.isReady()).toBe(true); h.backend.stop(); }); it("throws (and does not spawn) when the OAuth token is null", async () => { const oauth = makeOAuth(); oauth.getAccessToken.mockResolvedValue(null); const h = makeHarness({ oauth }); await expect(h.backend.start()).rejects.toThrow(/authorized|token/i); expect(h.spawn).not.toHaveBeenCalled(); }); }); describe("RustLibrespotBackend transport delegation (Connect API)", () => { it("playTrack resolves the device then transfer(false) then play(uri)", async () => { const h = makeHarness(); await h.backend.playTrack("spotify:track:go"); expect(h.connect.findDeviceByName).toHaveBeenCalledWith("Test Bot"); expect(h.connect.transfer).toHaveBeenCalledWith("dev1", false); expect(h.connect.play).toHaveBeenCalledWith("dev1", "spotify:track:go"); // ordering: transfer before play expect(h.connect.transfer.mock.invocationCallOrder[0]) .toBeLessThan(h.connect.play.mock.invocationCallOrder[0]); }); it("playTrack throws when the device cannot be found", async () => { const h = makeHarness(); h.connect.findDeviceByName.mockResolvedValue(null); await expect(h.backend.playTrack("spotify:track:x")).rejects.toThrow(/device/i); }); // R4-4 (multi-bot): control must be scoped to OUR device. playTrack resolves // and stores our device id (dev1); pause/resume/seek then pass it to the // Connect API so bot A's pause/resume/seek can't act on bot B's playback (the // account-wide default would pause whatever device is currently active). it("pause/resume/seek delegate to the Connect API scoped to OUR device, and seek updates position", async () => { const h = makeHarness(); await h.backend.playTrack("spotify:track:go"); // stores our device id (dev1) await h.backend.pause(); await h.backend.resume(); await h.backend.seek(5000); expect(h.connect.pause).toHaveBeenCalledWith("dev1"); expect(h.connect.resume).toHaveBeenCalledWith("dev1"); expect(h.connect.seek).toHaveBeenCalledWith(5000, "dev1"); expect(h.backend.getPositionMs()).toBe(5000); }); }); describe("RustLibrespotBackend track-end poll loop", () => { it("emits trackEnded when progress reaches the end-of-track window", async () => { const h = makeHarness(); const ended = vi.fn(); const meta = vi.fn(); h.backend.on("trackEnded", ended); h.backend.on("metadata", meta); // Arm detection the way production does — via our own playTrack(). await h.backend.playTrack("spotify:track:A"); h.connect.getPlaybackState .mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 }) .mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000 }); await (h.backend as any).pollState(); expect(meta).toHaveBeenCalledWith(expect.objectContaining({ uri: "spotify:track:A", durationMs: 200000 })); expect(h.backend.getPositionMs()).toBe(1000); expect(ended).not.toHaveBeenCalled(); await (h.backend as any).pollState(); expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" }); }); // C1(pause-skip): a USER pause reports is_playing:false with the SAME uri on // the Rust backend. That MUST NOT be read as a track end (it would skip the // paused track and break pause + occupancy auto-pause). Formerly the // "!isPlaying after having played" test asserted the opposite — that encoded // the bug; it is now split into this pause-no-skip test plus the two-poll // external-stop test below. it("does NOT emit trackEnded when the user PAUSES (self-initiated pause is not a track end)", async () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); await h.backend.playTrack("spotify:track:A"); // Observe our track actually playing first. h.connect.getPlaybackState.mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000, }); await (h.backend as any).pollState(); // User pauses: the Connect device stays loaded but reports is_playing:false // with the SAME uri across every subsequent poll while paused. await h.backend.pause(); h.connect.getPlaybackState.mockResolvedValue({ isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000, }); await (h.backend as any).pollState(); await (h.backend as any).pollState(); // stays paused across multiple polls expect(ended).not.toHaveBeenCalled(); // Resuming keeps the same track playing — still no spurious end. await h.backend.resume(); h.connect.getPlaybackState.mockResolvedValue({ isPlaying: true, progressMs: 6000, trackUri: "spotify:track:A", durationMs: 200000, }); await (h.backend as any).pollState(); expect(ended).not.toHaveBeenCalled(); }); it("emits trackEnded once on an EXTERNAL stop only after TWO consecutive !isPlaying polls (a transient mid-track !isPlaying is not a skip)", async () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); await h.backend.playTrack("spotify:track:A"); h.connect.getPlaybackState .mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 }) .mockResolvedValueOnce({ isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 }) .mockResolvedValue({ isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 }); await (h.backend as any).pollState(); // observed playing await (h.backend as any).pollState(); // FIRST !isPlaying -> unconfirmed (could be transient buffering) expect(ended).not.toHaveBeenCalled(); await (h.backend as any).pollState(); // SECOND consecutive !isPlaying -> confirmed external stop await (h.backend as any).pollState(); // idempotent: no second emit for same track expect(ended).toHaveBeenCalledTimes(1); expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" }); }); it("a transient single !isPlaying poll followed by playing again does NOT emit trackEnded (buffering hiccup)", async () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); await h.backend.playTrack("spotify:track:A"); h.connect.getPlaybackState .mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 }) .mockResolvedValueOnce({ isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 }) .mockResolvedValue({ isPlaying: true, progressMs: 6000, trackUri: "spotify:track:A", durationMs: 200000 }); await (h.backend as any).pollState(); // playing await (h.backend as any).pollState(); // momentary !isPlaying (buffering) await (h.backend as any).pollState(); // playing again -> stop confirmation reset expect(ended).not.toHaveBeenCalled(); }); // m(sub-window): a track SHORTER than the end-of-track window must not be // declared finished on its first observed-playing poll (durationMs - window // is negative, so the old near-end check fired unconditionally). it("does NOT false-finish a sub-window (< END_OF_TRACK_WINDOW_MS) duration on the first playing poll", async () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); await h.backend.playTrack("spotify:track:short"); h.connect.getPlaybackState.mockResolvedValue({ isPlaying: true, progressMs: 100, trackUri: "spotify:track:short", durationMs: 1200, }); await (h.backend as any).pollState(); expect(ended).not.toHaveBeenCalled(); }); // C1(pause-skip) residual: a self-initiated pause within the FINAL // END_OF_TRACK_WINDOW_MS freezes progress at >= dur-window with is_playing:false // and the SAME uri. The finishedByProgress near-end heuristic must NOT fire // while paused (it would skip the paused track). After resume(), the track // plays on and finishes exactly once. it("does NOT skip when the user PAUSES within the final end-of-track window, but ends once after resume", async () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); await h.backend.playTrack("spotify:track:A"); // Observe our track actually playing first (mid-track). h.connect.getPlaybackState.mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000, }); await (h.backend as any).pollState(); // User pauses within the final ~1.5s: frozen progress >= dur-window, // is_playing:false, SAME uri, across every subsequent paused poll. await h.backend.pause(); h.connect.getPlaybackState.mockResolvedValue({ isPlaying: false, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000, }); await (h.backend as any).pollState(); await (h.backend as any).pollState(); // stays paused across multiple polls expect(ended).not.toHaveBeenCalled(); // Resume -> the track plays on to its natural end and finishes exactly once. await h.backend.resume(); h.connect.getPlaybackState.mockResolvedValue({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000, }); await (h.backend as any).pollState(); expect(ended).toHaveBeenCalledTimes(1); expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" }); }); // C1(pause-skip) residual: during a LONG self-initiated pause the Connect // device can idle out to a 204 / null playback state. That null state while // paused must NOT be read as a track end (it would skip the paused track). // After resume() a genuinely dead device (persistent null) IS detected. it("does NOT skip a PAUSED track when the device idles out to null/204, but DOES after resume", async () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); await h.backend.playTrack("spotify:track:A"); // Observe our track actually playing first. h.connect.getPlaybackState.mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000, }); await (h.backend as any).pollState(); // Pause, then the Connect device idles out to a 204 / null state. await h.backend.pause(); h.connect.getPlaybackState.mockResolvedValue(null as any); await (h.backend as any).pollState(); await (h.backend as any).pollState(); // stays paused across multiple null polls expect(ended).not.toHaveBeenCalled(); // Resume -> a genuinely dead device (persistent null) is now detected once. await h.backend.resume(); await (h.backend as any).pollState(); expect(ended).toHaveBeenCalledTimes(1); expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" }); }); // Regression: a NON-paused track that idles out to a null/204 state after // having played must STILL emit exactly one trackEnded (the pause gate must // not suppress genuine device death for a non-paused track). it("regression: a NON-paused track that idles out to null/204 after playing still emits exactly one trackEnded", async () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); await h.backend.playTrack("spotify:track:A"); h.connect.getPlaybackState.mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000, }); await (h.backend as any).pollState(); // observed playing, not paused h.connect.getPlaybackState.mockResolvedValue(null as any); // device idles out await (h.backend as any).pollState(); await (h.backend as any).pollState(); // idempotent: no second emit expect(ended).toHaveBeenCalledTimes(1); expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" }); }); // R3-1: a GENUINE end where the item stays null across TWO consecutive // non-paused polls still emits exactly one trackEnded. The null-item path now // shares the external-stop two-poll confirmation, so a single transient null // (Connect handoff / market relink) no longer skips a still-playing track — // but a persistent null is still detected. (Previously this test asserted a // single null poll ended the track; that encoded the R3-1 corner-case bug.) it("emits trackEnded once when the track uri stays null across TWO consecutive polls after playing", async () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); await h.backend.playTrack("spotify:track:A"); h.connect.getPlaybackState .mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 }) .mockResolvedValue({ isPlaying: true, progressMs: 0, trackUri: null, durationMs: 0 }); await (h.backend as any).pollState(); // observed playing our uri await (h.backend as any).pollState(); // FIRST null item -> unconfirmed (could be transient) expect(ended).not.toHaveBeenCalled(); await (h.backend as any).pollState(); // SECOND consecutive null -> confirmed end await (h.backend as any).pollState(); // idempotent: no second emit for same track expect(ended).toHaveBeenCalledTimes(1); expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" }); }); // R3-1: Spotify legitimately returns item:null transiently at a track/Connect // handoff boundary, and getPlaybackState omits the `market` param so a // region-relinked/restricted item can momentarily map to uri:null. A SINGLE // {isPlaying:true, trackUri:null} poll mid-track must NOT skip a still-playing // track; a following poll showing our real uri again confirms it kept playing. it("a transient single null-item poll (isPlaying:true, trackUri:null) followed by our uri again does NOT emit trackEnded", async () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); await h.backend.playTrack("spotify:track:A"); h.connect.getPlaybackState .mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 }) .mockResolvedValueOnce({ isPlaying: true, progressMs: 0, trackUri: null, durationMs: 0 }) .mockResolvedValue({ isPlaying: true, progressMs: 6000, trackUri: "spotify:track:A", durationMs: 200000 }); await (h.backend as any).pollState(); // playing our uri await (h.backend as any).pollState(); // momentary null item (handoff / market relink) await (h.backend as any).pollState(); // our uri again -> confirmation reset, still playing expect(ended).not.toHaveBeenCalled(); }); // R3-1 (pause invariant): a self-paused track must NEVER skip. A {trackUri:null} // poll while WE hold a pause must not be read as a track end (same invariant // the finishedByStop / null-204 paths already enforce with a `!paused` guard). it("does NOT emit trackEnded on a null-item poll while the track is self-paused", async () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); await h.backend.playTrack("spotify:track:A"); h.connect.getPlaybackState.mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000, }); await (h.backend as any).pollState(); // observed playing await h.backend.pause(); // While paused, a null item appears across multiple polls (state present, no item). h.connect.getPlaybackState.mockResolvedValue({ isPlaying: false, progressMs: 5000, trackUri: null, durationMs: 0, }); await (h.backend as any).pollState(); await (h.backend as any).pollState(); expect(ended).not.toHaveBeenCalled(); }); it("ignores a null playback state (no active device) without emitting", async () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); h.connect.getPlaybackState.mockResolvedValue(null); await (h.backend as any).pollState(); expect(ended).not.toHaveBeenCalled(); }); it("C3.4: a startup poll before playTrack never emits (foreign track near its end)", async () => { const h = makeHarness(); const ended = vi.fn(); const meta = vi.fn(); h.backend.on("trackEnded", ended); h.backend.on("metadata", meta); // Backend started, but playTrack has NOT been called => detection disarmed. await h.backend.start(); // First poll observes a FOREIGN track that is actively playing near its end. h.connect.getPlaybackState.mockResolvedValue({ isPlaying: true, progressMs: 199000, trackUri: "spotify:foreign", durationMs: 200000, }); await (h.backend as any).pollState(); // No spurious end-of-track and no bogus metadata before the bot ever plays. expect(ended).not.toHaveBeenCalled(); expect(meta).not.toHaveBeenCalled(); expect(h.backend.getPositionMs()).toBe(0); h.backend.stop(); }); it("after playTrack, a normal finish emits trackEnded exactly once for our uri", async () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); // Arm detection via our own play, then confirm-then-finish our uri. await h.backend.playTrack("spotify:track:ours"); h.connect.getPlaybackState .mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:ours", durationMs: 200000 }) .mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:ours", durationMs: 200000 }); await (h.backend as any).pollState(); // confirms our uri playing expect(ended).not.toHaveBeenCalled(); await (h.backend as any).pollState(); // finishes expect(ended).toHaveBeenCalledTimes(1); expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:ours", reason: "ended" }); }); }); // R4-4 (multi-bot): config.spotify (and thus the Connect session) is shared by // every bot under one Premium account, which supports only ONE active playback // stream. GET /v1/me/player is account-wide, so once bot B steals the active // session our poll would see B's device + B's track. The backend now stores OUR // device id and, when the reported activeDeviceId differs, refuses to treat the // foreign playback as ours: no foreign metadata, no misattribution — the stolen // session instead advances OUR queue cleanly via the existing two-poll stop. describe("RustLibrespotBackend multi-bot device scoping (R4-4)", () => { it("ignores foreign-device poll state: no foreign metadata, no misattribution, and a stolen session advances OUR queue once", async () => { const h = makeHarness(); const ended = vi.fn(); const meta = vi.fn(); h.backend.on("trackEnded", ended); h.backend.on("metadata", meta); // Our track plays on OUR device (dev1 — the findDeviceByName mock id). await h.backend.playTrack("spotify:track:ours"); h.connect.getPlaybackState.mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:ours", durationMs: 200000, activeDeviceId: "dev1", }); await (h.backend as any).pollState(); // our track observed playing on our device expect(meta).toHaveBeenCalledTimes(1); expect(meta).toHaveBeenCalledWith(expect.objectContaining({ uri: "spotify:track:ours" })); expect(h.backend.getPositionMs()).toBe(5000); meta.mockClear(); // Bot B steals the single active Connect session: /v1/me/player now reports // THEIR device (dev2) + THEIR track, and would keep doing so every poll. h.connect.getPlaybackState.mockResolvedValue({ isPlaying: true, progressMs: 123000, trackUri: "spotify:track:foreign", durationMs: 200000, activeDeviceId: "dev2", }); await (h.backend as any).pollState(); // FIRST foreign poll -> unconfirmed stop, no side effects expect(meta).not.toHaveBeenCalled(); // foreign metadata NOT surfaced as ours expect(ended).not.toHaveBeenCalled(); // two-poll confirmation not met yet expect(h.backend.getPositionMs()).toBe(5000); // foreign progress NOT misattributed await (h.backend as any).pollState(); // SECOND foreign poll -> confirmed -> OUR queue advances await (h.backend as any).pollState(); // idempotent: no second emit for our track expect(ended).toHaveBeenCalledTimes(1); expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:ours", reason: "ended" }); expect(meta).not.toHaveBeenCalled(); // never emitted metadata for the foreign uri }); it("when the active device IS ours (activeDeviceId === our id), end-detection behaves exactly as today", async () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); await h.backend.playTrack("spotify:track:A"); h.connect.getPlaybackState .mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000, activeDeviceId: "dev1" }) .mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000, activeDeviceId: "dev1" }); await (h.backend as any).pollState(); // confirms our uri playing on our device expect(ended).not.toHaveBeenCalled(); await (h.backend as any).pollState(); // near-end -> finishes normally expect(ended).toHaveBeenCalledTimes(1); expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" }); }); }); // R4-6: finishedByProgress is a SINGLE-poll near-end heuristic. If a user // deliberately SEEKS to within the final END_OF_TRACK_WINDOW_MS, the next poll // would see progressMs >= durationMs-1500 and emit trackEnded — skipping the ~1s // the user seeked into. A one-poll "just-seeked" grace suppresses that single // misfire; the track then plays its remaining <=1.5s and ends naturally on the // following poll via the stop/null detection. Natural near-end (reached by // PLAYING, no seek) must be UNCHANGED. describe("RustLibrespotBackend seek-into-end grace (R4-6)", () => { it("does NOT skip when a seek lands within the final end-of-track window; ends naturally on the following poll", async () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); await h.backend.playTrack("spotify:track:A"); // Observe our track actually playing first (mid-track). h.connect.getPlaybackState.mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000, }); await (h.backend as any).pollState(); // User deliberately seeks to ~1s before the end (inside the 1.5s window). await h.backend.seek(199000); // First poll after the seek: progress is already inside the near-end window // and still playing. The grace must suppress this single finishedByProgress. h.connect.getPlaybackState.mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000, }); await (h.backend as any).pollState(); expect(ended).not.toHaveBeenCalled(); // The remaining <=1.5s plays out and librespot goes idle (null/204) — the // genuine end. It emits exactly one trackEnded. h.connect.getPlaybackState.mockResolvedValue(null as any); await (h.backend as any).pollState(); expect(ended).toHaveBeenCalledTimes(1); expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" }); }); it("the seek grace only spans ONE poll: a second in-window playing poll still finishes by progress", async () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); await h.backend.playTrack("spotify:track:A"); h.connect.getPlaybackState.mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000, }); await (h.backend as any).pollState(); // observed playing await h.backend.seek(199000); // seek into the final window // Grace poll: suppressed. h.connect.getPlaybackState.mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000, }); await (h.backend as any).pollState(); expect(ended).not.toHaveBeenCalled(); // Second in-window playing poll: grace already consumed -> natural near-end // detection fires exactly once (grace must not permanently disable it). h.connect.getPlaybackState.mockResolvedValueOnce({ isPlaying: true, progressMs: 199500, trackUri: "spotify:track:A", durationMs: 200000, }); await (h.backend as any).pollState(); expect(ended).toHaveBeenCalledTimes(1); expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" }); }); it("regression: a track that reaches the final window by PLAYING (no seek) still emits trackEnded on the first in-window poll", async () => { const h = makeHarness(); const ended = vi.fn(); h.backend.on("trackEnded", ended); await h.backend.playTrack("spotify:track:A"); h.connect.getPlaybackState .mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 }) .mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000 }); await (h.backend as any).pollState(); // observed playing (no seek) expect(ended).not.toHaveBeenCalled(); await (h.backend as any).pollState(); // reaches window by PLAYING -> natural end expect(ended).toHaveBeenCalledTimes(1); expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" }); }); }); describe("RustLibrespotBackend playback-start watchdog (I4 degrade-to-skip)", () => { /** A controllable timer seam matching the file's injected-deps style. */ function makeFakeTimer() { const pending: Array<{ cb: () => void; ms: number; handle: object }> = []; const setTimer = vi.fn((cb: () => void, ms: number) => { const handle = {}; pending.push({ cb, ms, handle }); return handle; }); const clearTimer = vi.fn((h: unknown) => { const i = pending.findIndex((p) => p.handle === h); if (i >= 0) pending.splice(i, 1); }); // "advance fake timers": run (and drain) every armed callback. const advance = () => pending.splice(0).forEach((p) => p.cb()); return { setTimer, clearTimer, advance, pending }; } it("emits exactly ONE trackEnded{reason:'error'} when playback never starts", async () => { const timer = makeFakeTimer(); const h = makeHarness({ deps: { playbackStartTimeoutMs: 8000, setTimeout: timer.setTimer as any, clearTimeout: timer.clearTimer as any, }, }); const ended = vi.fn(); h.backend.on("trackEnded", ended); // The connect layer NEVER reports our track playing (204 / idle). h.connect.getPlaybackState.mockResolvedValue(null); await h.backend.playTrack("spotify:track:stuck"); // Polls that never observe playback must NOT emit anything on their own. await (h.backend as any).pollState(); await (h.backend as any).pollState(); expect(ended).not.toHaveBeenCalled(); // The watchdog is armed exactly once; advance past playbackStartTimeoutMs. expect(timer.pending).toHaveLength(1); expect(timer.pending[0].ms).toBe(8000); timer.advance(); // Degraded-to-skip: one trackEnded with reason "error" for our uri. expect(ended).toHaveBeenCalledTimes(1); expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:stuck", reason: "error" }); // A late idle poll after the watchdog fired does not double-emit. await (h.backend as any).pollState(); expect(ended).toHaveBeenCalledTimes(1); h.backend.stop(); }); it("does NOT fire the watchdog when the device actually starts playing", async () => { const timer = makeFakeTimer(); const h = makeHarness({ deps: { playbackStartTimeoutMs: 8000, setTimeout: timer.setTimer as any, clearTimeout: timer.clearTimer as any, }, }); const ended = vi.fn(); h.backend.on("trackEnded", ended); await h.backend.playTrack("spotify:track:ok"); // Our device reports the track actually playing -> watchdog is disarmed. h.connect.getPlaybackState.mockResolvedValue({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:ok", durationMs: 200000, }); await (h.backend as any).pollState(); // Real playback observed -> the watchdog was cleared, not left armed. expect(timer.clearTimer).toHaveBeenCalled(); expect(timer.pending).toHaveLength(0); // Even if a stale timer somehow fired, no "error" end must be emitted. timer.advance(); expect(ended).not.toHaveBeenCalledWith( expect.objectContaining({ reason: "error" }), ); h.backend.stop(); }); it("a new playTrack() cancels the previous track's watchdog (at-most-one per track)", async () => { const timer = makeFakeTimer(); const h = makeHarness({ deps: { playbackStartTimeoutMs: 8000, setTimeout: timer.setTimer as any, clearTimeout: timer.clearTimer as any, }, }); const ended = vi.fn(); h.backend.on("trackEnded", ended); h.connect.getPlaybackState.mockResolvedValue(null); await h.backend.playTrack("spotify:track:one"); await h.backend.playTrack("spotify:track:two"); // The first track's watchdog was cleared; only the second remains armed. expect(timer.clearTimer).toHaveBeenCalled(); expect(timer.pending).toHaveLength(1); timer.advance(); expect(ended).toHaveBeenCalledTimes(1); expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:two", reason: "error" }); h.backend.stop(); }); }); describe("RustLibrespotBackend.stop", () => { it("kills librespot + ffmpeg, clears ready, and is idempotent", async () => { const h = makeHarness(); await h.backend.start(); h.backend.stop(); h.backend.stop(); // second call must not throw expect(h.librespotChild.kill).toHaveBeenCalled(); expect(h.ffmpegChild.kill).toHaveBeenCalled(); expect(h.backend.isReady()).toBe(false); }); }); describe("RustLibrespotBackend.start failure cleanup", () => { it("tears down librespot + ffmpeg when the device never appears", async () => { const h = makeHarness(); h.connect.getDevices.mockResolvedValue([]); // device never shows up -> waitForDevice times out await expect(h.backend.start()).rejects.toThrow(/did not appear/i); expect(h.librespotChild.kill).toHaveBeenCalled(); expect(h.ffmpegChild.kill).toHaveBeenCalled(); expect(h.backend.isReady()).toBe(false); }); }); describe("RustLibrespotBackend child-process error handling", () => { it("swallows+logs a child 'error' when no backend 'error' listener is attached", async () => { const h = makeHarness(); await h.backend.start(); expect(h.backend.listenerCount("error")).toBe(0); expect(() => h.librespotChild.emit("error", new Error("boom"))).not.toThrow(); expect(() => h.ffmpegChild.emit("error", new Error("boom"))).not.toThrow(); h.backend.stop(); }); it("re-emits a child 'error' to an attached backend 'error' listener", async () => { const h = makeHarness(); await h.backend.start(); const onErr = vi.fn(); h.backend.on("error", onErr); const err = new Error("ffmpeg boom"); h.ffmpegChild.emit("error", err); expect(onErr).toHaveBeenCalledWith(err); h.backend.stop(); }); // I(pipe): ffmpeg dying mid-track while librespot keeps producing PCM raises // EPIPE on ffmpeg.stdin. With no stdin 'error' listener Node escalates it to // process 'uncaughtException'. The backend must handle it in-band. it("swallows an EPIPE 'error' on ffmpeg.stdin (ffmpeg died mid-track) without an unhandled throw", async () => { const h = makeHarness(); await h.backend.start(); expect(h.backend.listenerCount("error")).toBe(0); const epipe = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); expect(() => h.ffmpegChild.stdin.emit("error", epipe)).not.toThrow(); h.backend.stop(); // idempotent second teardown must not throw }); it("routes an ffmpeg.stdin EPIPE to the backend 'error' listener and tears down cleanly", async () => { const h = makeHarness(); await h.backend.start(); const onErr = vi.fn(); h.backend.on("error", onErr); const epipe = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); expect(() => h.ffmpegChild.stdin.emit("error", epipe)).not.toThrow(); expect(onErr).toHaveBeenCalledWith(epipe); // Broken pipe -> clean teardown: children killed, not ready. expect(h.librespotChild.kill).toHaveBeenCalled(); expect(h.ffmpegChild.kill).toHaveBeenCalled(); expect(h.backend.isReady()).toBe(false); h.backend.stop(); // second teardown must not throw (no double-teardown crash) expect(onErr).toHaveBeenCalledTimes(1); // single emit despite both pipe ends }); it("does not throw when librespot proc.stdout emits an EPIPE on the broken pipe", async () => { const h = makeHarness(); await h.backend.start(); const epipe = Object.assign(new Error("read/write EPIPE"), { code: "EPIPE" }); expect(() => h.librespotChild.stdout.emit("error", epipe)).not.toThrow(); h.backend.stop(); }); });