Files
makelore/tests/unit/game-resource-plugin-adapter.test.ts

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();
});
});