mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
327 lines
13 KiB
TypeScript
327 lines
13 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 } 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 } = {}) {
|
|
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,
|
|
},
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it("pause/resume/seek delegate to the Connect API and seek updates position", async () => {
|
|
const h = makeHarness();
|
|
await h.backend.pause();
|
|
await h.backend.resume();
|
|
await h.backend.seek(5000);
|
|
expect(h.connect.pause).toHaveBeenCalled();
|
|
expect(h.connect.resume).toHaveBeenCalled();
|
|
expect(h.connect.seek).toHaveBeenCalledWith(5000);
|
|
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" });
|
|
});
|
|
|
|
it("emits trackEnded once when playback stops (!isPlaying) after having played", 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();
|
|
await (h.backend as any).pollState();
|
|
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("emits trackEnded when the track uri transitions to null 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 })
|
|
.mockResolvedValueOnce({ isPlaying: true, progressMs: 0, trackUri: null, durationMs: 0 });
|
|
await (h.backend as any).pollState();
|
|
await (h.backend as any).pollState();
|
|
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
|
|
});
|
|
|
|
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" });
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|