// tests/unit/system-audio-capture-redteam-r3-22.test.ts // SystemAudioCaptureController: stop-during-start 취소, off/on 중복 획득 방지, // getDisplayMedia 실패 시 loopback 해제를 fake port로 검증한다. import { describe, it, expect, vi } from 'vitest' import { SystemAudioCaptureController, type PcmPipeline, type SystemAudioPort, type SystemAudioStream, } from '../../src/renderer/utils/systemAudioCaptureController' import { float32ToPcm16 } from '../../src/renderer/utils/systemAudioCapture' interface Deferred { promise: Promise resolve: (v: T) => void reject: (e: unknown) => void } function deferred(): Deferred { let resolve!: (v: T) => void let reject!: (e: unknown) => void const promise = new Promise((res, rej) => { resolve = res reject = rej }) return { promise, resolve, reject } } class FakeStream implements SystemAudioStream { stopped = false videoDropped = false constructor(readonly id: number, private readonly audio = true) {} dropVideoTracks(): void { this.videoDropped = true } hasAudioTrack(): boolean { return this.audio } stop(): void { this.stopped = true } } class FakePipeline implements PcmPipeline { closed = false constructor(readonly stream: FakeStream, readonly onPcm: (b: ArrayBuffer) => void) {} close(): void { this.closed = true } } class FakePort implements SystemAudioPort { loopbackEnabled = false enableCalls = 0 disableCalls = 0 displayRequests: Deferred[] = [] pipelines: FakePipeline[] = [] /** 루프백 핸들러가 꺼진 상태에서 getDisplayMedia 요청 → 실제 Electron처럼 실패 */ requestsWithoutLoopback = 0 async enableLoopback(): Promise { this.enableCalls++ this.loopbackEnabled = true } async disableLoopback(): Promise { this.disableCalls++ this.loopbackEnabled = false } getDisplayMedia(): Promise { if (!this.loopbackEnabled) this.requestsWithoutLoopback++ const d = deferred() this.displayRequests.push(d) return d.promise } createPcmPipeline(stream: FakeStream, onPcm: (b: ArrayBuffer) => void): PcmPipeline { const p = new FakePipeline(stream, onPcm) this.pipelines.push(p) return p } openPipelines(): FakePipeline[] { return this.pipelines.filter((p) => !p.closed) } } /** 마이크로태스크 큐 비우기 */ async function flush(): Promise { for (let i = 0; i < 10; i++) await Promise.resolve() } function setup(): { port: FakePort; ctrl: SystemAudioCaptureController; onPcm: ReturnType } { const port = new FakePort() const onPcm = vi.fn() const ctrl = new SystemAudioCaptureController(port, { onPcm }) return { port, ctrl, onPcm } } describe('SystemAudioCaptureController', () => { it('starts: enable loopback → getDisplayMedia → disable loopback → pipeline', async () => { const { port, ctrl, onPcm } = setup() const started = ctrl.start() expect(ctrl.getState()).toBe('starting') await flush() const stream = new FakeStream(1) port.displayRequests[0].resolve(stream) await started expect(ctrl.getState()).toBe('active') expect(ctrl.isActive()).toBe(true) expect(port.enableCalls).toBe(1) expect(port.disableCalls).toBe(1) expect(port.loopbackEnabled).toBe(false) expect(stream.videoDropped).toBe(true) expect(port.openPipelines()).toHaveLength(1) expect(port.pipelines[0].onPcm).toBe(onPcm) ctrl.stop() expect(ctrl.getState()).toBe('idle') expect(port.pipelines[0].closed).toBe(true) expect(stream.stopped).toBe(true) }) it('stop while getDisplayMedia is pending cancels the start and releases the stream', async () => { const { port, ctrl } = setup() const started = ctrl.start() await flush() expect(port.displayRequests).toHaveLength(1) ctrl.stop() // 대기 중 STOP 도착 expect(ctrl.getState()).toBe('idle') const stream = new FakeStream(1) port.displayRequests[0].resolve(stream) await expect(started).resolves.toBeUndefined() expect(ctrl.getState()).toBe('idle') expect(stream.stopped).toBe(true) expect(port.pipelines).toHaveLength(0) // 캡처 파이프라인이 만들어지지 않음 expect(port.loopbackEnabled).toBe(false) }) it('stop issued synchronously after start acquires nothing', async () => { const { port, ctrl } = setup() const started = ctrl.start() ctrl.stop() await started expect(port.displayRequests).toHaveLength(0) expect(port.loopbackEnabled).toBe(false) expect(ctrl.getState()).toBe('idle') }) it('stop while enableLoopback is pending disables loopback without requesting media', async () => { const { port, ctrl } = setup() const enable = deferred() port.enableLoopback = async () => { port.enableCalls++ await enable.promise port.loopbackEnabled = true } const started = ctrl.start() await flush() expect(port.enableCalls).toBe(1) ctrl.stop() enable.resolve() await started expect(port.displayRequests).toHaveLength(0) expect(port.loopbackEnabled).toBe(false) expect(port.disableCalls).toBe(1) }) it('start while starting returns the same pending promise (no second acquisition)', async () => { const { port, ctrl } = setup() const a = ctrl.start() const b = ctrl.start() expect(b).toBe(a) await flush() expect(port.enableCalls).toBe(1) expect(port.displayRequests).toHaveLength(1) port.displayRequests[0].resolve(new FakeStream(1)) await a expect(port.openPipelines()).toHaveLength(1) }) it('start while active is a no-op', async () => { const { port, ctrl } = setup() const a = ctrl.start() await flush() port.displayRequests[0].resolve(new FakeStream(1)) await a await ctrl.start() expect(port.enableCalls).toBe(1) expect(port.openPipelines()).toHaveLength(1) }) it('on → off → on inside the pending window leaves exactly one stoppable capture', async () => { const { port, ctrl } = setup() const first = ctrl.start() await flush() ctrl.stop() const second = ctrl.start() await flush() // 두 번째 start는 첫 starter 정리 전까지 loopback/getDisplayMedia를 건드리지 않는다 expect(port.displayRequests).toHaveLength(1) const s1 = new FakeStream(1) port.displayRequests[0].resolve(s1) await first await flush() expect(s1.stopped).toBe(true) // 이제 두 번째 starter가 자기 loopback 핸들러로 요청 expect(port.displayRequests).toHaveLength(2) expect(port.requestsWithoutLoopback).toBe(0) const s2 = new FakeStream(2) port.displayRequests[1].resolve(s2) await second expect(ctrl.getState()).toBe('active') expect(port.openPipelines()).toHaveLength(1) expect(port.openPipelines()[0].stream).toBe(s2) ctrl.stop() expect(port.openPipelines()).toHaveLength(0) expect(s2.stopped).toBe(true) }) it('on → off → on → off leaves nothing running', async () => { const { port, ctrl } = setup() const first = ctrl.start() await flush() ctrl.stop() const second = ctrl.start() ctrl.stop() port.displayRequests[0].resolve(new FakeStream(1)) await first await second await flush() expect(ctrl.getState()).toBe('idle') expect(port.displayRequests).toHaveLength(1) // 두 번째 starter는 시작 전에 취소됨 expect(port.pipelines).toHaveLength(0) expect(port.loopbackEnabled).toBe(false) }) it('failed getDisplayMedia always disables loopback and rejects to the caller', async () => { const { port, ctrl } = setup() const started = ctrl.start() await flush() port.displayRequests[0].reject(new Error('denied')) await expect(started).rejects.toThrow('denied') expect(port.loopbackEnabled).toBe(false) expect(port.disableCalls).toBe(1) expect(ctrl.getState()).toBe('idle') // 실패 후 재시작 가능 const again = ctrl.start() await flush() port.displayRequests[1].resolve(new FakeStream(2)) await again expect(ctrl.getState()).toBe('active') }) it('failed getDisplayMedia after stop still disables loopback but does not reject', async () => { const { port, ctrl } = setup() const started = ctrl.start() await flush() ctrl.stop() port.displayRequests[0].reject(new Error('denied')) await expect(started).resolves.toBeUndefined() expect(port.loopbackEnabled).toBe(false) }) it('stream without an audio track is stopped and the start rejects', async () => { const { port, ctrl } = setup() const started = ctrl.start() await flush() const stream = new FakeStream(1, false) port.displayRequests[0].resolve(stream) await expect(started).rejects.toThrow('No audio track') expect(stream.stopped).toBe(true) expect(port.pipelines).toHaveLength(0) expect(ctrl.getState()).toBe('idle') }) it('stop is idempotent when nothing was started', () => { const { port, ctrl } = setup() expect(() => { ctrl.stop() ctrl.stop() }).not.toThrow() expect(ctrl.getState()).toBe('idle') expect(port.enableCalls).toBe(0) }) it('cleanup errors are reported, not thrown', async () => { const port = new FakePort() const onCleanupError = vi.fn() const ctrl = new SystemAudioCaptureController(port, { onPcm: vi.fn(), onCleanupError }) const started = ctrl.start() await flush() const stream = new FakeStream(1) stream.stop = () => { throw new Error('stop failed') } port.displayRequests[0].resolve(stream) await started expect(() => ctrl.stop()).not.toThrow() expect(onCleanupError).toHaveBeenCalledTimes(1) }) }) describe('float32ToPcm16', () => { it('clamps and scales samples to PCM16', () => { const out = new Int16Array(float32ToPcm16(new Float32Array([0, 1, -1, 2, -2, 0.5]))) expect(Array.from(out)).toEqual([0, 32767, -32768, 32767, -32768, 16383]) }) })