/* Copyright 2026 Lotus Guild SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ import { EventEmitter } from "events"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { of } from "rxjs"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; import { startLotusAudioInject } from "./lotusAudioInject"; import { LotusWidgetActions } from "./lotusActions"; const lazyActions = new EventEmitter(); const reply = vi.fn(); vi.mock("../widget", () => ({ widget: { api: { transport: { reply: (...args: unknown[]) => reply(...args) } }, // Getter: `vi.mock` factories run at import time, before the const above. get lazyActions(): EventEmitter { return lazyActions; }, }, })); function send(data: unknown): void { lazyActions.emit(LotusWidgetActions.InjectAudio, { detail: { data } }); } /** Flush the microtask queue enough times to drain the async awaits in * `playInjectedClip` (fetch -> arrayBuffer -> resume -> decodeAudioData -> * publishTrack...), none of which use real timers in this test. */ async function flush(times = 30): Promise { for (let i = 0; i < times; i++) await Promise.resolve(); } function makeTrack(): MediaStreamTrack { return { clone: vi.fn(() => makeTrack()), stop: vi.fn(), } as unknown as MediaStreamTrack; } function makeRoom(isMicrophoneEnabled: boolean): { localParticipant: { isMicrophoneEnabled: boolean; publishTrack: ReturnType; unpublishTrack: ReturnType; }; } { return { localParticipant: { isMicrophoneEnabled, publishTrack: vi.fn(async (clone: unknown) => { await Promise.resolve(); return { track: clone }; }), unpublishTrack: vi.fn().mockResolvedValue(undefined), }, }; } function mockVm(rooms: unknown[]): CallViewModel { return { allConnections$: of({ getConnections: () => rooms.map((livekitRoom) => ({ livekitRoom })), }), } as unknown as CallViewModel; } /** Fake AudioContext/nodes good enough to drive playInjectedClip end to end, * tracking how many contexts and destinations get constructed (#14). */ let contextInstances: FakeAudioContext[]; class FakeAudioContext { public state = "running"; public resume = vi.fn().mockResolvedValue(undefined); public close = vi.fn().mockResolvedValue(undefined); public decodeAudioData = vi .fn() .mockResolvedValue({ duration: 0.01 } as AudioBuffer); public createMediaStreamDestination = vi.fn(() => ({ stream: { getAudioTracks: (): MediaStreamTrack[] => [makeTrack()] }, })); public createGain = vi.fn(() => ({ gain: { value: 0 }, connect: vi.fn((n: unknown) => n), disconnect: vi.fn(), })); public createBufferSource = vi.fn(() => ({ buffer: undefined as AudioBuffer | undefined, connect: vi.fn((n: unknown) => n), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), onended: null as (() => void) | null, addEventListener: vi.fn(), })); public constructor() { contextInstances.push(this); } } beforeEach(() => { contextInstances = []; reply.mockClear(); // The module sets a real `setTimeout` guard per clip (MAX_CLIP_MS safety // net); use fake timers so a test ending before that guard fires doesn't // leave a real timer pending. vi.useFakeTimers(); vi.stubGlobal("AudioContext", FakeAudioContext); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ ok: true, arrayBuffer: async () => { await Promise.resolve(); return new ArrayBuffer(8); }, }), ); // `lotusParam`/`lotusFlag` re-parse `window.location` on every call, so // just set the flag before each test. window.location.hash = "#/room?lotusAudioInject=1"; }); afterEach(() => { lazyActions.removeAllListeners(); vi.unstubAllGlobals(); vi.useRealTimers(); }); test("#13: a muted local mic blocks the clip and replies with a machine-readable reason", async () => { const room = makeRoom(false); const vm = mockVm([room]); const stop = startLotusAudioInject(vm); send({ url: "https://example.com/clip.mp3" }); await flush(); expect(reply).toHaveBeenCalledWith(expect.anything(), { played: false, reason: "muted", }); expect(room.localParticipant.publishTrack).not.toHaveBeenCalled(); // No context should even be created for a clip that never plays. expect(contextInstances).toHaveLength(0); stop(); }); test("#13: an unmuted local mic plays the clip normally", async () => { const room = makeRoom(true); const vm = mockVm([room]); const stop = startLotusAudioInject(vm); send({ url: "https://example.com/clip.mp3" }); await flush(); expect(reply).toHaveBeenCalledWith(expect.anything(), {}); expect(room.localParticipant.publishTrack).toHaveBeenCalledTimes(1); stop(); }); test("#14: one shared AudioContext/destination is reused across clips, and closed only on the last teardown", async () => { const room = makeRoom(true); const vm = mockVm([room]); const stop = startLotusAudioInject(vm); send({ url: "https://example.com/a.mp3" }); await flush(); expect(contextInstances).toHaveLength(1); const ctx = contextInstances[0]; expect(ctx.createMediaStreamDestination).toHaveBeenCalledTimes(1); // Finish the first clip (as if it played to completion) before starting a // second one, matching the module's own "one clip at a time" contract. const firstSource = ctx.createBufferSource.mock.results[0]!.value as { onended: (() => void) | null; }; firstSource.onended?.(); await flush(); expect(room.localParticipant.unpublishTrack).toHaveBeenCalledTimes(1); // A second clip must reuse the SAME context/destination rather than // creating a new one. send({ url: "https://example.com/b.mp3" }); await flush(); expect(contextInstances).toHaveLength(1); expect(ctx.createMediaStreamDestination).toHaveBeenCalledTimes(1); expect(ctx.close).not.toHaveBeenCalled(); const secondSource = ctx.createBufferSource.mock.results[1]!.value as { onended: (() => void) | null; }; secondSource.onended?.(); await flush(); // Tearing down the (only) active instance closes the shared context. stop(); expect(ctx.close).toHaveBeenCalledTimes(1); }); test("#14: the shared context stays open while another instance is still active", async () => { const roomA = makeRoom(true); const roomB = makeRoom(true); const stopA = startLotusAudioInject(mockVm([roomA])); const stopB = startLotusAudioInject(mockVm([roomB])); send({ url: "https://example.com/a.mp3" }); await flush(); expect(contextInstances).toHaveLength(1); const ctx = contextInstances[0]; // Tearing down the first (of two) active instances must not close the // context out from under the other one. stopA(); expect(ctx.close).not.toHaveBeenCalled(); stopB(); expect(ctx.close).toHaveBeenCalledTimes(1); });