495 lines
18 KiB
TypeScript
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();
|
|
});
|
|
});
|