mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
815 lines
38 KiB
TypeScript
815 lines
38 KiB
TypeScript
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<RustLibrespotBackendDeps> } = {},
|
|
) {
|
|
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();
|
|
});
|
|
});
|