feat: add official game audio generation client
This commit is contained in:
@@ -82,9 +82,10 @@ async function installCodingFirstChatHost(
|
||||
hostConnection: HostConnection,
|
||||
featureComplete = false,
|
||||
managedCapabilities = false,
|
||||
audioPreview?: { executionId: string; path: string; dataUrl: string },
|
||||
): Promise<void> {
|
||||
await electronApp.evaluate(async (_, payload) => {
|
||||
const { connection, featureComplete, managedCapabilities } = payload;
|
||||
const { connection, featureComplete, managedCapabilities, audioPreview } = payload;
|
||||
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
|
||||
type MainState = {
|
||||
captured: CapturedRequest[];
|
||||
@@ -456,6 +457,9 @@ async function installCodingFirstChatHost(
|
||||
) => {
|
||||
const path = request.path ?? '';
|
||||
const method = request.method ?? 'GET';
|
||||
if (audioPreview && path === '/api/coding/game-audio/' + audioPreview.executionId + '/outputs/0') {
|
||||
return respond({ path: audioPreview.path, dataUrl: audioPreview.dataUrl });
|
||||
}
|
||||
const body = typeof request.body === 'string' && request.body
|
||||
? JSON.parse(request.body) as Record<string, unknown>
|
||||
: undefined;
|
||||
@@ -737,9 +741,71 @@ async function installCodingFirstChatHost(
|
||||
if (path === '/api/coding/runtime/diagnostics') return respond({ runtime: { revision: { provider: 1, resources: 1 }, workers: [{ conversationId: conversation.id, generation: 1, state: 'running', stage: 'running' }] } });
|
||||
return respond({ success: false, error: `Unhandled E2E route: ${method} ${path}` }, 404);
|
||||
});
|
||||
}, { connection: hostConnection, featureComplete, managedCapabilities });
|
||||
}, { connection: hostConnection, featureComplete, managedCapabilities, audioPreview });
|
||||
}
|
||||
|
||||
test('saved Game Audio offers click-only playable local preview in Electron', async ({ launchElectronApp }, testInfo) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
let page = await getStableWindow(app);
|
||||
const connection = await page.evaluate(async () => ({
|
||||
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
|
||||
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
|
||||
}));
|
||||
const executionId = '00000000-0000-4000-8000-000000000601';
|
||||
const relative = 'assets/generated/game-audio/' + executionId + '/sound-1.wav';
|
||||
// Synthetic quarter-second PCM sample, never a claimed Meowa output.
|
||||
const wav = Buffer.alloc(4044);
|
||||
wav.write('RIFF', 0); wav.writeUInt32LE(4036, 4); wav.write('WAVEfmt ', 8);
|
||||
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
|
||||
wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28);
|
||||
wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);
|
||||
wav.write('data', 36); wav.writeUInt32LE(4000, 40);
|
||||
await installCodingFirstChatHost(app, connection, true, false, {
|
||||
executionId, path: relative, dataUrl: 'data:audio/wav;base64,' + wav.toString('base64'),
|
||||
});
|
||||
await settleSnapshot(app);
|
||||
await disableCodingEventSource(page);
|
||||
await page.reload();
|
||||
page = await getStableWindow(app);
|
||||
await page.getByTestId('ai-module-option-programming').click();
|
||||
await page.evaluate(() => { window.location.hash = '/chat'; });
|
||||
await expect(page.getByTestId('coding-conversation-header')).toBeVisible();
|
||||
const snapshot = await page.evaluate(async () => {
|
||||
const response = await window.electron.ipcRenderer.invoke('hostapi:fetch', {
|
||||
path: '/api/coding/conversations/conversation-pi-first-chat/snapshot', method: 'GET',
|
||||
}) as { data: { json: { snapshot: Record<string, unknown> } } };
|
||||
return response.data.json.snapshot;
|
||||
});
|
||||
const details = {
|
||||
schema: 'makelore-capability.v1', plugin_id: 'makelore.game-audio', plugin_version: '1.0.0',
|
||||
capability_id: 'game-audio.sound', operation: 'generate', request_id: 'pi:r:t',
|
||||
success: true, status: 200, code: null, error: null, retryable: false, payload_schema: 'game-audio.v1',
|
||||
billing: { mode: 'platform_metered', status: 'settled', reserved_points: '0.04',
|
||||
actual_points: '0.04', usage_amount: 4, unit: 'half_second' },
|
||||
data: { executionId, logicalOperationId: 'pi:r:t', projectId: 'project-e2e',
|
||||
providerStatus: 'succeeded', deliveryStatus: 'saved', phase: 'saved', outputCount: 1,
|
||||
files: [{ index: 0, path: relative, bytes: wav.length, mimeType: 'audio/wav' }] },
|
||||
};
|
||||
await emitCodingEvent(page, 'snapshot', {
|
||||
type: 'snapshot', conversationId: 'conversation-pi-first-chat', workerGeneration: 1, seq: 10,
|
||||
snapshot: { ...snapshot, cursor: { workerGeneration: 1, seq: 10 }, nodes: [{
|
||||
kind: 'tool', id: 'audio-preview', toolCallId: 'audio-preview', toolName: 'game_sound_generate',
|
||||
title: '生成游戏音效', status: 'complete', inputText: '', output: [], details,
|
||||
}] },
|
||||
});
|
||||
await expect(page.getByTestId('tool-progress-preview')).toContainText('游戏音频 · 已保存');
|
||||
await page.getByTestId('coding-process-group').locator('summary').first().click();
|
||||
await page.locator('[data-node-id="audio-preview"] > summary').click();
|
||||
await expect(page.locator('audio')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '试听 sound-1.wav' }).click();
|
||||
const player = page.getByLabel('试听 sound-1.wav', { exact: true });
|
||||
await expect(player).toHaveAttribute('controls', '');
|
||||
expect(await player.getAttribute('autoplay')).toBeNull();
|
||||
await expect.poll(() => player.evaluate((element) => (element as HTMLAudioElement).duration)).toBe(0.25);
|
||||
expect(await player.evaluate((element) => (element as HTMLAudioElement).paused)).toBe(true);
|
||||
await page.screenshot({ path: testInfo.outputPath('audio-preview.png') });
|
||||
});
|
||||
|
||||
async function readState(electronApp: ElectronApplication): Promise<{
|
||||
captured: CapturedRequest[];
|
||||
snapshotPending: boolean;
|
||||
|
||||
@@ -12,15 +12,19 @@ vi.mock('@/lib/coding-attachments', () => ({
|
||||
describe('CodingConversationTimeline', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('shows one Game Resource progress card from generation through automatic project save', async () => {
|
||||
it.each([
|
||||
['makelore.game-resource', '游戏资源'],
|
||||
['makelore.game-audio', '游戏音频'],
|
||||
])('shows one %s progress card through automatic project save', async (pluginId, label) => {
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
const { CodingConversationTimeline } = await import(
|
||||
'@/pages/Chat/CodingConversationTimeline'
|
||||
);
|
||||
const base = createProductSnapshot('conversation-game-resource-progress', 1);
|
||||
const conversationId = 'conversation-progress-' + pluginId;
|
||||
const base = createProductSnapshot(conversationId, 1);
|
||||
const details = (phase: 'saving' | 'saved') => ({
|
||||
schema: 'makelore-capability.v1' as const,
|
||||
plugin_id: 'makelore.game-resource',
|
||||
plugin_id: pluginId,
|
||||
plugin_version: '1.0.0',
|
||||
capability_id: 'game-resource.generate',
|
||||
operation: 'generate',
|
||||
@@ -82,28 +86,28 @@ describe('CodingConversationTimeline', () => {
|
||||
};
|
||||
codingConversationStore.getState().applySnapshotEvent({
|
||||
type: 'snapshot',
|
||||
conversationId: 'conversation-game-resource-progress',
|
||||
conversationId,
|
||||
workerGeneration: 1,
|
||||
seq: base.cursor.seq,
|
||||
snapshot: snapshot(base.cursor.seq, 'saving'),
|
||||
});
|
||||
|
||||
render(<CodingConversationTimeline conversationId="conversation-game-resource-progress" />);
|
||||
render(<CodingConversationTimeline conversationId={conversationId} />);
|
||||
|
||||
expect(screen.getByTestId('tool-progress-preview')).toHaveTextContent(
|
||||
'游戏资源 · 正在保存到项目',
|
||||
label + ' · 正在保存到项目',
|
||||
);
|
||||
|
||||
const nextSeq = base.cursor.seq + 1;
|
||||
act(() => codingConversationStore.getState().applySnapshotEvent({
|
||||
type: 'snapshot',
|
||||
conversationId: 'conversation-game-resource-progress',
|
||||
conversationId,
|
||||
workerGeneration: 1,
|
||||
seq: nextSeq,
|
||||
snapshot: snapshot(nextSeq, 'saved'),
|
||||
}));
|
||||
await waitFor(() => expect(screen.getByTestId('tool-progress-preview'))
|
||||
.toHaveTextContent('游戏资源 · 已保存 2 个文件'));
|
||||
.toHaveTextContent(label + ' · 已保存 2 个文件'));
|
||||
});
|
||||
|
||||
it('resolves attachment refs into temporary object URLs without base64 state', async () => {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
DATA_SERVICE_PLUGIN_DEFINITION,
|
||||
GAME_RESOURCE_BUNDLED_RELEASE_ID,
|
||||
GAME_RESOURCE_PLUGIN_ID,
|
||||
GAME_AUDIO_PLUGIN_ID,
|
||||
GAME_AUDIO_BUNDLED_RELEASE_ID,
|
||||
PROJECT_SCAFFOLD_BUNDLED_RELEASE_ID,
|
||||
PROJECT_SCAFFOLD_PLUGIN_ID,
|
||||
type CodingPluginDefinition,
|
||||
@@ -458,11 +460,14 @@ describe('effective plugin resolver', () => {
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('materializes an acquired code-owned bundled hosted Plugin without Package Store bytes or Agent assignment', async () => {
|
||||
it.each([
|
||||
[GAME_RESOURCE_PLUGIN_ID, GAME_RESOURCE_BUNDLED_RELEASE_ID],
|
||||
[GAME_AUDIO_PLUGIN_ID, GAME_AUDIO_BUNDLED_RELEASE_ID],
|
||||
])('materializes acquired bundled %s without Package Store bytes or Agent assignment', async (pluginId, releaseId) => {
|
||||
const bundled: CodingPluginDefinition = {
|
||||
...serverDefinition,
|
||||
id: GAME_RESOURCE_PLUGIN_ID,
|
||||
releaseId: GAME_RESOURCE_BUNDLED_RELEASE_ID,
|
||||
id: pluginId,
|
||||
releaseId,
|
||||
provenance: { source: 'bundled', packageRoot: 'game-resource' },
|
||||
skills: [{
|
||||
id: 'game-resource',
|
||||
|
||||
@@ -38,13 +38,15 @@ describe('bundled coding plugin manifests', () => {
|
||||
'data-service',
|
||||
'game-resource',
|
||||
'project-scaffold',
|
||||
'game-audio',
|
||||
]);
|
||||
expect(resolveBundledCodingPluginRootPaths(path.resolve('resources/coding-plugins'))).toEqual([
|
||||
PACKAGE_ROOT,
|
||||
GAME_RESOURCE_ROOT,
|
||||
PROJECT_SCAFFOLD_ROOT,
|
||||
path.resolve('resources/coding-plugins/game-audio'),
|
||||
]);
|
||||
expect(definitions).toHaveLength(3);
|
||||
expect(definitions).toHaveLength(4);
|
||||
expect(definitions[0]).toMatchObject({
|
||||
id: 'makelore.data-service',
|
||||
adapterId: 'data-service',
|
||||
@@ -53,7 +55,7 @@ describe('bundled coding plugin manifests', () => {
|
||||
});
|
||||
expect(definitions[0]?.tools.map(({ name }) => name)).toEqual(DATA_SERVICE_TOOL_NAMES);
|
||||
expect(definitions[0]).toEqual(DATA_SERVICE_PLUGIN_DEFINITION);
|
||||
expect(definitions.slice(1)).toMatchObject([
|
||||
expect(definitions.slice(1, 3)).toMatchObject([
|
||||
{
|
||||
id: 'makelore.game-resource', version: '1.0.0', runtimeKind: 'platform_hosted',
|
||||
acquisitionMode: 'user_acquired', releaseId: '00000000-0000-4000-8000-000000000105',
|
||||
@@ -160,6 +162,7 @@ describe('bundled coding plugin manifests', () => {
|
||||
path.resolve('tmp/data-service'),
|
||||
path.resolve('tmp/game-resource'),
|
||||
path.resolve('tmp/project-scaffold'),
|
||||
path.resolve('tmp/game-audio'),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -46,6 +46,16 @@ async function invoke(ctx: HostApiContext, method: string, target: string, body?
|
||||
}
|
||||
|
||||
describe('coding plugin Host routes', () => {
|
||||
it('previews only a receipt output, without accepting project paths or remote URLs', async () => {
|
||||
const readSavedOutput = vi.fn().mockResolvedValue({ path: 'assets/generated/game-audio/file.mp3', dataUrl: 'data:audio/mpeg;base64,SUQz' });
|
||||
const ctx = { codingProducts: { gameAudio: { readSavedOutput } } } as unknown as HostApiContext;
|
||||
const id = '00000000-0000-4000-8000-000000000601';
|
||||
const route = '/api/coding/game-audio/' + id + '/outputs/0';
|
||||
expect(await invoke(ctx, 'GET', route)).toMatchObject({ handled: true, status: 200 });
|
||||
expect(readSavedOutput).toHaveBeenCalledWith(id, 0);
|
||||
expect(await invoke(ctx, 'GET', route + '?projectPath=/forged')).toMatchObject({ status: 400 });
|
||||
expect(readSavedOutput).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('uses only the local project handle for GET and preserves the bounded projection', async () => {
|
||||
const projection = {
|
||||
schemaVersion: 1 as const,
|
||||
|
||||
85
tests/unit/game-audio-client.test.ts
Normal file
85
tests/unit/game-audio-client.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { GameAudioClient } from '../../electron/services/game-audio-client';
|
||||
|
||||
const execution = '00000000-0000-4000-8000-000000000601';
|
||||
const release = '00000000-0000-4000-8000-000000000505';
|
||||
const project = '00000000-0000-4000-8000-000000000602';
|
||||
const receipt = () => ({
|
||||
schema_version: 1, plugin_id: 'makelore.game-audio', execution_id: execution,
|
||||
release_id: release, project_id: project, logical_operation_id: 'audio-request',
|
||||
kind: 'sound', provider_status: 'succeeded', output_ready: true,
|
||||
outputs: [{ index: 0, name: 'sound-1' }], progress_percent: 100,
|
||||
poll_interval_seconds: 2, error_code: null,
|
||||
billing: { mode: 'platform_metered', status: 'settled', reserved_points: '0.16',
|
||||
actual_points: '0.04', usage_amount: 4, unit: 'half_second' },
|
||||
});
|
||||
const command = {
|
||||
release_id: release, release_admission_id: project, project_id: project,
|
||||
logical_operation_id: 'audio-request', pricing_version_id: project,
|
||||
kind: 'sound' as const, mode: 'single' as const, duration: 2, count: 1,
|
||||
loop: false, prompt: 'A coin chime', confirmed: true as const,
|
||||
};
|
||||
function setup(fetchImpl: typeof fetch) {
|
||||
return new GameAudioClient({ fetchImpl, apiBaseUrl: 'https://square.test',
|
||||
getAccessToken: async () => 'session-token',
|
||||
getAccountBinding: () => ({ accountKey: 'owner', epoch: 1 }) });
|
||||
}
|
||||
describe('Game Audio typed Main transport', () => {
|
||||
it('normalizes nullable unsettled amounts into the shared optional receipt field', async () => {
|
||||
const wire = { ...receipt(), provider_status: 'running', output_ready: false, outputs: [],
|
||||
billing: { ...receipt().billing, status: 'dispatched', actual_points: null } };
|
||||
const job = await setup(vi.fn<typeof fetch>().mockResolvedValue(Response.json(wire))).get(execution, 'owner');
|
||||
expect(job.billing).not.toHaveProperty('actual_points');
|
||||
});
|
||||
it('uses fixed routes and retries a 401 only once with exactly the same command', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response('', { status: 401 }))
|
||||
.mockResolvedValueOnce(Response.json(receipt()));
|
||||
const client = setup(fetchImpl);
|
||||
expect(await client.generate(command, 'owner')).toEqual(receipt());
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(fetchImpl.mock.calls[0]?.[0]).toBe('https://square.test/api/plugins/v1/hosted/game-audio/generations');
|
||||
expect(fetchImpl.mock.calls[0]?.[1]?.body).toBe(JSON.stringify(command));
|
||||
expect(fetchImpl.mock.calls[1]?.[1]?.body).toBe(fetchImpl.mock.calls[0]?.[1]?.body);
|
||||
});
|
||||
it('rejects contradictory output/billing and response identity', async () => {
|
||||
for (const value of [
|
||||
{ ...receipt(), output_ready: false },
|
||||
{ ...receipt(), execution_id: '../escape' },
|
||||
{ ...receipt(), project_id: execution },
|
||||
{ ...receipt(), billing: { ...receipt().billing, actual_points: 'bad' } },
|
||||
{ ...receipt(), outputs: [{ index: 1, name: 'sound-2' }] },
|
||||
]) {
|
||||
await expect(setup(vi.fn<typeof fetch>().mockResolvedValue(Response.json(value)))
|
||||
.generate(command, 'owner')).rejects.toMatchObject({ code: 'plugin_backend_invalid' });
|
||||
}
|
||||
});
|
||||
it('checks account binding before network and again after a response', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(Response.json(receipt()));
|
||||
await expect(setup(fetchImpl).get(execution, 'other')).rejects.toMatchObject({ code: 'plugin_account_changed' });
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
let epoch = 1;
|
||||
const client = new GameAudioClient({
|
||||
getAccessToken: async () => 'token', getAccountBinding: () => ({ accountKey: 'owner', epoch }),
|
||||
fetchImpl: async () => { epoch++; return Response.json(receipt()); },
|
||||
});
|
||||
await expect(client.get(execution, 'owner')).rejects.toMatchObject({ code: 'plugin_account_changed' });
|
||||
});
|
||||
it('bounds chunked JSON and downloads only supported audio MIME', async () => {
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) { controller.enqueue(new Uint8Array(600_000)); },
|
||||
cancel() { cancelled = true; },
|
||||
});
|
||||
await expect(setup(vi.fn<typeof fetch>().mockResolvedValue(new Response(body)))
|
||||
.get(execution, 'owner')).rejects.toMatchObject({ code: 'plugin_backend_invalid' });
|
||||
expect(cancelled).toBe(true);
|
||||
await expect(setup(vi.fn<typeof fetch>().mockResolvedValue(new Response('html', {
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
}))).download(execution, 0, 'owner')).rejects.toMatchObject({ code: 'game_audio_content_invalid' });
|
||||
const audio = await setup(vi.fn<typeof fetch>().mockResolvedValue(new Response('ID3test', {
|
||||
headers: { 'Content-Type': 'audio/mpeg' },
|
||||
}))).download(execution, 0, 'owner');
|
||||
expect(audio.extension).toBe('mp3');
|
||||
});
|
||||
});
|
||||
127
tests/unit/game-audio-delivery.test.ts
Normal file
127
tests/unit/game-audio-delivery.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
19
tests/unit/game-audio-files.test.tsx
Normal file
19
tests/unit/game-audio-files.test.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { GameAudioFiles } from '../../src/pages/Chat/GameAudioFiles';
|
||||
const mocks = vi.hoisted(() => ({ read: vi.fn() }));
|
||||
vi.mock('@/lib/host-api', () => ({ hostApiFetch: mocks.read }));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
it('loads only saved local audio on explicit preview; never auto-plays or asks to save again', async () => {
|
||||
const executionId = '00000000-0000-4000-8000-000000000601';
|
||||
const relative = 'assets/generated/game-audio/00000000-0000-4000-8000-000000000601/sound-1.mp3';
|
||||
mocks.read.mockResolvedValue({ path: relative, dataUrl: 'data:audio/mpeg;base64,SUQz' });
|
||||
const { unmount } = render(<GameAudioFiles data={{ executionId, files: [{ index: 0, path: relative, bytes: 3 }] }} />);
|
||||
expect(mocks.read).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '试听 sound-1.mp3' }));
|
||||
const player = await screen.findByLabelText('试听 sound-1.mp3', { selector: 'audio' });
|
||||
expect(player).toHaveAttribute('controls');
|
||||
expect(player).not.toHaveAttribute('autoplay');
|
||||
expect(mocks.read).toHaveBeenCalledWith('/api/coding/game-audio/' + executionId + '/outputs/0');
|
||||
unmount();
|
||||
});
|
||||
99
tests/unit/game-audio-plugin.test.ts
Normal file
99
tests/unit/game-audio-plugin.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment node
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { loadBundledCodingPluginDefinitionsSync } from '../../electron/coding-plugins/manifest';
|
||||
import { isProjectWideCodingPluginId } from '../../shared/coding-plugins';
|
||||
import { GameAudioPluginAdapter } from '../../electron/coding-plugins/adapters/game-audio';
|
||||
import { CodingCapabilityRegistryImpl } from '../../electron/coding-plugins/registry';
|
||||
import type { PluginPolicyClientState } from '../../electron/services/plugin-policy-client';
|
||||
import type { AudioDeliveryResult } from '../../electron/services/game-audio-delivery';
|
||||
|
||||
const context = { conversationId: 'conversation', runId: 'run', resourceId: 'tool',
|
||||
requestId: 'trusted-id', localProjectId: 'local', projectPath: '/project',
|
||||
durableProjectId: 'durable', workerRole: 'parent' as const, effectiveSkillIds: ['game-audio'],
|
||||
pluginReleaseId: '00000000-0000-4000-8000-000000000505' };
|
||||
function definition() {
|
||||
return loadBundledCodingPluginDefinitionsSync(path.resolve('resources/coding-plugins'))
|
||||
.find((item) => item.id === 'makelore.game-audio')!;
|
||||
}
|
||||
describe('official Game Audio plugin', () => {
|
||||
it('passes normalized receipts through the registry with progress and saved files', async () => {
|
||||
const plugin = definition();
|
||||
const delivered: AudioDeliveryResult = {
|
||||
executionId: '00000000-0000-4000-8000-000000000601', logicalOperationId: 'request',
|
||||
providerStatus: 'succeeded', deliveryStatus: 'saved', phase: 'saved', progressPercent: 100,
|
||||
outputCount: 1, files: [{ index: 0, path: 'assets/generated/game-audio/00000000-0000-4000-8000-000000000601/music-1.mp3', bytes: 10, mimeType: 'audio/mpeg' }],
|
||||
billing: { mode: 'platform_metered', status: 'settled', reserved_points: '1.00', actual_points: '1.00', usage_amount: 1, unit: 'generation' },
|
||||
};
|
||||
const adapter = new GameAudioPluginAdapter({
|
||||
delivery: { generateAndMaterialize: async (_context, _command, progress) => {
|
||||
progress?.({ ...delivered, providerStatus: 'running', deliveryStatus: 'waiting', phase: 'generating', files: [],
|
||||
billing: { mode: 'platform_metered', status: 'dispatched', reserved_points: '1.00', usage_amount: 1, unit: 'generation' } });
|
||||
return delivered;
|
||||
} },
|
||||
client: { cancel: vi.fn() },
|
||||
admission: { resolve: vi.fn(async () => ({ releaseId: context.pluginReleaseId, releaseAdmissionId: 'admission' })) },
|
||||
getPricingVersion: () => 'price', getAccountKey: () => 'owner',
|
||||
});
|
||||
const policy: PluginPolicyClientState = { status: 'current', revision: 1, lastVerifiedAt: 1,
|
||||
catalog: { schema_version: 1, catalog_version: '1', pricing_version: 'price',
|
||||
plugins: [{ plugin_id: plugin.id, supported_contract_versions: [1], status: 'active',
|
||||
capabilities: plugin.operations.map((op) => ({ capability_id: op.capabilityId, operations: [{
|
||||
operation: op.operation, billing: { mode: 'platform_metered', entitlement_scope: 'plugin_usage', notice: 'Metered',
|
||||
unit_name: 'generation', unit_size: 1, rate_points: '1.00', minimum_charge_points: '0.00', rounding_mode: 'ceil' },
|
||||
}] })) }] } };
|
||||
const registry = new CodingCapabilityRegistryImpl({ definitions: [plugin], adapters: [adapter],
|
||||
getEnabledPluginIds: async () => [plugin.id], policyClient: { getState: () => policy, refresh: vi.fn() } });
|
||||
const onUpdate = vi.fn();
|
||||
const output = await registry.invoke({ toolName: 'game_music_generate', workerRole: 'parent',
|
||||
effectiveSkillIds: ['game-audio'], context: { conversationId: 'c', runId: 'r', resourceId: 't',
|
||||
projectId: 'p', projectPath: '/project', skillIds: ['game-audio'] },
|
||||
value: { prompt: 'forest', confirmed: true }, onUpdate });
|
||||
expect(output.details).toMatchObject({ success: true, data: { files: delivered.files } });
|
||||
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
details: expect.objectContaining({ success: true, billing: { ...delivered.billing, status: 'dispatched', actual_points: undefined } }),
|
||||
}));
|
||||
});
|
||||
it('ships one project-wide Skill and exactly four statically priced parent tools', () => {
|
||||
const plugin = definition();
|
||||
expect(plugin).toBeDefined();
|
||||
expect(isProjectWideCodingPluginId(plugin.id)).toBe(true);
|
||||
expect(plugin.tools.map((tool) => [tool.name, tool.capabilityId, tool.operation])).toEqual([
|
||||
['game_music_generate', 'game-audio.music', 'generate'],
|
||||
['game_music_preview', 'game-audio.music', 'preview'],
|
||||
['game_sound_generate', 'game-audio.sound', 'generate'],
|
||||
['game_audio_cancel', 'game-audio.control', 'cancel'],
|
||||
]);
|
||||
expect(plugin.tools.every((tool) => tool.roles.length === 1 && tool.roles[0] === 'parent' && !tool.projectWriteLease)).toBe(true);
|
||||
});
|
||||
it('requires literal confirmation; Main freezes identity, mode and trusted price before auto-delivery', async () => {
|
||||
const generateAndMaterialize = vi.fn(async (): Promise<AudioDeliveryResult> => ({
|
||||
executionId: null, logicalOperationId: 'trusted-id', providerStatus: 'running',
|
||||
deliveryStatus: 'waiting', files: [], outputCount: 0, phase: 'generating', progressPercent: 40,
|
||||
billing: { mode: 'platform_metered', status: 'receipt_unavailable' },
|
||||
}));
|
||||
const resolve = vi.fn(async () => ({ releaseId: context.pluginReleaseId, releaseAdmissionId: 'admission' }));
|
||||
const adapter = new GameAudioPluginAdapter({
|
||||
delivery: { generateAndMaterialize },
|
||||
client: { cancel: vi.fn() }, admission: { resolve },
|
||||
getPricingVersion: () => 'trusted-price', getAccountKey: () => 'owner',
|
||||
});
|
||||
const tool = definition().tools[1]!;
|
||||
expect(await adapter.invoke(context, tool, { prompt: 'ambient forest', confirmed: 1 }))
|
||||
.toMatchObject({ success: false, code: 'confirmation_required', billing: { status: 'not_started' } });
|
||||
expect(generateAndMaterialize).not.toHaveBeenCalled();
|
||||
expect(await adapter.invoke(context, tool, { prompt: 'ambient forest', confirmed: true }))
|
||||
.toMatchObject({ success: true, status: 202 });
|
||||
expect(generateAndMaterialize).toHaveBeenCalledWith(expect.objectContaining({
|
||||
accountKey: 'owner', logicalOperationId: 'trusted-id', projectPath: '/project',
|
||||
}), expect.objectContaining({
|
||||
kind: 'music', mode: 'demo', pricing_version_id: 'trusted-price', project_id: 'durable',
|
||||
release_id: context.pluginReleaseId, release_admission_id: 'admission', logical_operation_id: 'trusted-id',
|
||||
}), expect.any(Function));
|
||||
expect(await adapter.invoke(context, tool, { prompt: 'x', confirmed: true, mode: 'full' }))
|
||||
.toMatchObject({ success: false, code: 'plugin_input_invalid' });
|
||||
generateAndMaterialize.mockRejectedValueOnce(new Error('receipt disk failed after POST'));
|
||||
expect(await adapter.invoke(context, tool, { prompt: 'forest', confirmed: true }))
|
||||
.toMatchObject({ success: false, billing: { status: 'receipt_unavailable' } });
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ const MARKETPLACE_ARTIFACT_TEXT = [
|
||||
'makelore-plugin-release.v1', 'skill_only', 'platform_hosted',
|
||||
'plugin_signature_invalid', 'signing key is not trusted',
|
||||
'makelore.game-resource', '/api/plugins/v1/hosted/game-resource/generations',
|
||||
'makelore.game-audio', '/api/plugins/v1/hosted/game-audio/generations',
|
||||
'makelore-model-tool.v1', 'model.web-search', 'forced_search',
|
||||
'makelore-device-package.v1', '/api/coding/device-packages', 'device-parent-workers',
|
||||
'/api/coding/plugin-marketplace',
|
||||
@@ -399,6 +400,7 @@ describe('final Pi product artifact verification', () => {
|
||||
await writeFile(path.join(source, 'dist', 'assets', 'plugin-marketplace.js'), [
|
||||
'makelore-plugin-release.v1 skill_only platform_hosted plugin_signature_invalid signing key is not trusted',
|
||||
'makelore.game-resource /api/plugins/v1/hosted/game-resource/generations',
|
||||
'makelore.game-audio /api/plugins/v1/hosted/game-audio/generations',
|
||||
'makelore-model-tool.v1 model.web-search forced_search',
|
||||
'makelore-device-package.v1 /api/coding/device-packages device-parent-workers',
|
||||
'/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/',
|
||||
@@ -419,6 +421,7 @@ describe('final Pi product artifact verification', () => {
|
||||
await writeFile(path.join(staleSource, 'dist-electron', 'stale-marketplace.js'), [
|
||||
'makelore-plugin-release.v1 skill_only platform_hosted plugin_signature_invalid signing key is not trusted',
|
||||
'makelore.game-resource /api/plugins/v1/hosted/game-resource/generations',
|
||||
'makelore.game-audio /api/plugins/v1/hosted/game-audio/generations',
|
||||
'/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/',
|
||||
'effectiveSkillIds pluginReleaseIds',
|
||||
'/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 为你的智能体添加插件,拓展更多能力。 acquire enable_project /project-config/plugins',
|
||||
|
||||
Reference in New Issue
Block a user