1818 lines
60 KiB
TypeScript
1818 lines
60 KiB
TypeScript
import { spawn } from 'node:child_process';
|
|
import {
|
|
lstat,
|
|
mkdir,
|
|
mkdtemp,
|
|
readdir,
|
|
readFile,
|
|
realpath,
|
|
rm,
|
|
symlink,
|
|
writeFile,
|
|
} from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { deflateSync } from 'node:zlib';
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
appIdentifier,
|
|
buildCargoCliArguments,
|
|
buildMissingConfigWizardArguments,
|
|
canPromptForMissingRuntimeConfig,
|
|
childExitWithTimeout,
|
|
cleanupSwarmTestProject,
|
|
cleanupSwarmTestRuntimeConfig,
|
|
configFileName,
|
|
defaultRealSwarmTestTask,
|
|
defaultRuntimeConfigDirCandidates,
|
|
discoverRuntimeConfigDir,
|
|
hasConfiguredEditorApiKey,
|
|
hasGeneratedGameEntry,
|
|
hasIncompleteArtifactMarker,
|
|
inspectSwarmProjectArtifacts,
|
|
localConfigFileName,
|
|
parseRunnerShutdownOutput,
|
|
parseSettledSwarmTurnReport,
|
|
parseSwarmTestArguments,
|
|
prepareSwarmTestProject,
|
|
prepareSwarmTestRuntimeConfig,
|
|
removeDirectoryWithTimeout,
|
|
requiredSwarmManifestTaskIds,
|
|
resolveSwarmTestTimeoutMs,
|
|
runnerEndpointFileName,
|
|
shouldStartPersistentPreview,
|
|
terminateChildTree,
|
|
testProjectPrefix,
|
|
testProjectSentinelName,
|
|
testProjectSentinelSchema,
|
|
testRuntimeConfigPrefix,
|
|
testRuntimeConfigSentinelName,
|
|
testRuntimeConfigSentinelSchema,
|
|
ungeneratedGameEntryMarker,
|
|
validatePngBytes,
|
|
validatePreviewUrl,
|
|
validateSwarmProjectArtifacts,
|
|
} from '../scripts/agent-swarm-test-chat.mjs';
|
|
import {
|
|
buildGameCreatorWizardConfig,
|
|
gameCreatorProviderPresets,
|
|
normalizeWizardBaseUrl,
|
|
parseConfigWizardArguments,
|
|
readGameCreatorWizardConfigState,
|
|
resolveGameCreatorAppConfigDir,
|
|
writeGameCreatorConfigAtomically,
|
|
writeGameCreatorWizardConfig,
|
|
} from '../scripts/game-creator-config-wizard.mjs';
|
|
|
|
const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
|
|
async function withTemporaryRoot<T>(
|
|
run: (root: string) => Promise<T>,
|
|
): Promise<T> {
|
|
const root = await mkdtemp(
|
|
path.join(os.tmpdir(), 'genarrative-swarm-entry-test-'),
|
|
);
|
|
try {
|
|
return await run(root);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function pathExists(targetPath: string): Promise<boolean> {
|
|
return lstat(targetPath).then(
|
|
() => true,
|
|
(error: NodeJS.ErrnoException) => {
|
|
if (error.code === 'ENOENT') return false;
|
|
throw error;
|
|
},
|
|
);
|
|
}
|
|
|
|
const minimumFormalArtifactContents: Record<string, string> = {
|
|
'memory/project.md':
|
|
'# Project\n\n原创项目目标、世界观、资源与单位命名均已确定。\n',
|
|
'game/game_design.md':
|
|
'# Game design\n\n玩家放置原创守卫完成波次,胜利后进入下一关,并可随时重新开始。\n',
|
|
'game/balance.json': '{"speed":1,"waves":3}\n',
|
|
'assets/manifest.art.json':
|
|
'{"assets":[{"path":"assets/art-spritesheet.png"}]}\n',
|
|
'assets/manifest.audio.json': '{"assets":[],"status":"planned"}\n',
|
|
'game/index.html':
|
|
'<!doctype html><html><body><canvas id="game"></canvas><button>开始</button><script>const game={phase:"ready",level:1}; requestAnimationFrame(()=>game.phase);</script></body></html>\n',
|
|
'exports/README.md':
|
|
'# Export\n\n项目已完成静态检查与桌面、移动双视口试玩。\n',
|
|
};
|
|
|
|
const fixturePngSignature = Buffer.from([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
]);
|
|
|
|
function fixtureCrc32(bytes: Buffer): number {
|
|
let value = 0xffffffff;
|
|
for (const byte of bytes) {
|
|
value ^= byte;
|
|
for (let bit = 0; bit < 8; bit += 1) {
|
|
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
|
}
|
|
}
|
|
return (value ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function fixturePngChunk(type: string, data: Buffer): Buffer {
|
|
const typeBytes = Buffer.from(type, 'ascii');
|
|
const chunk = Buffer.alloc(12 + data.length);
|
|
chunk.writeUInt32BE(data.length, 0);
|
|
typeBytes.copy(chunk, 4);
|
|
data.copy(chunk, 8);
|
|
chunk.writeUInt32BE(
|
|
fixtureCrc32(Buffer.concat([typeBytes, data])),
|
|
8 + data.length,
|
|
);
|
|
return chunk;
|
|
}
|
|
|
|
function fixturePng(
|
|
width: number,
|
|
height: number,
|
|
{ invalidFilter = false, trailingCompressedBytes = false } = {},
|
|
): Buffer {
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(width, 0);
|
|
ihdr.writeUInt32BE(height, 4);
|
|
ihdr[8] = 1;
|
|
ihdr[9] = 0;
|
|
const rowBytes = Math.ceil(width / 8);
|
|
const scanlines = Buffer.alloc((rowBytes + 1) * height);
|
|
let pseudoRandom = 0x12345678;
|
|
for (let row = 0; row < height; row += 1) {
|
|
const rowOffset = row * (rowBytes + 1);
|
|
scanlines[rowOffset] = invalidFilter && row === 0 ? 5 : 0;
|
|
for (let column = 0; column < rowBytes; column += 1) {
|
|
pseudoRandom = (Math.imul(pseudoRandom, 1664525) + 1013904223) >>> 0;
|
|
scanlines[rowOffset + column + 1] = pseudoRandom >>> 24;
|
|
}
|
|
}
|
|
return Buffer.concat([
|
|
fixturePngSignature,
|
|
fixturePngChunk('IHDR', ihdr),
|
|
fixturePngChunk(
|
|
'IDAT',
|
|
trailingCompressedBytes
|
|
? Buffer.concat([deflateSync(scanlines), Buffer.from('junk')])
|
|
: deflateSync(scanlines),
|
|
),
|
|
fixturePngChunk('IEND', Buffer.alloc(0)),
|
|
]);
|
|
}
|
|
|
|
function fixtureIndexedPng({
|
|
includePalette = true,
|
|
duplicatePalette = false,
|
|
unknownCriticalChunk = false,
|
|
} = {}): Buffer {
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(1, 0);
|
|
ihdr.writeUInt32BE(1, 4);
|
|
ihdr[8] = 8;
|
|
ihdr[9] = 3;
|
|
const palette = fixturePngChunk('PLTE', Buffer.from([0, 0, 0]));
|
|
return Buffer.concat([
|
|
fixturePngSignature,
|
|
fixturePngChunk('IHDR', ihdr),
|
|
...(includePalette ? [palette] : []),
|
|
...(duplicatePalette ? [palette] : []),
|
|
...(unknownCriticalChunk ? [fixturePngChunk('ABCD', Buffer.alloc(0))] : []),
|
|
fixturePngChunk('IDAT', deflateSync(Buffer.from([0, 0]))),
|
|
fixturePngChunk('IEND', Buffer.alloc(0)),
|
|
]);
|
|
}
|
|
|
|
async function writeMinimumFormalArtifacts(root: string): Promise<void> {
|
|
for (const [relativePath, content] of Object.entries(
|
|
minimumFormalArtifactContents,
|
|
)) {
|
|
const targetPath = path.join(root, ...relativePath.split('/'));
|
|
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
await writeFile(targetPath, content);
|
|
}
|
|
const revision = 8;
|
|
const reportPath =
|
|
'.agent/runtime/browser-validations/publish-package/test-run/8/validation.json';
|
|
const desktopPath =
|
|
'.agent/runtime/browser-validations/publish-package/test-run/8/desktop.png';
|
|
const mobilePath =
|
|
'.agent/runtime/browser-validations/publish-package/test-run/8/mobile.png';
|
|
await mkdir(path.join(root, '.agent', 'runtime'), { recursive: true });
|
|
await writeFile(
|
|
path.join(root, '.agent', 'manifest.json'),
|
|
`${JSON.stringify({
|
|
tasks: requiredSwarmManifestTaskIds.map((id) => ({
|
|
id,
|
|
status: 'completed',
|
|
})),
|
|
})}\n`,
|
|
);
|
|
await writeFile(
|
|
path.join(root, '.agent', 'runtime', 'project-revision.json'),
|
|
`${JSON.stringify({ revision })}\n`,
|
|
);
|
|
const evidenceDirectory = path.dirname(path.join(root, reportPath));
|
|
await mkdir(evidenceDirectory, { recursive: true });
|
|
await writeFile(path.join(root, desktopPath), fixturePng(1280, 720));
|
|
await writeFile(path.join(root, mobilePath), fixturePng(390, 844));
|
|
await writeFile(
|
|
path.join(root, reportPath),
|
|
`${JSON.stringify({
|
|
passed: true,
|
|
playtest: { passed: true },
|
|
viewportResults: [
|
|
{ viewport: 'desktop', passed: true },
|
|
{ viewport: 'mobile', passed: true },
|
|
],
|
|
})}\n`,
|
|
);
|
|
await writeFile(
|
|
path.join(root, '.agent', 'agent.db'),
|
|
[
|
|
JSON.stringify({
|
|
recordType: 'agent.runtime.command.run_limited',
|
|
commandId: 'game.static_smoke',
|
|
status: 'completed',
|
|
revision,
|
|
}),
|
|
JSON.stringify({
|
|
recordType: 'agent.runtime.preview.validation',
|
|
passed: true,
|
|
playtestPassed: true,
|
|
revision,
|
|
reportPath,
|
|
screenshots: [desktopPath, mobilePath],
|
|
updatedAt: 1,
|
|
}),
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
}
|
|
|
|
async function writeReadyTaskExactlyOnceEvidence(
|
|
root: string,
|
|
parentRunId: string,
|
|
): Promise<void> {
|
|
const databasePath = path.join(root, '.agent', 'agent.db');
|
|
const database = await readFile(databasePath, 'utf8');
|
|
const records: Array<Record<string, unknown>> = [];
|
|
for (const taskId of requiredSwarmManifestTaskIds) {
|
|
const runId = `autonomous-ready-${taskId}-fixture`;
|
|
const taskDirectory = path.join(root, '.agent', 'runtime', 'tasks');
|
|
await mkdir(taskDirectory, { recursive: true });
|
|
await writeFile(
|
|
path.join(taskDirectory, `${taskId}.jsonl`),
|
|
`${JSON.stringify({
|
|
agentId: taskId,
|
|
taskId,
|
|
runId,
|
|
source: 'agent-ready-task-scheduler',
|
|
runProfile: 'autonomous-game-build',
|
|
parentAgentId: 'project-supervisor',
|
|
parentRunId,
|
|
status: 'completed',
|
|
phase: 'completed',
|
|
})}\n`,
|
|
);
|
|
records.push(
|
|
{
|
|
recordType: 'agent.runtime.background_task',
|
|
agentId: taskId,
|
|
taskId,
|
|
runId,
|
|
source: 'agent-ready-task-scheduler',
|
|
},
|
|
{
|
|
recordType: 'agent.runtime.background_task.completed',
|
|
agentId: taskId,
|
|
taskId,
|
|
runId,
|
|
source: 'agent-ready-task-scheduler',
|
|
},
|
|
{
|
|
recordType: 'agent.runtime.autonomous_ready_task.manifest_projected',
|
|
agentId: taskId,
|
|
taskId,
|
|
runId,
|
|
source: 'agent-ready-task-scheduler',
|
|
parentAgentId: 'project-supervisor',
|
|
parentRunId,
|
|
terminalPhase: 'completed',
|
|
manifestStatus: 'completed',
|
|
},
|
|
);
|
|
}
|
|
await writeFile(
|
|
databasePath,
|
|
`${database.trimEnd()}\n${records.map((record) => JSON.stringify(record)).join('\n')}\n`,
|
|
);
|
|
}
|
|
|
|
describe('terminal configuration wizard arguments', () => {
|
|
it('parses the supported options and documented defaults', () => {
|
|
const configDir = path.resolve('fixture-config');
|
|
|
|
expect(parseConfigWizardArguments([])).toEqual({
|
|
configDir: null,
|
|
configureOnly: false,
|
|
help: false,
|
|
});
|
|
expect(
|
|
parseConfigWizardArguments([
|
|
'--config-dir',
|
|
configDir,
|
|
'--configure-only',
|
|
'--help',
|
|
]),
|
|
).toEqual({ configDir, configureOnly: true, help: true });
|
|
expect(parseConfigWizardArguments(['-h']).help).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
['separate API key argument', ['--api-key', 'fixture-secret'], 'API Key'],
|
|
['inline API key argument', ['--api-key=fixture-secret'], 'API Key'],
|
|
['relative config directory', ['--config-dir', 'relative'], '绝对路径'],
|
|
['missing config directory', ['--config-dir'], '缺少目录路径'],
|
|
[
|
|
'duplicate config directory',
|
|
['--config-dir', '/first', '--config-dir', '/second'],
|
|
'只能指定一次',
|
|
],
|
|
['unknown option', ['--unknown'], '未知选项'],
|
|
])('rejects %s', (_label, args, marker) => {
|
|
expect(() => parseConfigWizardArguments(args)).toThrow(marker);
|
|
});
|
|
});
|
|
|
|
describe('terminal configuration wizard AppData paths', () => {
|
|
it.each([
|
|
{
|
|
label: 'Linux XDG config',
|
|
context: {
|
|
platform: 'linux',
|
|
environment: { XDG_CONFIG_HOME: '/fixture/xdg' },
|
|
homeDirectory: '/fixture/home',
|
|
},
|
|
candidate: path.posix.join('/fixture/xdg', appIdentifier),
|
|
},
|
|
{
|
|
label: 'macOS Application Support',
|
|
context: {
|
|
platform: 'darwin',
|
|
environment: {},
|
|
homeDirectory: '/Users/fixture',
|
|
},
|
|
candidate: path.posix.join(
|
|
'/Users/fixture',
|
|
'Library',
|
|
'Application Support',
|
|
appIdentifier,
|
|
),
|
|
},
|
|
{
|
|
label: 'Windows roaming AppData',
|
|
context: {
|
|
platform: 'win32',
|
|
environment: {
|
|
APPDATA: 'C:\\Users\\fixture\\AppData\\Roaming',
|
|
LOCALAPPDATA: 'C:\\Users\\fixture\\AppData\\Local',
|
|
},
|
|
homeDirectory: 'C:\\Users\\fixture',
|
|
},
|
|
candidate: path.win32.join(
|
|
'C:\\Users\\fixture\\AppData\\Roaming',
|
|
appIdentifier,
|
|
),
|
|
},
|
|
])(
|
|
'resolves the GUI-compatible $label directory',
|
|
({ context, candidate }) => {
|
|
expect(resolveGameCreatorAppConfigDir(context)).toBe(candidate);
|
|
},
|
|
);
|
|
|
|
it('uses an explicit absolute directory and rejects a relative one', () => {
|
|
const explicitConfigDir = path.resolve('explicit-config');
|
|
|
|
expect(resolveGameCreatorAppConfigDir({ explicitConfigDir })).toBe(
|
|
explicitConfigDir,
|
|
);
|
|
expect(() =>
|
|
resolveGameCreatorAppConfigDir({ explicitConfigDir: 'relative-config' }),
|
|
).toThrow('绝对路径');
|
|
});
|
|
});
|
|
|
|
describe('terminal configuration wizard providers', () => {
|
|
it('publishes the supported provider presets exactly', () => {
|
|
expect(gameCreatorProviderPresets).toEqual([
|
|
{
|
|
id: 'openai',
|
|
label: 'OpenAI',
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
model: 'gpt-4.1',
|
|
apiKind: 'openai_responses',
|
|
},
|
|
{
|
|
id: 'deepseek',
|
|
label: 'DeepSeek',
|
|
baseUrl: 'https://api.deepseek.com',
|
|
model: 'deepseek-chat',
|
|
apiKind: 'openai_chat',
|
|
},
|
|
{
|
|
id: 'anthropic',
|
|
label: 'Anthropic',
|
|
baseUrl: 'https://api.anthropic.com',
|
|
model: 'claude-3-5-sonnet-latest',
|
|
apiKind: 'anthropic',
|
|
},
|
|
{
|
|
id: 'ark',
|
|
label: '火山 Ark',
|
|
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
|
|
model: 'doubao-seed-1-6',
|
|
apiKind: 'openai_chat',
|
|
},
|
|
{
|
|
id: 'custom',
|
|
label: '自定义',
|
|
baseUrl: '',
|
|
model: '',
|
|
apiKind: 'openai_chat',
|
|
},
|
|
]);
|
|
expect(Object.isFrozen(gameCreatorProviderPresets)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('terminal configuration wizard config merge', () => {
|
|
it('updates the default LLM while retaining per-Agent, Editor API, and MCP config', () => {
|
|
const existingConfig = {
|
|
schemaVersion: 3,
|
|
llm: {
|
|
apiKey: 'old-fixture-secret',
|
|
model: 'old-model',
|
|
maxRetries: 2,
|
|
},
|
|
agentLlm: {
|
|
'project-supervisor': { providerId: 'supervisor-provider' },
|
|
},
|
|
editorApi: {
|
|
apiKey: 'fixture-editor-secret',
|
|
baseUrl: 'https://editor.example.test/v1',
|
|
},
|
|
mcpServers: [{ id: 'fixture-mcp', enabled: true }],
|
|
};
|
|
const originalConfig = structuredClone(existingConfig);
|
|
|
|
const merged = buildGameCreatorWizardConfig(existingConfig, {
|
|
apiKey: ' new-fixture-secret ',
|
|
baseUrl: 'https://llm.example.test/v1/',
|
|
model: ' fixture-model ',
|
|
apiKind: 'openai_chat',
|
|
});
|
|
|
|
expect(merged).toEqual({
|
|
...originalConfig,
|
|
llm: {
|
|
apiKey: 'new-fixture-secret',
|
|
model: 'fixture-model',
|
|
maxRetries: 2,
|
|
baseUrl: 'https://llm.example.test/v1',
|
|
apiKind: 'openai_chat',
|
|
reasoningEffort: 'high',
|
|
},
|
|
});
|
|
expect(merged.agentLlm).toEqual(originalConfig.agentLlm);
|
|
expect(merged.editorApi).toEqual(originalConfig.editorApi);
|
|
expect(merged.mcpServers).toEqual(originalConfig.mcpServers);
|
|
expect(existingConfig).toEqual(originalConfig);
|
|
});
|
|
|
|
it('uses provider-default reasoning for Anthropic', () => {
|
|
expect(
|
|
buildGameCreatorWizardConfig(
|
|
{ llm: { webSearchEnabled: true } },
|
|
{
|
|
apiKey: 'fixture-secret',
|
|
baseUrl: 'https://api.anthropic.com/',
|
|
model: 'claude-fixture',
|
|
apiKind: 'anthropic',
|
|
},
|
|
).llm,
|
|
).toMatchObject({ reasoningEffort: 'default', webSearchEnabled: false });
|
|
});
|
|
});
|
|
|
|
describe('terminal configuration wizard URL safety', () => {
|
|
it.each([
|
|
['https://provider.example.test/v1///', 'https://provider.example.test/v1'],
|
|
['http://127.0.0.1:8080/v1/', 'http://127.0.0.1:8080/v1'],
|
|
['http://localhost:8080/', 'http://localhost:8080'],
|
|
['http://[::1]:8080/v1/', 'http://[::1]:8080/v1'],
|
|
])('accepts %s', (input, expected) => {
|
|
expect(normalizeWizardBaseUrl(input)).toBe(expected);
|
|
});
|
|
|
|
it.each([
|
|
'',
|
|
'not-a-url',
|
|
'http://provider.example.test/v1',
|
|
'ftp://provider.example.test/v1',
|
|
'https://user:password@provider.example.test/v1',
|
|
'https://provider.example.test/v1?token=fixture',
|
|
'https://provider.example.test/v1#fragment',
|
|
])('rejects unsafe Base URL %s', (input) => {
|
|
expect(() => normalizeWizardBaseUrl(input)).toThrowError();
|
|
});
|
|
});
|
|
|
|
describe('terminal configuration wizard persistence', () => {
|
|
it('atomically replaces a private AppData config file', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
const configDir = path.join(root, 'nested', appIdentifier);
|
|
const configPath = path.join(configDir, configFileName);
|
|
const firstConfig = { llm: { model: 'first-fixture-model' } };
|
|
const finalConfig = {
|
|
llm: { model: 'final-fixture-model', apiKey: 'fixture-secret' },
|
|
agentLlm: { 'project-supervisor': { model: 'agent-fixture-model' } },
|
|
};
|
|
|
|
await expect(
|
|
writeGameCreatorConfigAtomically(configPath, firstConfig),
|
|
).resolves.toBe(configPath);
|
|
const firstMetadata = await lstat(configPath);
|
|
await expect(
|
|
writeGameCreatorConfigAtomically(configPath, finalConfig),
|
|
).resolves.toBe(configPath);
|
|
|
|
const [directoryMetadata, finalMetadata, entries, contents] =
|
|
await Promise.all([
|
|
lstat(configDir),
|
|
lstat(configPath),
|
|
readdir(configDir),
|
|
readFile(configPath, 'utf8'),
|
|
]);
|
|
expect(JSON.parse(contents)).toEqual(finalConfig);
|
|
expect(contents.endsWith('\n')).toBe(true);
|
|
expect(entries).toEqual([configFileName]);
|
|
expect(finalMetadata.isFile()).toBe(true);
|
|
expect(finalMetadata.isSymbolicLink()).toBe(false);
|
|
if (process.platform !== 'win32') {
|
|
expect(directoryMetadata.mode & 0o077).toBe(0);
|
|
expect(finalMetadata.mode & 0o077).toBe(0);
|
|
expect([finalMetadata.dev, finalMetadata.ino]).not.toEqual([
|
|
firstMetadata.dev,
|
|
firstMetadata.ino,
|
|
]);
|
|
}
|
|
});
|
|
});
|
|
|
|
it('moves the default LLM to primary config and lets later GUI saves win', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
const configDir = path.join(root, appIdentifier);
|
|
const primaryPath = path.join(configDir, configFileName);
|
|
const localPath = path.join(configDir, localConfigFileName);
|
|
await mkdir(configDir);
|
|
await writeFile(
|
|
primaryPath,
|
|
'{"llm":{"model":"primary","requestTimeoutMs":12345},"editorApi":{"apiKey":"canvas"}}\n',
|
|
);
|
|
await writeFile(
|
|
localPath,
|
|
'{"llm":{"model":"stale-local","stream":true},"agentLlm":{"planner":{"model":"planner"}},"mcpServers":{"local":{"command":"fixture"}}}\n',
|
|
);
|
|
|
|
const state = await readGameCreatorWizardConfigState(configDir);
|
|
expect(state.configPath).toBe(primaryPath);
|
|
expect(state.effectiveConfig.llm.model).toBe('stale-local');
|
|
const wizardConfig = buildGameCreatorWizardConfig(state.writeConfig, {
|
|
apiKey: 'wizard-key',
|
|
baseUrl: 'https://provider.example.test/v1',
|
|
model: 'wizard-model',
|
|
apiKind: 'openai_chat',
|
|
});
|
|
await writeGameCreatorWizardConfig(state, wizardConfig);
|
|
|
|
const sanitizedLocal = JSON.parse(await readFile(localPath, 'utf8'));
|
|
expect(sanitizedLocal.llm).toBeUndefined();
|
|
expect(sanitizedLocal.agentLlm.planner.model).toBe('planner');
|
|
expect(sanitizedLocal.mcpServers.local.command).toBe('fixture');
|
|
const afterWizard = await readGameCreatorWizardConfigState(configDir);
|
|
expect(afterWizard.effectiveConfig.llm).toMatchObject({
|
|
apiKey: 'wizard-key',
|
|
model: 'wizard-model',
|
|
stream: true,
|
|
requestTimeoutMs: 12345,
|
|
});
|
|
|
|
const guiConfig = JSON.parse(await readFile(primaryPath, 'utf8'));
|
|
guiConfig.llm = {
|
|
...guiConfig.llm,
|
|
apiKey: 'gui-key',
|
|
model: 'gui-model',
|
|
};
|
|
await writeGameCreatorConfigAtomically(primaryPath, guiConfig);
|
|
const afterGui = await readGameCreatorWizardConfigState(configDir);
|
|
expect(afterGui.effectiveConfig.llm.apiKey).toBe('gui-key');
|
|
expect(afterGui.effectiveConfig.llm.model).toBe('gui-model');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Swarm test argument parsing', () => {
|
|
it('returns the documented defaults', () => {
|
|
expect(parseSwarmTestArguments([])).toEqual({
|
|
configDir: null,
|
|
projectDir: null,
|
|
keepProject: false,
|
|
openBrowser: true,
|
|
task: null,
|
|
timeoutMinutes: null,
|
|
dryRun: false,
|
|
help: false,
|
|
});
|
|
});
|
|
|
|
it('parses every supported option', () => {
|
|
const configDir = path.resolve('fixture-config');
|
|
const projectDir = path.resolve('fixture-project');
|
|
|
|
expect(
|
|
parseSwarmTestArguments([
|
|
'--config-dir',
|
|
configDir,
|
|
'--project-dir',
|
|
projectDir,
|
|
'--keep-project',
|
|
'--no-open',
|
|
'--task',
|
|
'生成一款可试玩的塔防游戏',
|
|
'--timeout-minutes',
|
|
'75',
|
|
'--dry-run',
|
|
'--help',
|
|
]),
|
|
).toEqual({
|
|
configDir,
|
|
projectDir,
|
|
keepProject: true,
|
|
openBrowser: false,
|
|
task: '生成一款可试玩的塔防游戏',
|
|
timeoutMinutes: 75,
|
|
dryRun: true,
|
|
help: true,
|
|
});
|
|
expect(parseSwarmTestArguments(['-h']).help).toBe(true);
|
|
expect(() => parseSwarmTestArguments(['--task', ''])).toThrowError();
|
|
});
|
|
|
|
it('keeps persistent preview only for manual chat mode', () => {
|
|
expect(shouldStartPersistentPreview(parseSwarmTestArguments([]))).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
shouldStartPersistentPreview(
|
|
parseSwarmTestArguments(['--task', '生成一款可试玩的塔防游戏']),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('applies a bounded default only to non-interactive tasks', () => {
|
|
expect(resolveSwarmTestTimeoutMs(parseSwarmTestArguments([]))).toBeNull();
|
|
expect(
|
|
resolveSwarmTestTimeoutMs(
|
|
parseSwarmTestArguments(['--task', '生成原创塔防游戏']),
|
|
),
|
|
).toBe(50 * 60_000);
|
|
expect(
|
|
resolveSwarmTestTimeoutMs(
|
|
parseSwarmTestArguments(['--timeout-minutes', '12']),
|
|
),
|
|
).toBe(12 * 60_000);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: 'duplicate config directory',
|
|
args: ['--config-dir', '/first', '--config-dir', '/second'],
|
|
marker: '--config-dir',
|
|
},
|
|
{
|
|
label: 'duplicate project directory',
|
|
args: ['--project-dir', '/first', '--project-dir', '/second'],
|
|
marker: '--project-dir',
|
|
},
|
|
{
|
|
label: 'missing config directory',
|
|
args: ['--config-dir'],
|
|
marker: '--config-dir',
|
|
},
|
|
{
|
|
label: 'missing project directory',
|
|
args: ['--project-dir', '--dry-run'],
|
|
marker: '--project-dir',
|
|
},
|
|
{
|
|
label: 'duplicate timeout',
|
|
args: ['--timeout-minutes', '10', '--timeout-minutes', '20'],
|
|
marker: '--timeout-minutes',
|
|
},
|
|
{
|
|
label: 'invalid timeout',
|
|
args: ['--timeout-minutes', '0'],
|
|
marker: '1-1440',
|
|
},
|
|
{
|
|
label: 'unknown option',
|
|
args: ['--unsupported'],
|
|
marker: '--unsupported',
|
|
},
|
|
])('rejects $label', ({ args, marker }) => {
|
|
expect(() => parseSwarmTestArguments(args)).toThrow(marker);
|
|
});
|
|
});
|
|
|
|
describe('runtime config directory candidates', () => {
|
|
it('uses an absolute Linux XDG config root', () => {
|
|
expect(
|
|
defaultRuntimeConfigDirCandidates({
|
|
platform: 'linux',
|
|
environment: { XDG_CONFIG_HOME: '/fixture/xdg' },
|
|
homeDirectory: '/fixture/home',
|
|
}),
|
|
).toEqual([path.posix.join('/fixture/xdg', appIdentifier)]);
|
|
});
|
|
|
|
it('falls back to the Linux home config root', () => {
|
|
expect(
|
|
defaultRuntimeConfigDirCandidates({
|
|
platform: 'linux',
|
|
environment: { XDG_CONFIG_HOME: 'relative-xdg' },
|
|
homeDirectory: '/fixture/home',
|
|
}),
|
|
).toEqual([path.posix.join('/fixture/home', '.config', appIdentifier)]);
|
|
});
|
|
|
|
it('uses the macOS Application Support directory', () => {
|
|
expect(
|
|
defaultRuntimeConfigDirCandidates({
|
|
platform: 'darwin',
|
|
environment: {},
|
|
homeDirectory: '/Users/fixture',
|
|
}),
|
|
).toEqual([
|
|
path.posix.join(
|
|
'/Users/fixture',
|
|
'Library',
|
|
'Application Support',
|
|
appIdentifier,
|
|
),
|
|
]);
|
|
});
|
|
|
|
it('uses both Windows roaming and local AppData directories', () => {
|
|
const appData = 'C:\\Users\\fixture\\AppData\\Roaming';
|
|
const localAppData = 'C:\\Users\\fixture\\AppData\\Local';
|
|
|
|
expect(
|
|
defaultRuntimeConfigDirCandidates({
|
|
platform: 'win32',
|
|
environment: {
|
|
APPDATA: appData,
|
|
LOCALAPPDATA: localAppData,
|
|
},
|
|
homeDirectory: 'C:\\Users\\fixture',
|
|
}),
|
|
).toEqual([
|
|
path.win32.join(appData, appIdentifier),
|
|
path.win32.join(localAppData, appIdentifier),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('runtime config discovery', () => {
|
|
it('passes an empty explicit config directory through the TTY wizard only', () => {
|
|
const explicitConfigDir = path.resolve('empty-explicit-config');
|
|
const argumentsForWizard =
|
|
buildMissingConfigWizardArguments(explicitConfigDir);
|
|
|
|
expect(argumentsForWizard.slice(1)).toEqual([
|
|
'--configure-only',
|
|
'--config-dir',
|
|
explicitConfigDir,
|
|
]);
|
|
expect(argumentsForWizard[0]).toMatch(/game-creator-config-wizard\.mjs$/u);
|
|
expect(canPromptForMissingRuntimeConfig(true, true)).toBe(true);
|
|
expect(canPromptForMissingRuntimeConfig(false, true)).toBe(false);
|
|
expect(canPromptForMissingRuntimeConfig(true, false)).toBe(false);
|
|
});
|
|
|
|
it('discovers an explicitly selected config directory', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
const configDir = path.join(root, 'explicit-config');
|
|
await mkdir(configDir);
|
|
await writeFile(path.join(configDir, configFileName), '{}\n');
|
|
|
|
await expect(
|
|
discoverRuntimeConfigDir(configDir, {
|
|
platform: 'linux',
|
|
environment: { XDG_CONFIG_HOME: path.join(root, 'unused') },
|
|
homeDirectory: path.join(root, 'unused-home'),
|
|
}),
|
|
).resolves.toBe(await realpath(configDir));
|
|
});
|
|
});
|
|
|
|
it('discovers a config directory from an isolated XDG root', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
const xdgRoot = path.join(root, 'xdg');
|
|
const configDir = path.join(xdgRoot, appIdentifier);
|
|
await mkdir(configDir, { recursive: true });
|
|
await writeFile(path.join(configDir, configFileName), '{}\n');
|
|
|
|
await expect(
|
|
discoverRuntimeConfigDir(null, {
|
|
platform: 'linux',
|
|
environment: { XDG_CONFIG_HOME: xdgRoot },
|
|
homeDirectory: path.join(root, 'unused-home'),
|
|
}),
|
|
).resolves.toBe(await realpath(configDir));
|
|
});
|
|
});
|
|
|
|
it('rejects symlinked and non-file config entries', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
const target = path.join(root, 'config-target.json');
|
|
await writeFile(target, '{}\n');
|
|
|
|
const symlinkConfigDir = path.join(root, 'symlink-config');
|
|
await mkdir(symlinkConfigDir);
|
|
await symlink(target, path.join(symlinkConfigDir, configFileName));
|
|
await expect(discoverRuntimeConfigDir(symlinkConfigDir)).rejects.toThrow(
|
|
configFileName,
|
|
);
|
|
|
|
const directoryConfigDir = path.join(root, 'directory-config');
|
|
await mkdir(path.join(directoryConfigDir, configFileName), {
|
|
recursive: true,
|
|
});
|
|
await expect(
|
|
discoverRuntimeConfigDir(directoryConfigDir),
|
|
).rejects.toThrow(configFileName);
|
|
});
|
|
});
|
|
|
|
it('rejects a relative explicit config directory', async () => {
|
|
await expect(discoverRuntimeConfigDir('relative-config')).rejects.toThrow(
|
|
'--config-dir',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('isolated Swarm runtime config', () => {
|
|
it('privately copies only active config files and removes the owned directory', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
const sourceConfigDir = path.join(root, 'source-config');
|
|
await mkdir(sourceConfigDir);
|
|
await writeFile(
|
|
path.join(sourceConfigDir, configFileName),
|
|
'{"llm":{"apiKey":"fixture-credential"}}\n',
|
|
{ mode: 0o600 },
|
|
);
|
|
await writeFile(
|
|
path.join(sourceConfigDir, localConfigFileName),
|
|
'{"llm":{"model":"fixture-model"}}\n',
|
|
{ mode: 0o600 },
|
|
);
|
|
await writeFile(
|
|
path.join(sourceConfigDir, runnerEndpointFileName),
|
|
'{"mustNotCopy":true}\n',
|
|
);
|
|
await writeFile(
|
|
path.join(sourceConfigDir, 'agent-runner.lock'),
|
|
'must-not-copy\n',
|
|
);
|
|
await writeFile(
|
|
path.join(sourceConfigDir, `.${configFileName}.previous`),
|
|
'must-not-copy\n',
|
|
);
|
|
const sourceMetadata = await lstat(
|
|
path.join(sourceConfigDir, configFileName),
|
|
);
|
|
|
|
const runtimeConfig = await prepareSwarmTestRuntimeConfig(
|
|
sourceConfigDir,
|
|
root,
|
|
);
|
|
const runtimeEntries = (await readdir(runtimeConfig.path)).sort();
|
|
const runtimeDirectoryMetadata = await lstat(runtimeConfig.path);
|
|
const primaryMetadata = await lstat(
|
|
path.join(runtimeConfig.path, configFileName),
|
|
);
|
|
const localMetadata = await lstat(
|
|
path.join(runtimeConfig.path, localConfigFileName),
|
|
);
|
|
const sentinel = JSON.parse(
|
|
await readFile(
|
|
path.join(runtimeConfig.path, testRuntimeConfigSentinelName),
|
|
'utf8',
|
|
),
|
|
);
|
|
|
|
expect(runtimeConfig.sourcePath).toBe(await realpath(sourceConfigDir));
|
|
expect(path.basename(runtimeConfig.path)).toMatch(
|
|
new RegExp(`^${testRuntimeConfigPrefix}`),
|
|
);
|
|
expect(runtimeEntries).toEqual(
|
|
[
|
|
configFileName,
|
|
localConfigFileName,
|
|
testRuntimeConfigSentinelName,
|
|
].sort(),
|
|
);
|
|
expect(
|
|
await readFile(path.join(runtimeConfig.path, configFileName), 'utf8'),
|
|
).toBe('{"llm":{"apiKey":"fixture-credential"}}\n');
|
|
expect(
|
|
await readFile(
|
|
path.join(runtimeConfig.path, localConfigFileName),
|
|
'utf8',
|
|
),
|
|
).toBe('{"llm":{"model":"fixture-model"}}\n');
|
|
expect(sentinel).toEqual({
|
|
schemaVersion: testRuntimeConfigSentinelSchema,
|
|
token: runtimeConfig.sentinelToken,
|
|
});
|
|
if (process.platform !== 'win32') {
|
|
expect(runtimeDirectoryMetadata.mode & 0o077).toBe(0);
|
|
expect(primaryMetadata.mode & 0o077).toBe(0);
|
|
expect(localMetadata.mode & 0o077).toBe(0);
|
|
expect([primaryMetadata.dev, primaryMetadata.ino]).not.toEqual([
|
|
sourceMetadata.dev,
|
|
sourceMetadata.ino,
|
|
]);
|
|
}
|
|
expect(
|
|
await pathExists(path.join(runtimeConfig.path, runnerEndpointFileName)),
|
|
).toBe(false);
|
|
|
|
await expect(cleanupSwarmTestRuntimeConfig(runtimeConfig)).resolves.toBe(
|
|
true,
|
|
);
|
|
expect(await pathExists(runtimeConfig.path)).toBe(false);
|
|
expect(await pathExists(sourceConfigDir)).toBe(true);
|
|
expect(
|
|
await readFile(path.join(sourceConfigDir, configFileName), 'utf8'),
|
|
).toBe('{"llm":{"apiKey":"fixture-credential"}}\n');
|
|
});
|
|
});
|
|
|
|
it('rejects a symlinked local override without leaving a temporary directory', async () => {
|
|
if (process.platform === 'win32') return;
|
|
await withTemporaryRoot(async (root) => {
|
|
const sourceConfigDir = path.join(root, 'source-config');
|
|
const localTarget = path.join(root, 'local-target.json');
|
|
await mkdir(sourceConfigDir);
|
|
await writeFile(path.join(sourceConfigDir, configFileName), '{}\n');
|
|
await writeFile(localTarget, '{}\n');
|
|
await symlink(
|
|
localTarget,
|
|
path.join(sourceConfigDir, localConfigFileName),
|
|
);
|
|
|
|
await expect(
|
|
prepareSwarmTestRuntimeConfig(sourceConfigDir, root),
|
|
).rejects.toThrow(localConfigFileName);
|
|
expect(
|
|
(await readdir(root)).filter((entry) =>
|
|
entry.startsWith(testRuntimeConfigPrefix),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it('refuses to delete an isolated config while its Runner endpoint exists', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
const sourceConfigDir = path.join(root, 'source-config');
|
|
await mkdir(sourceConfigDir);
|
|
await writeFile(path.join(sourceConfigDir, configFileName), '{}\n');
|
|
const runtimeConfig = await prepareSwarmTestRuntimeConfig(
|
|
sourceConfigDir,
|
|
root,
|
|
);
|
|
const endpointPath = path.join(
|
|
runtimeConfig.path,
|
|
runnerEndpointFileName,
|
|
);
|
|
await writeFile(endpointPath, '{}\n');
|
|
|
|
await expect(
|
|
cleanupSwarmTestRuntimeConfig(runtimeConfig),
|
|
).rejects.toThrow('Runner');
|
|
expect(await pathExists(runtimeConfig.path)).toBe(true);
|
|
|
|
await rm(endpointPath);
|
|
await expect(cleanupSwarmTestRuntimeConfig(runtimeConfig)).resolves.toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Swarm test project ownership', () => {
|
|
it('creates a sentinel-owned project and removes it during cleanup', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
const project = await prepareSwarmTestProject(null, root);
|
|
const sentinelPath = path.join(project.path, testProjectSentinelName);
|
|
const sentinelMetadata = await lstat(sentinelPath);
|
|
const sentinel = JSON.parse(await readFile(sentinelPath, 'utf8'));
|
|
|
|
expect(project.owned).toBe(true);
|
|
expect(project.sentinelToken).toEqual(expect.any(String));
|
|
expect(path.basename(project.path)).toMatch(
|
|
new RegExp(`^${testProjectPrefix}`),
|
|
);
|
|
expect(sentinelMetadata.isFile()).toBe(true);
|
|
expect(sentinelMetadata.isSymbolicLink()).toBe(false);
|
|
expect(sentinel).toEqual({
|
|
schemaVersion: testProjectSentinelSchema,
|
|
token: project.sentinelToken,
|
|
});
|
|
|
|
await expect(cleanupSwarmTestProject(project)).resolves.toBe(true);
|
|
expect(await pathExists(project.path)).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('refuses cleanup after the sentinel identity is changed', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
const project = await prepareSwarmTestProject(null, root);
|
|
await writeFile(
|
|
path.join(project.path, testProjectSentinelName),
|
|
`${JSON.stringify({
|
|
schemaVersion: testProjectSentinelSchema,
|
|
token: 'changed-token',
|
|
})}\n`,
|
|
);
|
|
|
|
await expect(cleanupSwarmTestProject(project)).rejects.toThrowError();
|
|
expect(await pathExists(project.path)).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('keeps an explicit empty directory unowned', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
const explicitProjectDir = path.join(root, 'explicit-project');
|
|
await mkdir(explicitProjectDir);
|
|
|
|
const project = await prepareSwarmTestProject(explicitProjectDir, root);
|
|
|
|
expect(project).toEqual({
|
|
path: await realpath(explicitProjectDir),
|
|
owned: false,
|
|
sentinelToken: null,
|
|
});
|
|
await expect(cleanupSwarmTestProject(project)).resolves.toBe(false);
|
|
expect(await readdir(explicitProjectDir)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it('rejects a non-empty uninitialized explicit directory', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
const explicitProjectDir = path.join(root, 'uninitialized-project');
|
|
await mkdir(explicitProjectDir);
|
|
await writeFile(path.join(explicitProjectDir, 'existing.txt'), 'fixture');
|
|
|
|
await expect(
|
|
prepareSwarmTestProject(explicitProjectDir, root),
|
|
).rejects.toThrow('--project-dir');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('cargo CLI argument construction', () => {
|
|
it('uses the shell manifest and separates cargo from application arguments', () => {
|
|
const cliArguments = ['--config-dir', 'fixture-config', '--llm-status'];
|
|
const cargoArguments = buildCargoCliArguments(cliArguments);
|
|
|
|
expect(cargoArguments.slice(0, 2)).toEqual(['run', '--manifest-path']);
|
|
expect(path.isAbsolute(cargoArguments[2])).toBe(true);
|
|
expect(path.relative(appRoot, cargoArguments[2])).toBe(
|
|
path.join('src-tauri', 'Cargo.toml'),
|
|
);
|
|
expect(cargoArguments[3]).toBe('--');
|
|
expect(cargoArguments.slice(4)).toEqual(cliArguments);
|
|
});
|
|
|
|
it('parses the idle Runner shutdown marker', () => {
|
|
expect(parseRunnerShutdownOutput('runner.stopped=true\n')).toBe(true);
|
|
expect(parseRunnerShutdownOutput('runner.stopped=false\n')).toBe(false);
|
|
expect(() => parseRunnerShutdownOutput('runner.status=unknown\n')).toThrow(
|
|
'stopped',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('bounded process-tree termination', () => {
|
|
it('kills a POSIX process group after its leader exits with stdio still open', async () => {
|
|
if (process.platform === 'win32') return;
|
|
const child = spawn(
|
|
process.execPath,
|
|
[
|
|
'-e',
|
|
`const { spawn } = require('node:child_process');
|
|
const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
|
stdio: 'inherit',
|
|
});
|
|
grandchild.once('spawn', () => {
|
|
process.stdout.write('grandchild-ready\\n');
|
|
setTimeout(() => process.exit(0), 50);
|
|
});
|
|
grandchild.once('error', () => process.exit(2));`,
|
|
],
|
|
{ detached: true, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
);
|
|
try {
|
|
child.stdout.setEncoding('utf8');
|
|
const grandchildReady = new Promise<void>((resolve, reject) => {
|
|
let output = '';
|
|
child.stdout.on('data', (chunk) => {
|
|
output += chunk;
|
|
if (output.includes('grandchild-ready')) resolve();
|
|
});
|
|
child.once('error', reject);
|
|
});
|
|
child.stderr.resume();
|
|
const leaderExited = new Promise<void>((resolve, reject) => {
|
|
child.once('error', reject);
|
|
child.once('exit', () => resolve());
|
|
});
|
|
await Promise.all([leaderExited, grandchildReady]);
|
|
expect(child.exitCode).toBe(0);
|
|
const startedAt = Date.now();
|
|
await expect(
|
|
childExitWithTimeout(child, 20, 'fixture process tree', {
|
|
graceMs: 50,
|
|
forceWaitMs: 500,
|
|
}),
|
|
).rejects.toMatchObject({ code: 'AGC_CHILD_TIMEOUT' });
|
|
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
|
} finally {
|
|
await terminateChildTree(child, 'SIGKILL', true);
|
|
}
|
|
});
|
|
|
|
it('bounds recursive cleanup in an independently terminable child', async () => {
|
|
if (process.platform === 'win32') return;
|
|
const target = await mkdtemp(
|
|
path.join(os.tmpdir(), 'genarrative-bounded-cleanup-test-'),
|
|
);
|
|
try {
|
|
await expect(
|
|
removeDirectoryWithTimeout(target, {
|
|
timeoutMs: 20,
|
|
childProgram: 'setInterval(() => {}, 1000);',
|
|
}),
|
|
).rejects.toMatchObject({ code: 'AGC_CHILD_TIMEOUT' });
|
|
expect(await pathExists(target)).toBe(true);
|
|
} finally {
|
|
await rm(target, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('non-interactive Swarm turn report validation', () => {
|
|
const settledReport = {
|
|
schemaVersion: 'game-creator-swarm-turn-report.v1',
|
|
outcome: 'settled',
|
|
parentAgentId: 'project-supervisor',
|
|
sessionId: 'agent-session-project-supervisor',
|
|
parentRunId: 'swarm-project-supervisor-fixture',
|
|
runtimeCount: 5,
|
|
busyRuntimeCount: 0,
|
|
pendingTaskCount: 0,
|
|
runningTaskCount: 0,
|
|
waitingForConfirmationCount: 0,
|
|
waitingForUserInputCount: 0,
|
|
newAssistantMessageCount: 1,
|
|
finalReplyChars: 128,
|
|
reconciliationAgentCount: 0,
|
|
};
|
|
const outputFor = (...reports: unknown[]) =>
|
|
[
|
|
'[状态] 正在收束',
|
|
...reports.map((report) => `[turn.report] ${JSON.stringify(report)}`),
|
|
'[完成] 本轮结束',
|
|
].join('\n');
|
|
|
|
it('accepts exactly one settled report with no remaining work', () => {
|
|
expect(parseSettledSwarmTurnReport(outputFor(settledReport))).toEqual(
|
|
settledReport,
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
['missing', '[状态] 没有报告'],
|
|
['duplicate', outputFor(settledReport, settledReport)],
|
|
['malformed JSON', '[turn.report] {invalid'],
|
|
['non-object JSON', '[turn.report] []'],
|
|
[
|
|
'incomplete shape',
|
|
outputFor(
|
|
(({ finalReplyChars: _removed, ...report }) => report)(settledReport),
|
|
),
|
|
],
|
|
['unknown shape', outputFor({ ...settledReport, unexpected: true })],
|
|
])('rejects a %s report', (_label, output) => {
|
|
expect(() => parseSettledSwarmTurnReport(output)).toThrowError();
|
|
});
|
|
|
|
it.each(['failed', 'incomplete', 'needs-reconciliation'])(
|
|
'rejects the %s outcome',
|
|
(outcome) => {
|
|
expect(() =>
|
|
parseSettledSwarmTurnReport(outputFor({ ...settledReport, outcome })),
|
|
).toThrow(`outcome=${outcome}`);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
'busyRuntimeCount',
|
|
'pendingTaskCount',
|
|
'runningTaskCount',
|
|
'waitingForConfirmationCount',
|
|
'waitingForUserInputCount',
|
|
'reconciliationAgentCount',
|
|
])('rejects non-zero %s', (field) => {
|
|
expect(() =>
|
|
parseSettledSwarmTurnReport(outputFor({ ...settledReport, [field]: 1 })),
|
|
).toThrow(field);
|
|
});
|
|
|
|
it.each([
|
|
['no Runtime', { runtimeCount: 0 }],
|
|
['no assistant reply', { newAssistantMessageCount: 0 }],
|
|
['multiple assistant replies', { newAssistantMessageCount: 2 }],
|
|
['empty final reply', { finalReplyChars: 0 }],
|
|
['implausibly large final reply', { finalReplyChars: 1_000_001 }],
|
|
['fractional count', { pendingTaskCount: 0.5 }],
|
|
['missing parent run', { parentRunId: null }],
|
|
])('rejects %s', (_label, overrides) => {
|
|
expect(() =>
|
|
parseSettledSwarmTurnReport(
|
|
outputFor({ ...settledReport, ...overrides }),
|
|
),
|
|
).toThrowError();
|
|
});
|
|
});
|
|
|
|
describe('generated game entry validation', () => {
|
|
it('rejects the initializer placeholder and accepts generated HTML', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
const gameDir = path.join(root, 'game');
|
|
const gameEntry = path.join(gameDir, 'index.html');
|
|
await mkdir(gameDir);
|
|
|
|
expect(await hasGeneratedGameEntry(root)).toBe(false);
|
|
await writeFile(
|
|
gameEntry,
|
|
`<!doctype html><main>${ungeneratedGameEntryMarker}</main>`,
|
|
);
|
|
expect(await hasGeneratedGameEntry(root)).toBe(false);
|
|
|
|
await writeFile(gameEntry, '<!doctype html><canvas id="game"></canvas>');
|
|
expect(await hasGeneratedGameEntry(root)).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('formal Swarm project artifact validation', () => {
|
|
it('reports every missing required artifact by relative path', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
const inspection = await inspectSwarmProjectArtifacts(root);
|
|
|
|
expect(inspection.valid).toBe(false);
|
|
expect(inspection.invalidPaths).toEqual([
|
|
...Object.keys(minimumFormalArtifactContents),
|
|
'.agent/manifest.json',
|
|
'.agent/runtime/project-revision.json',
|
|
'.agent/agent.db',
|
|
]);
|
|
const validationError = await validateSwarmProjectArtifacts(root).then(
|
|
() => null,
|
|
(error: Error) => error,
|
|
);
|
|
expect(validationError).toBeInstanceOf(Error);
|
|
for (const relativePath of Object.keys(minimumFormalArtifactContents)) {
|
|
expect(validationError?.message).toContain(relativePath);
|
|
}
|
|
|
|
await mkdir(path.join(root, 'exports'));
|
|
await writeFile(path.join(root, 'exports', 'README.md'), ' \n');
|
|
const emptyInspection = await inspectSwarmProjectArtifacts(root);
|
|
expect(emptyInspection.issues).toContainEqual({
|
|
path: 'exports/README.md',
|
|
reason: '空文件',
|
|
});
|
|
|
|
if (process.platform !== 'win32') {
|
|
const target = path.join(root, 'project-target.md');
|
|
await mkdir(path.join(root, 'memory'));
|
|
await writeFile(target, '# Outside artifact path\n');
|
|
await symlink(target, path.join(root, 'memory', 'project.md'));
|
|
const symlinkInspection = await inspectSwarmProjectArtifacts(root);
|
|
expect(symlinkInspection.issues).toContainEqual({
|
|
path: 'memory/project.md',
|
|
reason: '不是无符号链接普通文件',
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
it('rejects each malformed JSON artifact', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
await writeMinimumFormalArtifacts(root);
|
|
const invalidJsonPaths = [
|
|
'game/balance.json',
|
|
'assets/manifest.art.json',
|
|
'assets/manifest.audio.json',
|
|
];
|
|
await Promise.all(
|
|
invalidJsonPaths.map((relativePath) =>
|
|
writeFile(path.join(root, ...relativePath.split('/')), '{invalid'),
|
|
),
|
|
);
|
|
|
|
const inspection = await inspectSwarmProjectArtifacts(root);
|
|
|
|
expect(inspection.invalidPaths).toEqual(invalidJsonPaths);
|
|
expect(inspection.issues).toEqual(
|
|
invalidJsonPaths.map((relativePath) => ({
|
|
path: relativePath,
|
|
reason: 'JSON 无法解析',
|
|
})),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('rejects placeholder Markdown and empty JSON objects', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
await writeMinimumFormalArtifacts(root);
|
|
const projectMemoryPath = path.join(root, 'memory', 'project.md');
|
|
await writeFile(projectMemoryPath, '# TODO\n\n待补充项目说明。\n');
|
|
let inspection = await inspectSwarmProjectArtifacts(root);
|
|
expect(inspection.issues).toContainEqual({
|
|
path: 'memory/project.md',
|
|
reason: '仍包含占位标记',
|
|
});
|
|
|
|
await writeFile(
|
|
projectMemoryPath,
|
|
minimumFormalArtifactContents['memory/project.md'],
|
|
);
|
|
const gameEntryPath = path.join(root, 'game', 'index.html');
|
|
await writeFile(
|
|
gameEntryPath,
|
|
'<!doctype html><html><body><canvas></canvas><script>/* TODO */</script></body></html>\n',
|
|
);
|
|
inspection = await inspectSwarmProjectArtifacts(root);
|
|
expect(inspection.issues).toContainEqual({
|
|
path: 'game/index.html',
|
|
reason: '仍包含占位标记',
|
|
});
|
|
|
|
await writeFile(
|
|
gameEntryPath,
|
|
minimumFormalArtifactContents['game/index.html'],
|
|
);
|
|
await writeFile(path.join(root, 'game', 'balance.json'), '{}\n');
|
|
inspection = await inspectSwarmProjectArtifacts(root);
|
|
expect(inspection.issues).toContainEqual({
|
|
path: 'game/balance.json',
|
|
reason: 'JSON 必须是非空对象',
|
|
});
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['TODO', 'TODO: 补齐导出说明'],
|
|
['TBD', 'TBD - export notes'],
|
|
['placeholder', 'This is a placeholder document.'],
|
|
['coming soon', 'Release notes are coming soon.'],
|
|
['lorem ipsum', 'Lorem ipsum dolor sit amet.'],
|
|
['待补充', '导出说明待补充。'],
|
|
['待完善', '移动端结论待完善。'],
|
|
['占位', '本文件仅供流程占位。'],
|
|
['尚未完成', '最终试玩尚未完成。'],
|
|
['稍后补充', '截图说明稍后补充。'],
|
|
['待填写', '版本信息待填写。'],
|
|
['待验证', '桌面视口待验证。'],
|
|
['待复核', '最终结论待复核。'],
|
|
['待确认', '发布范围待确认。'],
|
|
['待定', '交付日期待定。'],
|
|
['unchecked checklist', '- [ ] 补齐移动端试玩记录'],
|
|
])('rejects exports/README.md containing %s', async (_label, marker) => {
|
|
expect(hasIncompleteArtifactMarker(`# Export\n\n${marker}\n`)).toBe(true);
|
|
|
|
await withTemporaryRoot(async (root) => {
|
|
await writeMinimumFormalArtifacts(root);
|
|
await writeFile(
|
|
path.join(root, 'exports', 'README.md'),
|
|
`# Export\n\n当前导出流程记录如下。\n\n${marker}\n`,
|
|
);
|
|
|
|
const inspection = await inspectSwarmProjectArtifacts(root);
|
|
|
|
expect(inspection.issues).toContainEqual({
|
|
path: 'exports/README.md',
|
|
reason: '仍包含占位标记',
|
|
});
|
|
});
|
|
});
|
|
|
|
it('accepts complete formal Markdown and checked task lists', async () => {
|
|
const completeReadme = [
|
|
'# Export',
|
|
'',
|
|
'- [x] 桌面视口试玩验证已经通过',
|
|
'- [X] 移动视口试玩验证已经通过',
|
|
'',
|
|
'版本信息已经填写,静态检查、人工复核和发布范围确认均已完成。',
|
|
'',
|
|
].join('\n');
|
|
expect(hasIncompleteArtifactMarker(completeReadme)).toBe(false);
|
|
|
|
await withTemporaryRoot(async (root) => {
|
|
await writeMinimumFormalArtifacts(root);
|
|
await writeFile(path.join(root, 'exports', 'README.md'), completeReadme);
|
|
|
|
await expect(validateSwarmProjectArtifacts(root)).resolves.toMatchObject({
|
|
valid: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
it('requires all 16 manifest tasks and browser evidence for the current revision', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
await writeMinimumFormalArtifacts(root);
|
|
const manifestPath = path.join(root, '.agent', 'manifest.json');
|
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
manifest.tasks[0].status = 'running';
|
|
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`);
|
|
|
|
let inspection = await inspectSwarmProjectArtifacts(root);
|
|
expect(inspection.issues).toContainEqual({
|
|
path: '.agent/manifest.json',
|
|
reason: '固定 16 个正式任务未全部且仅完成一次',
|
|
});
|
|
|
|
manifest.tasks[0].status = 'completed';
|
|
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`);
|
|
await writeFile(
|
|
path.join(root, '.agent', 'runtime', 'project-revision.json'),
|
|
'{"revision":9}\n',
|
|
);
|
|
inspection = await inspectSwarmProjectArtifacts(root);
|
|
expect(inspection.issues).toContainEqual({
|
|
path: '.agent/agent.db',
|
|
reason: '缺少当前 revision 的桌面与移动试玩通过凭证',
|
|
});
|
|
});
|
|
});
|
|
|
|
it('binds all 16 task lifecycles exactly once to the settled parent run', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
const parentRunId = 'swarm-project-supervisor-exactly-once';
|
|
await writeMinimumFormalArtifacts(root);
|
|
await writeReadyTaskExactlyOnceEvidence(root, parentRunId);
|
|
|
|
await expect(
|
|
validateSwarmProjectArtifacts(root, { parentRunId }),
|
|
).resolves.toMatchObject({ valid: true });
|
|
|
|
const databasePath = path.join(root, '.agent', 'agent.db');
|
|
const baselineDatabase = await readFile(databasePath, 'utf8');
|
|
const failedThenCompletedTaskId = requiredSwarmManifestTaskIds[0];
|
|
await writeFile(
|
|
databasePath,
|
|
`${baselineDatabase}${JSON.stringify({
|
|
recordType: 'agent.runtime.background_task.failed',
|
|
agentId: failedThenCompletedTaskId,
|
|
taskId: failedThenCompletedTaskId,
|
|
runId: `autonomous-ready-${failedThenCompletedTaskId}-fixture`,
|
|
source: 'agent-ready-task-scheduler',
|
|
})}\n`,
|
|
);
|
|
const failedThenCompleted = await inspectSwarmProjectArtifacts(root, {
|
|
parentRunId,
|
|
});
|
|
expect(failedThenCompleted.issues).toContainEqual({
|
|
path: `.agent/runtime/tasks/${failedThenCompletedTaskId}.jsonl`,
|
|
reason: `正式任务 ${failedThenCompletedTaskId} 未在当前父 Run 中恰好启动并完成一次`,
|
|
});
|
|
await writeFile(databasePath, baselineDatabase);
|
|
|
|
const duplicateTaskId = requiredSwarmManifestTaskIds[0];
|
|
await writeFile(
|
|
databasePath,
|
|
`${baselineDatabase}${JSON.stringify({
|
|
recordType: 'agent.runtime.background_task',
|
|
agentId: duplicateTaskId,
|
|
taskId: duplicateTaskId,
|
|
runId: `autonomous-ready-${duplicateTaskId}-fixture`,
|
|
source: 'agent-ready-task-scheduler',
|
|
})}\n`,
|
|
);
|
|
const duplicate = await inspectSwarmProjectArtifacts(root, {
|
|
parentRunId,
|
|
});
|
|
expect(duplicate.issues).toContainEqual({
|
|
path: `.agent/runtime/tasks/${duplicateTaskId}.jsonl`,
|
|
reason: `正式任务 ${duplicateTaskId} 未在当前父 Run 中恰好启动并完成一次`,
|
|
});
|
|
|
|
const secondRunTaskId = requiredSwarmManifestTaskIds[1];
|
|
const journalPath = path.join(
|
|
root,
|
|
'.agent',
|
|
'runtime',
|
|
'tasks',
|
|
`${secondRunTaskId}.jsonl`,
|
|
);
|
|
await writeFile(
|
|
journalPath,
|
|
`${await readFile(journalPath, 'utf8')}${JSON.stringify({
|
|
agentId: secondRunTaskId,
|
|
taskId: secondRunTaskId,
|
|
runId: `autonomous-ready-${secondRunTaskId}-second-attempt`,
|
|
source: 'agent-ready-task-scheduler',
|
|
runProfile: 'autonomous-game-build',
|
|
parentAgentId: 'project-supervisor',
|
|
parentRunId,
|
|
status: 'completed',
|
|
phase: 'completed',
|
|
})}\n`,
|
|
);
|
|
const secondRun = await inspectSwarmProjectArtifacts(root, {
|
|
parentRunId,
|
|
});
|
|
expect(secondRun.invalidPaths).toContain(
|
|
`.agent/runtime/tasks/${secondRunTaskId}.jsonl`,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('rejects a stale static smoke record from an older revision', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
await writeMinimumFormalArtifacts(root);
|
|
const databasePath = path.join(root, '.agent', 'agent.db');
|
|
const records = (await readFile(databasePath, 'utf8'))
|
|
.trim()
|
|
.split('\n')
|
|
.map((line) => JSON.parse(line));
|
|
records[0].revision = 7;
|
|
await writeFile(
|
|
databasePath,
|
|
`${records.map((record) => JSON.stringify(record)).join('\n')}\n`,
|
|
);
|
|
|
|
const inspection = await inspectSwarmProjectArtifacts(root);
|
|
expect(inspection.issues).toContainEqual({
|
|
path: '.agent/agent.db',
|
|
reason: '缺少当前 revision 的静态检查通过凭证',
|
|
});
|
|
});
|
|
});
|
|
|
|
it('requires valid image files only when the editor API key is configured', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
await writeMinimumFormalArtifacts(root);
|
|
const configDir = path.join(root, 'config');
|
|
await mkdir(configDir);
|
|
await writeFile(
|
|
path.join(configDir, configFileName),
|
|
'{"editorApi":{"apiKey":" "}}\n',
|
|
);
|
|
expect(await hasConfiguredEditorApiKey(configDir)).toBe(false);
|
|
await expect(validateSwarmProjectArtifacts(root)).resolves.toMatchObject({
|
|
valid: true,
|
|
requireEditorImages: false,
|
|
});
|
|
|
|
await writeFile(
|
|
path.join(configDir, localConfigFileName),
|
|
'{"editorApi":{"apiKey":"fixture-editor-key"}}\n',
|
|
);
|
|
expect(await hasConfiguredEditorApiKey(configDir)).toBe(true);
|
|
await expect(
|
|
validateSwarmProjectArtifacts(root, { requireEditorImages: true }),
|
|
).rejects.toThrow('assets/ui-prototype.png');
|
|
|
|
await writeFile(
|
|
path.join(root, 'assets', 'ui-prototype.png'),
|
|
Buffer.from('not-an-image'),
|
|
);
|
|
await writeFile(
|
|
path.join(root, 'assets', 'art-spritesheet.png'),
|
|
Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
|
|
);
|
|
await expect(
|
|
validateSwarmProjectArtifacts(root, { requireEditorImages: true }),
|
|
).rejects.toThrow('assets/ui-prototype.png');
|
|
|
|
await writeFile(
|
|
path.join(root, 'assets', 'ui-prototype.png'),
|
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
);
|
|
await expect(
|
|
validateSwarmProjectArtifacts(root, { requireEditorImages: true }),
|
|
).rejects.toThrow('assets/ui-prototype.png');
|
|
|
|
const headerOnlyPng = Buffer.alloc(1_024);
|
|
fixturePngSignature.copy(headerOnlyPng);
|
|
headerOnlyPng.writeUInt32BE(13, 8);
|
|
headerOnlyPng.write('IHDR', 12, 'ascii');
|
|
headerOnlyPng.writeUInt32BE(1_600, 16);
|
|
headerOnlyPng.writeUInt32BE(900, 20);
|
|
await writeFile(
|
|
path.join(root, 'assets', 'ui-prototype.png'),
|
|
headerOnlyPng,
|
|
);
|
|
await expect(
|
|
validateSwarmProjectArtifacts(root, { requireEditorImages: true }),
|
|
).rejects.toThrow('assets/ui-prototype.png');
|
|
|
|
await writeFile(
|
|
path.join(root, 'assets', 'ui-prototype.png'),
|
|
fixturePng(1_600, 900),
|
|
);
|
|
await writeFile(
|
|
path.join(root, 'assets', 'art-spritesheet.png'),
|
|
fixturePng(1_024, 1_024),
|
|
);
|
|
await expect(
|
|
validateSwarmProjectArtifacts(root, { requireEditorImages: true }),
|
|
).resolves.toMatchObject({ valid: true, requireEditorImages: true });
|
|
});
|
|
});
|
|
|
|
it('validates chunk CRC, zlib scanlines, filters, and screenshot PNGs', async () => {
|
|
const validPng = fixturePng(1_600, 900);
|
|
expect(validatePngBytes(validPng)).toEqual({ width: 1_600, height: 900 });
|
|
|
|
const crcCorrupted = Buffer.from(validPng);
|
|
crcCorrupted[42] ^= 0xff;
|
|
expect(() => validatePngBytes(crcCorrupted)).toThrow('CRC');
|
|
expect(() => validatePngBytes(validPng.subarray(0, -1))).toThrowError();
|
|
expect(() =>
|
|
validatePngBytes(fixturePng(320, 180, { invalidFilter: true })),
|
|
).toThrow('filter byte');
|
|
expect(() =>
|
|
validatePngBytes(fixturePng(320, 180, { trailingCompressedBytes: true })),
|
|
).toThrow('zlib');
|
|
expect(() =>
|
|
validatePngBytes(fixtureIndexedPng({ includePalette: false })),
|
|
).toThrow('PLTE');
|
|
expect(() =>
|
|
validatePngBytes(fixtureIndexedPng({ duplicatePalette: true })),
|
|
).toThrow('PLTE');
|
|
expect(() =>
|
|
validatePngBytes(fixtureIndexedPng({ unknownCriticalChunk: true })),
|
|
).toThrow('critical chunk');
|
|
expect(validatePngBytes(fixtureIndexedPng())).toEqual({
|
|
width: 1,
|
|
height: 1,
|
|
});
|
|
|
|
await withTemporaryRoot(async (root) => {
|
|
await writeMinimumFormalArtifacts(root);
|
|
const desktopScreenshot = path.join(
|
|
root,
|
|
'.agent',
|
|
'runtime',
|
|
'browser-validations',
|
|
'publish-package',
|
|
'test-run',
|
|
'8',
|
|
'desktop.png',
|
|
);
|
|
const fakeScreenshot = Buffer.alloc(2_048);
|
|
fixturePngSignature.copy(fakeScreenshot);
|
|
await writeFile(desktopScreenshot, fakeScreenshot);
|
|
|
|
const inspection = await inspectSwarmProjectArtifacts(root);
|
|
expect(inspection.issues).toContainEqual({
|
|
path: '.agent/agent.db',
|
|
reason: '缺少 desktop 试玩截图',
|
|
});
|
|
});
|
|
});
|
|
|
|
it('rejects non-PNG bytes at the fixed PNG artifact paths', async () => {
|
|
await withTemporaryRoot(async (root) => {
|
|
await writeMinimumFormalArtifacts(root);
|
|
await writeFile(
|
|
path.join(root, 'assets', 'ui-prototype.png'),
|
|
Buffer.from('RIFF\x04\x00\x00\x00WEBP', 'binary'),
|
|
);
|
|
await writeFile(
|
|
path.join(root, 'assets', 'art-spritesheet.png'),
|
|
Buffer.from('GIF89a', 'ascii'),
|
|
);
|
|
|
|
await expect(
|
|
validateSwarmProjectArtifacts(root, { requireEditorImages: true }),
|
|
).rejects.toThrow('assets/ui-prototype.png');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('preview URL validation', () => {
|
|
it('accepts an HTTP URL on the numeric loopback host', () => {
|
|
const previewUrl = 'http://127.0.0.1:4173/';
|
|
|
|
expect(validatePreviewUrl(previewUrl)).toBe(previewUrl);
|
|
});
|
|
|
|
it.each([
|
|
'https://127.0.0.1:4173/',
|
|
'http://localhost:4173/',
|
|
'http://[::1]:4173/',
|
|
'http://192.0.2.1:4173/',
|
|
'http://127.0.0.1.example:4173/',
|
|
'http://127.0.0.1/',
|
|
'http://127.0.0.1:4173/play',
|
|
'http://127.0.0.1:4173/?mode=test',
|
|
'http://127.0.0.1:4173/#ready',
|
|
'http://user@127.0.0.1:4173/',
|
|
'file:///fixture/game/index.html',
|
|
'not-a-url',
|
|
])('rejects %s', (previewUrl) => {
|
|
expect(() => validatePreviewUrl(previewUrl)).toThrowError();
|
|
});
|
|
});
|
|
|
|
describe('package script registration', () => {
|
|
it('registers the root and app config and test commands exactly', async () => {
|
|
const [rootPackage, appPackage, checkConfigSource] = await Promise.all(
|
|
[
|
|
new URL('../../../package.json', import.meta.url),
|
|
new URL('../package.json', import.meta.url),
|
|
new URL('../scripts/check-config.mjs', import.meta.url),
|
|
].map(async (packageUrl) =>
|
|
packageUrl.pathname.endsWith('.json')
|
|
? JSON.parse(await readFile(packageUrl, 'utf8'))
|
|
: readFile(packageUrl, 'utf8'),
|
|
),
|
|
);
|
|
|
|
expect(rootPackage.scripts?.['agc:config']).toBe(
|
|
'npm --prefix apps/ai-game-creator-shell run config --',
|
|
);
|
|
expect(rootPackage.scripts?.['agc:test']).toBe(
|
|
'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e --',
|
|
);
|
|
expect(rootPackage.scripts?.['agc:test:chat']).toBe(
|
|
'npm --prefix apps/ai-game-creator-shell run test:chat --',
|
|
);
|
|
expect(rootPackage.scripts?.['agc:test:chat:manual']).toBe(
|
|
'npm --prefix apps/ai-game-creator-shell run test:chat:manual --',
|
|
);
|
|
expect(appPackage.scripts?.['test:chat']).toBe(
|
|
`node scripts/agent-swarm-test-chat.mjs --task "${defaultRealSwarmTestTask}" --no-open`,
|
|
);
|
|
expect(appPackage.scripts?.config).toBe(
|
|
'node scripts/game-creator-config-wizard.mjs',
|
|
);
|
|
expect(appPackage.scripts?.['test:chat:manual']).toBe(
|
|
'node scripts/agent-swarm-test-chat.mjs',
|
|
);
|
|
expect(checkConfigSource).toMatch(
|
|
/packageConfig\.scripts\?\.config\s*!==\s*'node scripts\/game-creator-config-wizard\.mjs'/u,
|
|
);
|
|
expect(checkConfigSource).toMatch(
|
|
/rootPackageConfig\.scripts\?\.\['agc:config'\]\s*!==\s*'npm --prefix apps\/ai-game-creator-shell run config --'/u,
|
|
);
|
|
const wrapperSource = await readFile(
|
|
new URL('../scripts/run-cli-with-config.mjs', import.meta.url),
|
|
'utf8',
|
|
);
|
|
expect(wrapperSource).toContain('resolveGameCreatorAppConfigDir');
|
|
expect(wrapperSource).toContain(
|
|
"'--config-dir', resolveGameCreatorAppConfigDir()",
|
|
);
|
|
});
|
|
});
|