import { mkdtemp, readFile, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { PiProjectWriteLeaseCoordinator } from '../../electron/coding-runtime/pi/write-lease'; import { GameAudioDeliveryCoordinator, GameAudioReceiptStore } from '../../electron/services/game-audio-delivery'; import { GameAudioClientError, type AudioCommand, type AudioGeneration } from '../../electron/services/game-audio-client'; const directories: string[] = []; afterEach(async () => { await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); const execution = '00000000-0000-4000-8000-000000000601'; const project = '00000000-0000-4000-8000-000000000602'; const release = '00000000-0000-4000-8000-000000000505'; const command: AudioCommand = { release_id: release, release_admission_id: project, project_id: project, logical_operation_id: 'request', pricing_version_id: project, prompt: 'coin', confirmed: true, kind: 'sound', mode: 'pack', count: 2, duration: 2, loop: false }; function generation(active = false): AudioGeneration { return { schema_version: 1, plugin_id: 'makelore.game-audio', execution_id: execution, release_id: release, project_id: project, logical_operation_id: 'request', kind: 'sound', provider_status: active ? 'running' : 'succeeded', output_ready: !active, outputs: active ? [] : [{ index: 0, name: 'sound-1' }, { index: 1, name: 'sound-2' }], progress_percent: active ? 40 : 100, poll_interval_seconds: 2, error_code: null, billing: { mode: 'platform_metered', status: active ? 'dispatched' : 'settled', reserved_points: '0.08', actual_points: active ? null : '0.08', usage_amount: 8, unit: 'half_second' } }; } async function setup() { const root = await mkdtemp(path.join(os.tmpdir(), 'makelore-audio-')); directories.push(root); const receipts = new GameAudioReceiptStore(path.join(root, 'user-data', 'receipts.json')); const context = { accountKey: 'owner', conversationId: 'conversation', runId: 'run', localProjectId: 'local-project', durableProjectId: project, projectPath: root, logicalOperationId: 'request' }; const leases = new PiProjectWriteLeaseCoordinator(); const client = { generate: vi.fn(async () => generation(true)), get: vi.fn(async () => generation()), byOperation: vi.fn(async () => generation()), download: vi.fn(async () => ({ bytes: new TextEncoder().encode('ID3audio'), extension: 'mp3', mimeType: 'audio/mpeg' })), }; const recordTouchedPaths = vi.fn(async () => undefined); const options = { client, receipts, leases, recordTouchedPaths, getAccountKey: () => 'owner', sleep: vi.fn(async () => undefined) }; return { root, context, receipts, leases, client, options }; } describe('Game Audio durable automatic delivery', () => { it('persists intent before POST and saves all outputs under the frozen project without another confirmation', async () => { const s = await setup(); s.client.generate.mockImplementation(async () => { expect((await s.receipts.list())[0]?.context).toEqual(s.context); expect(s.leases.activeCount).toBe(0); return generation(true); }); s.client.download.mockImplementation(async () => { expect(s.leases.activeCount).toBe(0); return { bytes: new TextEncoder().encode('ID3audio'), extension: 'mp3', mimeType: 'audio/mpeg' }; }); const delivery = new GameAudioDeliveryCoordinator(s.options); const progress = vi.fn(); const result = await delivery.generateAndMaterialize(s.context, command, progress); expect(result.deliveryStatus).toBe('saved'); expect(result.files).toHaveLength(2); for (const file of result.files) expect(await readFile(path.join(s.root, file.path), 'utf8')).toBe('ID3audio'); expect(result.files[1]?.path).toBe('assets/generated/game-audio/' + execution + '/sound-2.mp3'); expect(s.options.recordTouchedPaths).toHaveBeenCalled(); expect(progress.mock.calls.some(([value]) => value.phase === 'generating')).toBe(true); await delivery.generateAndMaterialize(s.context, command); expect(s.client.generate).toHaveBeenCalledTimes(1); expect(s.client.download).toHaveBeenCalledTimes(2); expect(await delivery.readSavedOutput(execution, 1)).toEqual({ path: result.files[1]?.path, dataUrl: 'data:audio/mpeg;base64,' + Buffer.from('ID3audio').toString('base64'), }); await expect(delivery.readSavedOutput(execution, 9)).rejects.toMatchObject({ status: 404 }); const other = new GameAudioDeliveryCoordinator({ ...s.options, getAccountKey: () => 'other' }); await expect(other.readSavedOutput(execution, 0)).rejects.toMatchObject({ status: 404 }); other.dispose(); delivery.dispose(); }); it('recovers a lost POST response with by-operation, never re-submits', async () => { const s = await setup(); s.client.generate.mockRejectedValue(new Error('response lost')); const delivery = new GameAudioDeliveryCoordinator(s.options); expect((await delivery.generateAndMaterialize(s.context, command)).deliveryStatus).toBe('saved'); expect(s.client.byOperation).toHaveBeenCalledWith('request', 'owner'); expect(s.client.generate).toHaveBeenCalledTimes(1); delivery.dispose(); }); it('reports a definite pre-dispatch rejection as not started and never resumes it', async () => { const s = await setup(); s.client.generate.mockRejectedValue(new GameAudioClientError('plugin_pricing_changed', 409, 'Price changed')); const delivery = new GameAudioDeliveryCoordinator(s.options); expect(await delivery.generateAndMaterialize(s.context, command)) .toMatchObject({ providerStatus: 'failed', billing: { status: 'not_started' } }); await delivery.resumePending(); expect(s.client.byOperation).not.toHaveBeenCalled(); delivery.dispose(); }); it('automatically resumes a transient download failure without resubmitting', async () => { const s = await setup(); s.client.download.mockRejectedValueOnce(new Error('offline')); vi.useFakeTimers(); const delivery = new GameAudioDeliveryCoordinator(s.options); try { expect((await delivery.generateAndMaterialize(s.context, command)).deliveryStatus).toBe('delivery_failed'); await vi.advanceTimersByTimeAsync(30_000); vi.useRealTimers(); await vi.waitFor(async () => expect((await s.receipts.list())[0]?.deliveryStatus).toBe('saved')); expect(s.client.generate).toHaveBeenCalledTimes(1); } finally { delivery.dispose(); vi.useRealTimers(); } }); it('resumes only the missing output after restart and pauses for another account', async () => { const s = await setup(); s.client.download.mockResolvedValueOnce({ bytes: new TextEncoder().encode('ID3one'), extension: 'mp3', mimeType: 'audio/mpeg' }) .mockRejectedValue(new Error('download offline')); const first = new GameAudioDeliveryCoordinator(s.options); expect((await first.generateAndMaterialize(s.context, command)).deliveryStatus).toBe('delivery_failed'); first.dispose(); s.client.download.mockClear().mockResolvedValue({ bytes: new TextEncoder().encode('ID3two'), extension: 'mp3', mimeType: 'audio/mpeg' }); const other = new GameAudioDeliveryCoordinator({ ...s.options, getAccountKey: () => 'other' }); await other.resumePending(); expect(s.client.download).not.toHaveBeenCalled(); other.dispose(); const restored = new GameAudioDeliveryCoordinator(s.options); await restored.resumePending(); expect(s.client.generate).toHaveBeenCalledTimes(1); expect(s.client.download).toHaveBeenCalledTimes(1); expect(s.client.download).toHaveBeenCalledWith(execution, 1, 'owner'); expect((await s.receipts.list())[0]?.deliveryStatus).toBe('saved'); restored.dispose(); }); });