Files
makelore/tests/unit/game-resource-delivery.test.ts

495 lines
18 KiB
TypeScript

// @vitest-environment node
import { mkdir, 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 { 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';
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 dispatchedBilling = {
mode: 'platform_metered' as const,
status: 'dispatched' as const,
reserved_points: '2.00',
usage_amount: 1,
unit: 'generation',
};
const settledBilling = {
mode: 'platform_metered' as const,
status: 'settled' as const,
reserved_points: '2.00',
actual_points: '2.00',
usage_amount: 1,
unit: 'generation',
};
function generation(
status: GameResourceGeneration['status'],
overrides: Partial<GameResourceGeneration> = {},
): 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' ? 2 : 0,
pollIntervalSeconds: 3,
billing: status === 'succeeded' ? settledBilling : dispatchedBilling,
...overrides,
};
}
async function fixture(overrides: {
generate?: () => Promise<GameResourceGeneration>;
get?: () => Promise<GameResourceGeneration>;
download?: (executionId: string, outputIndex?: number) => Promise<{
bytes: Uint8Array;
fileName: string | null;
contentType: string;
}>;
writeOutput?: (filePath: string, bytes: Uint8Array) => Promise<void>;
sleep?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
acquireBackgroundLease?: () => () => void;
} = {}) {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-game-delivery-'));
const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-game-receipts-'));
roots.push(root, userDataDir);
const generate = vi.fn(overrides.generate ?? (async () => generation('accepted')));
const get = vi.fn(overrides.get ?? (async () => generation('succeeded')));
const download = vi.fn(overrides.download ?? (async (_executionId, index = 0) => ({
bytes: new Uint8Array([index + 1, index + 2]),
fileName: index === 0 ? 'hero.png' : 'portrait.webp',
contentType: index === 0 ? 'image/png' : 'image/webp',
})));
const client = { generate, get, download } as unknown as Pick<
GameResourceClient,
'generate' | 'get' | 'download'
>;
const leases = new PiProjectWriteLeaseCoordinator();
const touched = vi.fn(async () => undefined);
const progress = vi.fn();
const receiptStore = new GameResourceDeliveryReceiptStore(
path.join(userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'),
);
const coordinator = new GameResourceDeliveryCoordinator({
client,
receipts: receiptStore,
leases,
sleep: overrides.sleep ?? (async () => undefined),
recordTouchedPaths: touched,
...(overrides.writeOutput ? { writeOutput: overrides.writeOutput } : {}),
...(overrides.acquireBackgroundLease
? { acquireBackgroundLease: overrides.acquireBackgroundLease }
: {}),
});
const context = {
conversationId: 'conversation-a',
runId: 'run-a',
localProjectId: 'local-project-a',
durableProjectId: PROJECT_ID,
projectPath: root,
logicalOperationId: 'pi:run-a:resource-a',
};
const request = {
releaseAdmissionId: 'admission-a',
releaseId: RELEASE_ID,
projectId: PROJECT_ID,
logicalOperationId: context.logicalOperationId,
kind: 'pixel' as const,
templateName: 'character',
templateConfig: {},
requirement: 'A blue-armored hero',
};
return {
coordinator, receiptStore, context, request, root, userDataDir,
client, generate, get, download, leases, touched, progress,
};
}
afterEach(async () => {
await Promise.all(roots.splice(0).map(async (root) => await rm(root, { recursive: true, force: true })));
});
describe('GameResourceDeliveryCoordinator', () => {
it('submits once, hides polling, saves every output under the frozen project, and reports progress', async () => {
let activeProjectPath: string | undefined;
const states = [generation('running'), generation('succeeded')];
let fixtureValue: Awaited<ReturnType<typeof fixture>>;
fixtureValue = await fixture({
get: async () => {
expect(fixtureValue.leases.activeCount).toBe(0);
activeProjectPath = path.join(fixtureValue.root, '..', 'another-project');
return states.shift() as GameResourceGeneration;
},
download: async (_executionId, index = 0) => {
expect(fixtureValue.leases.activeCount).toBe(0);
expect(fixtureValue.progress.mock.calls.at(-1)?.[0].phase).toBe('saving');
return {
bytes: new Uint8Array([index + 1, index + 2]),
fileName: index === 0 ? 'hero.png' : 'portrait.webp',
contentType: index === 0 ? 'image/png' : 'image/webp',
};
},
});
const result = await fixtureValue.coordinator.generateAndMaterialize({
context: fixtureValue.context,
request: fixtureValue.request,
onProgress: fixtureValue.progress,
});
expect(activeProjectPath).not.toBe(fixtureValue.root);
expect(fixtureValue.generate).toHaveBeenCalledTimes(1);
expect(fixtureValue.get).toHaveBeenCalledTimes(2);
expect(fixtureValue.download.mock.calls.map((call) => call[1])).toEqual([0, 1]);
expect(result).toMatchObject({
executionId: EXECUTION_ID,
providerStatus: 'succeeded',
deliveryStatus: 'saved',
files: [
{ path: `assets/generated/game-resource/${EXECUTION_ID}/output-1.png`, bytes: 2 },
{ path: `assets/generated/game-resource/${EXECUTION_ID}/output-2.webp`, bytes: 2 },
],
billing: settledBilling,
});
await expect(readFile(path.join(
fixtureValue.root,
'assets', 'generated', 'game-resource', EXECUTION_ID, 'output-1.png',
))).resolves.toEqual(Buffer.from([1, 2]));
await expect(readFile(path.join(
fixtureValue.root,
'assets', 'generated', 'game-resource', EXECUTION_ID, 'output-2.webp',
))).resolves.toEqual(Buffer.from([2, 3]));
expect(fixtureValue.leases.activeCount).toBe(0);
expect(fixtureValue.touched).toHaveBeenCalledWith(
'conversation-a',
'run-a',
result.files.map(({ path: filePath }) => filePath),
);
expect(fixtureValue.progress.mock.calls.map(([item]) => item.phase)).toEqual([
'submitted', 'generating', 'generating', 'saving', 'saved',
]);
});
it('deduplicates concurrent replay and returns persisted saved paths without another submit or download', async () => {
let releaseGenerate: ((value: GameResourceGeneration) => void) | undefined;
const generated = new Promise<GameResourceGeneration>((resolve) => { releaseGenerate = resolve; });
const value = await fixture({ generate: async () => await generated });
const first = value.coordinator.generateAndMaterialize({ context: value.context, request: value.request });
const second = value.coordinator.generateAndMaterialize({ context: value.context, request: value.request });
releaseGenerate?.(generation('succeeded'));
const [left, right] = await Promise.all([first, second]);
expect(left).toEqual(right);
expect(value.generate).toHaveBeenCalledTimes(1);
expect(value.download).toHaveBeenCalledTimes(2);
const replay = await value.coordinator.generateAndMaterialize({
context: value.context,
request: value.request,
});
expect(replay).toEqual(left);
expect(value.generate).toHaveBeenCalledTimes(1);
expect(value.download).toHaveBeenCalledTimes(2);
});
it('keeps a background lifecycle lease through polling and delivery', async () => {
let held = false;
const release = vi.fn(() => { held = false; });
const value = await fixture({
acquireBackgroundLease: () => {
held = true;
return release;
},
generate: async () => {
expect(held).toBe(true);
return generation('accepted');
},
get: async () => {
expect(held).toBe(true);
return generation('succeeded');
},
download: async (_executionId, index = 0) => {
expect(held).toBe(true);
return {
bytes: new Uint8Array([index + 1]),
fileName: 'hero.png',
contentType: 'image/png',
};
},
});
await expect(value.coordinator.generateAndMaterialize({
context: value.context,
request: value.request,
})).resolves.toMatchObject({ deliveryStatus: 'saved' });
expect(held).toBe(false);
expect(release).toHaveBeenCalledOnce();
});
it('resumes an accepted persisted execution after the original waiter stops', async () => {
const value = await fixture({
sleep: async () => { throw new Error('waiter stopped'); },
});
await expect(value.coordinator.generateAndMaterialize({
context: value.context,
request: value.request,
})).rejects.toThrow('waiter stopped');
expect(value.generate).toHaveBeenCalledTimes(1);
const restarted = new GameResourceDeliveryCoordinator({
client: value.client,
receipts: new GameResourceDeliveryReceiptStore(
path.join(value.userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'),
),
leases: value.leases,
sleep: async () => undefined,
});
await expect(restarted.resumePending()).resolves.toEqual([expect.objectContaining({
executionId: EXECUTION_ID,
providerStatus: 'succeeded',
deliveryStatus: 'saved',
})]);
expect(value.generate).toHaveBeenCalledTimes(1);
});
it('persists provider success and resumes only local delivery after a write failure', async () => {
let failWrite = true;
const writeOutput = vi.fn(async (target: string, bytes: Uint8Array) => {
if (failWrite) throw Object.assign(new Error('disk unavailable'), { code: 'ENOSPC' });
await import('node:fs/promises').then(async ({ mkdir, writeFile }) => {
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, bytes, { flag: 'wx' });
});
});
const value = await fixture({
generate: async () => generation('succeeded'),
writeOutput,
});
await expect(value.coordinator.generateAndMaterialize({
context: value.context,
request: value.request,
})).resolves.toMatchObject({
providerStatus: 'succeeded',
deliveryStatus: 'delivery_failed',
errorCode: 'game_resource_delivery_failed',
files: [],
});
expect(value.generate).toHaveBeenCalledTimes(1);
failWrite = false;
const restarted = new GameResourceDeliveryCoordinator({
client: value.client,
receipts: new GameResourceDeliveryReceiptStore(
path.join(value.userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'),
),
leases: value.leases,
sleep: async () => undefined,
writeOutput,
recordTouchedPaths: value.touched,
});
const resumed = await restarted.resumePending();
expect(resumed).toEqual([expect.objectContaining({
executionId: EXECUTION_ID,
providerStatus: 'succeeded',
deliveryStatus: 'saved',
})]);
expect(value.generate).toHaveBeenCalledTimes(1);
expect(value.get).not.toHaveBeenCalled();
expect(value.download).toHaveBeenCalledTimes(8);
});
it('never overwrites an existing project file with different bytes', async () => {
const value = await fixture({ generate: async () => generation('succeeded') });
const relativePath = `assets/generated/game-resource/${EXECUTION_ID}/output-1.png`;
const target = path.join(value.root, ...relativePath.split('/'));
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, Buffer.from([99, 98]));
await expect(value.coordinator.generateAndMaterialize({
context: value.context,
request: value.request,
})).resolves.toMatchObject({
providerStatus: 'succeeded',
deliveryStatus: 'delivery_failed',
errorCode: 'game_resource_delivery_failed',
});
await expect(readFile(target)).resolves.toEqual(Buffer.from([99, 98]));
expect(value.generate).toHaveBeenCalledTimes(1);
});
it('persists provider success and resumes only local delivery after a download failure', async () => {
let failDownload = true;
const value = await fixture({
generate: async () => generation('succeeded'),
download: async (_executionId, index = 0) => {
if (failDownload) throw new Error('download unavailable');
return {
bytes: new Uint8Array([index + 1, index + 2]),
fileName: index === 0 ? 'hero.png' : 'portrait.webp',
contentType: index === 0 ? 'image/png' : 'image/webp',
};
},
});
await expect(value.coordinator.generateAndMaterialize({
context: value.context,
request: value.request,
})).resolves.toMatchObject({
providerStatus: 'succeeded',
deliveryStatus: 'delivery_failed',
errorCode: 'game_resource_delivery_failed',
files: [],
});
expect(value.generate).toHaveBeenCalledTimes(1);
failDownload = false;
const restarted = new GameResourceDeliveryCoordinator({
client: value.client,
receipts: new GameResourceDeliveryReceiptStore(
path.join(value.userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'),
),
leases: value.leases,
sleep: async () => undefined,
recordTouchedPaths: value.touched,
});
await expect(restarted.resumePending()).resolves.toEqual([expect.objectContaining({
executionId: EXECUTION_ID,
providerStatus: 'succeeded',
deliveryStatus: 'saved',
})]);
expect(value.generate).toHaveBeenCalledTimes(1);
expect(value.get).not.toHaveBeenCalled();
});
it('retries a transient local delivery failure without submitting another generation', async () => {
let downloadAttempts = 0;
const sleep = vi.fn(async () => undefined);
const value = await fixture({
generate: async () => generation('succeeded'),
sleep,
download: async (_executionId, index = 0) => {
downloadAttempts += 1;
if (downloadAttempts === 1) throw new Error('temporary download failure');
return {
bytes: new Uint8Array([index + 1, index + 2]),
fileName: index === 0 ? 'hero.png' : 'portrait.webp',
contentType: index === 0 ? 'image/png' : 'image/webp',
};
},
});
await expect(value.coordinator.generateAndMaterialize({
context: value.context,
request: value.request,
})).resolves.toMatchObject({
providerStatus: 'succeeded',
deliveryStatus: 'saved',
});
expect(value.generate).toHaveBeenCalledTimes(1);
expect(value.get).not.toHaveBeenCalled();
expect(sleep).toHaveBeenCalledOnce();
});
it('continues resuming later receipts when an earlier reconciliation is unavailable', async () => {
let generationIndex = 0;
let sleepCalls = 0;
const value = await fixture({
generate: async () => {
const first = generationIndex++ === 0;
return generation(first ? 'accepted' : 'succeeded', {
executionId: first
? '11111111-1111-4111-8111-111111111111'
: '44444444-4444-4444-8444-444444444444',
});
},
download: async () => { throw new Error('delivery unavailable'); },
sleep: async () => {
if (sleepCalls++ === 0) throw new Error('waiter stopped');
},
});
const secondContext = {
...value.context,
logicalOperationId: 'pi:run-a:resource-b',
};
const secondRequest = {
...value.request,
logicalOperationId: secondContext.logicalOperationId,
};
await expect(value.coordinator.generateAndMaterialize({
context: value.context,
request: value.request,
})).rejects.toThrow('waiter stopped');
await value.coordinator.generateAndMaterialize({
context: secondContext,
request: secondRequest,
});
const resumedClient = {
generate: vi.fn(async () => { throw new Error('must not regenerate'); }),
get: vi.fn(async () => { throw new Error('first reconciliation unavailable'); }),
download: vi.fn(async (executionId: string, index = 0) => {
if (executionId === EXECUTION_ID) throw new Error('first delivery remains unavailable');
return {
bytes: new Uint8Array([index + 1]),
fileName: 'result.png',
contentType: 'image/png',
};
}),
} as unknown as Pick<GameResourceClient, 'generate' | 'get' | 'download'>;
const restarted = new GameResourceDeliveryCoordinator({
client: resumedClient,
receipts: new GameResourceDeliveryReceiptStore(
path.join(value.userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'),
),
leases: value.leases,
sleep: async () => undefined,
});
const results = await restarted.resumePending();
expect(results).toEqual([expect.objectContaining({
executionId: '44444444-4444-4444-8444-444444444444',
deliveryStatus: 'saved',
})]);
expect(resumedClient.generate).not.toHaveBeenCalled();
expect(resumedClient.get).toHaveBeenCalledOnce();
});
it('does not hold the write lease or create files for a pending-review generation', async () => {
const value = await fixture({
generate: async () => generation('pending_review', { outputCount: 0 }),
});
await expect(value.coordinator.generateAndMaterialize({
context: value.context,
request: value.request,
})).resolves.toMatchObject({
providerStatus: 'pending_review',
deliveryStatus: 'not_started',
files: [],
});
expect(value.download).not.toHaveBeenCalled();
expect(value.leases.activeCount).toBe(0);
expect(value.touched).not.toHaveBeenCalled();
});
});