212 lines
8.3 KiB
TypeScript
212 lines
8.3 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import type { CodingPluginToolDefinition } from '../../shared/coding-plugins';
|
|
import {
|
|
GameResourcePluginAdapter,
|
|
} from '../../electron/coding-plugins/adapters/game-resource';
|
|
import { PiProjectWriteLeaseCoordinator } from '../../electron/coding-runtime/pi/write-lease';
|
|
import {
|
|
GameResourceDeliveryCoordinator,
|
|
GameResourceDeliveryReceiptStore,
|
|
} from '../../electron/services/game-resource-delivery';
|
|
import type { GameResourceClient, GameResourceGeneration } from '../../electron/services/game-resource-client';
|
|
import type { MarketplacePackageClientPort, PluginPackageStore } from '../../electron/coding-plugins/package-store';
|
|
import type { TrustedCodingCapabilityContext } from '../../electron/coding-plugins/registry';
|
|
|
|
const EXECUTION_ID = '11111111-1111-4111-8111-111111111111';
|
|
const RELEASE_ID = '22222222-2222-4222-8222-222222222222';
|
|
const PROJECT_ID = '33333333-3333-4333-8333-333333333333';
|
|
const roots: string[] = [];
|
|
|
|
const billing = {
|
|
mode: 'platform_metered' as const,
|
|
status: 'dispatched' as const,
|
|
reserved_points: '2.00',
|
|
usage_amount: 1,
|
|
unit: 'generation',
|
|
};
|
|
|
|
function generation(status: GameResourceGeneration['status'] = 'accepted'): GameResourceGeneration {
|
|
return {
|
|
executionId: EXECUTION_ID,
|
|
releaseId: RELEASE_ID,
|
|
projectId: PROJECT_ID,
|
|
logicalOperationId: 'pi:run-a:resource-a',
|
|
kind: 'pixel',
|
|
templateName: 'character',
|
|
status,
|
|
outputCount: status === 'succeeded' ? 1 : 0,
|
|
pollIntervalSeconds: 3,
|
|
billing,
|
|
};
|
|
}
|
|
|
|
function tool(
|
|
name: string,
|
|
capabilityId = 'game-resource.generate',
|
|
operation = name.replace('game_resource_', ''),
|
|
): CodingPluginToolDefinition {
|
|
return {
|
|
name,
|
|
label: name,
|
|
description: name,
|
|
capabilityId,
|
|
operation,
|
|
roles: ['parent'],
|
|
mutation: operation === 'templates' ? 'read' : 'write',
|
|
projectWriteLease: false,
|
|
permissions: [`hosted.game-resource.${operation.replaceAll('_', '-')}`],
|
|
inputSchema: { type: 'object' },
|
|
};
|
|
}
|
|
|
|
async function fixture() {
|
|
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-game-resource-'));
|
|
const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-game-resource-receipts-'));
|
|
roots.push(projectPath, userDataDir);
|
|
const resolve = vi.fn(async () => ({
|
|
resolveRequestId: 'pi:run-a:resource-a',
|
|
resolveRequestDigest: 'a'.repeat(64),
|
|
items: [{
|
|
pluginId: 'makelore.game-resource', action: 'keep' as const,
|
|
releaseId: RELEASE_ID, version: '1.0.0', sha256: 'a'.repeat(64), sizeBytes: 1,
|
|
releaseAdmissionId: 'admission-a', expiresAt: '2100-01-01T00:00:00Z',
|
|
channel: 'stable' as const, reason: null,
|
|
}],
|
|
catalogGeneration: 1,
|
|
etag: '"plugins-1"',
|
|
stale: false,
|
|
}));
|
|
const client = {
|
|
templates: vi.fn(async () => ['character']),
|
|
generate: vi.fn(async () => generation()),
|
|
get: vi.fn(async () => generation('succeeded')),
|
|
cancel: vi.fn(async () => generation('cancelled')),
|
|
download: vi.fn(async () => ({
|
|
bytes: new Uint8Array([1, 2, 3]), fileName: 'hero.png', contentType: 'image/png',
|
|
})),
|
|
};
|
|
const delivery = new GameResourceDeliveryCoordinator({
|
|
client: client as unknown as GameResourceClient,
|
|
receipts: new GameResourceDeliveryReceiptStore(path.join(userDataDir, 'receipts.json')),
|
|
leases: new PiProjectWriteLeaseCoordinator(),
|
|
sleep: async () => undefined,
|
|
});
|
|
const adapter = new GameResourcePluginAdapter({
|
|
client: client as unknown as GameResourceClient,
|
|
delivery,
|
|
marketplace: { resolve } as unknown as MarketplacePackageClientPort,
|
|
packageStore: {
|
|
getInstalled: vi.fn(async () => ({
|
|
pluginId: 'makelore.game-resource', releaseId: RELEASE_ID, version: '1.0.0',
|
|
sha256: 'a'.repeat(64), channel: 'stable',
|
|
})),
|
|
getInstalledRelease: vi.fn(async () => ({
|
|
pluginId: 'makelore.game-resource', releaseId: RELEASE_ID, version: '1.0.0',
|
|
sha256: 'a'.repeat(64), sizeBytes: 1, channel: 'stable',
|
|
})),
|
|
} as unknown as Pick<PluginPackageStore, 'getInstalled' | 'getInstalledRelease'>,
|
|
makeloreVersion: '2.0.0',
|
|
});
|
|
const context: TrustedCodingCapabilityContext = {
|
|
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'resource-a',
|
|
requestId: 'pi:run-a:resource-a', localProjectId: 'local-a',
|
|
projectPath, durableProjectId: PROJECT_ID, workerRole: 'parent',
|
|
effectiveSkillIds: ['game-resource'], pluginReleaseId: RELEASE_ID,
|
|
};
|
|
return { adapter, client, context, projectPath, resolve };
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(roots.splice(0).map(async (root) => await rm(root, { recursive: true, force: true })));
|
|
});
|
|
|
|
describe('GameResourcePluginAdapter', () => {
|
|
it('resolves admission, hides polling, and automatically saves generated output in the project', async () => {
|
|
const { adapter, client, context, projectPath, resolve } = await fixture();
|
|
await writeFile(path.join(projectPath, 'reference.png'), new Uint8Array([4, 5, 6]));
|
|
const onProgress = vi.fn();
|
|
|
|
const result = await adapter.invoke(context, tool('game_resource_generate'), {
|
|
kind: 'pixel', templateName: 'character', requirement: 'Blue-armored hero',
|
|
confirmed: true,
|
|
referencePaths: ['reference.png'],
|
|
}, onProgress);
|
|
|
|
expect(resolve).toHaveBeenCalledWith(expect.objectContaining({
|
|
resolveRequestId: 'pi:run-a:resource-a', channel: 'stable',
|
|
installed: [{ pluginId: 'makelore.game-resource', releaseId: RELEASE_ID, sha256: 'a'.repeat(64) }],
|
|
}));
|
|
expect(client.generate).toHaveBeenCalledWith(expect.objectContaining({
|
|
releaseAdmissionId: 'admission-a', releaseId: RELEASE_ID,
|
|
projectId: PROJECT_ID, logicalOperationId: 'pi:run-a:resource-a',
|
|
referenceFiles: [{ name: 'reference.png', mimeType: 'image/png', dataBase64: 'BAUG' }],
|
|
}));
|
|
expect(result).toMatchObject({
|
|
success: true, status: 200, billing,
|
|
data: {
|
|
executionId: EXECUTION_ID,
|
|
providerStatus: 'succeeded',
|
|
deliveryStatus: 'saved',
|
|
files: [{
|
|
path: `assets/generated/game-resource/${EXECUTION_ID}/output-1.png`,
|
|
bytes: 3,
|
|
}],
|
|
},
|
|
});
|
|
expect(client.get).toHaveBeenCalledTimes(1);
|
|
expect(client.download).toHaveBeenCalledWith(EXECUTION_ID, 0);
|
|
await expect(readFile(path.join(
|
|
projectPath,
|
|
'assets', 'generated', 'game-resource', EXECUTION_ID, 'output-1.png',
|
|
))).resolves.toEqual(Buffer.from([1, 2, 3]));
|
|
expect(onProgress.mock.calls.map(([progress]) => progress.data.phase)).toEqual([
|
|
'submitted', 'generating', 'saving', 'saved',
|
|
]);
|
|
expect(JSON.stringify(result)).not.toContain(projectPath);
|
|
});
|
|
|
|
it('does not resolve admission or reserve usage before explicit generation confirmation', async () => {
|
|
const { adapter, client, context, resolve } = await fixture();
|
|
|
|
await expect(adapter.invoke(context, tool('game_resource_generate'), {
|
|
kind: 'pixel', templateName: 'character', requirement: 'Blue-armored hero',
|
|
confirmed: false,
|
|
})).resolves.toMatchObject({ success: false, code: 'confirmation_required' });
|
|
|
|
expect(resolve).not.toHaveBeenCalled();
|
|
expect(client.generate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('lists server-owned templates through the same release admission', async () => {
|
|
const { adapter, context, client } = await fixture();
|
|
await expect(adapter.invoke(context, tool('game_resource_templates'), { kind: 'pixel' }))
|
|
.resolves.toMatchObject({ success: true, data: { kind: 'pixel', templates: ['character'] } });
|
|
expect(client.templates).toHaveBeenCalledWith({
|
|
releaseId: RELEASE_ID, releaseAdmissionId: 'admission-a', kind: 'pixel',
|
|
});
|
|
});
|
|
|
|
it('fails closed when a worker has no frozen release instead of using the current package', async () => {
|
|
const { adapter, context, client, resolve } = await fixture();
|
|
|
|
await expect(adapter.invoke(
|
|
{ ...context, pluginReleaseId: undefined },
|
|
tool('game_resource_templates'),
|
|
{ kind: 'pixel' },
|
|
)).resolves.toMatchObject({
|
|
success: false,
|
|
status: 409,
|
|
code: 'plugin_release_unavailable',
|
|
retryable: false,
|
|
});
|
|
|
|
expect(resolve).not.toHaveBeenCalled();
|
|
expect(client.templates).not.toHaveBeenCalled();
|
|
});
|
|
});
|