2fa967a93a
删除 agent-swarm-test-chat.mjs 的 --plan 模式、自动 GDD 审批回路与 planning 产物检查 删除根与应用 package.json 的 test:plan / test:plan:manual / agc:test:plan* 四条脚本 同步删除 agentSwarmTestEntry.test.ts 中只覆盖 V1 审批回路与 --plan 参数的用例 GddApprovalCard.tsx 注释改指 planning_gdd_model.rs 的现行路径权威定义 立项策划Agent(Fast GDD)方案文档头部标注已退役,仅作历史推导记录 策划会话 Runtime V2 方案文档状态更新为 P5 已完成并记录源码删除执行清单 Provider 兼容性缺陷文档的缺陷 4 标注相关代码已随 V1 退役删除 decision-log 新增 2026-09-08 策划 V1 链路源码整体退役决策记录
1889 lines
63 KiB
TypeScript
1889 lines
63 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,
|
||
nextSwarmAutoPilotReply,
|
||
parseRunnerShutdownOutput,
|
||
parseSettledSwarmTurnReport,
|
||
parseSwarmTestArguments,
|
||
prepareSwarmTestProject,
|
||
prepareSwarmTestRuntimeConfig,
|
||
removeDirectoryWithTimeout,
|
||
requiredSwarmManifestTaskIds,
|
||
resolveSwarmTestTimeoutMs,
|
||
runnerEndpointFileName,
|
||
shouldStartPersistentPreview,
|
||
swarmAutoPilotShouldCloseInput,
|
||
swarmAutoPilotSitsAtPrompt,
|
||
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 and Editor API config', () => {
|
||
const existingConfig = {
|
||
schemaVersion: 'game-creator-config.v2',
|
||
agentMode: 'codex_app_server',
|
||
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',
|
||
},
|
||
};
|
||
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,
|
||
agentMode: 'provider',
|
||
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(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 persistenceOptions =
|
||
process.platform === 'win32'
|
||
? { secureWindowsPath: async () => {} }
|
||
: {};
|
||
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"}}}\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,
|
||
persistenceOptions,
|
||
);
|
||
|
||
const sanitizedLocal = JSON.parse(await readFile(localPath, 'utf8'));
|
||
expect(sanitizedLocal.llm).toBeUndefined();
|
||
expect(sanitizedLocal.agentLlm.planner.model).toBe('planner');
|
||
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,
|
||
persistenceOptions,
|
||
);
|
||
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('auto-answers only the confirmation and choice prompts', () => {
|
||
expect(
|
||
nextSwarmAutoPilotReply(
|
||
'[待确认] agent=project-supervisor run=r action=a tool=agent.delegate\n输入 approve 或 reject:',
|
||
),
|
||
).toBe('approve');
|
||
expect(nextSwarmAutoPilotReply('请选择 1-3,或直接输入其他答案:')).toBe(
|
||
'1',
|
||
);
|
||
// 提示词已经被上一轮消费掉、不在缓冲末尾时不得重复应答。
|
||
expect(
|
||
nextSwarmAutoPilotReply('输入 approve 或 reject:\n[已批准] action-1\n'),
|
||
).toBeNull();
|
||
expect(
|
||
nextSwarmAutoPilotReply('[状态] project-supervisor running'),
|
||
).toBeNull();
|
||
});
|
||
|
||
it('closes the driven stdin only at the prompt that follows submission', () => {
|
||
// CLI 先打印提示符再读行:第一个「你>」是用来读我们这条任务的,此时本轮还没
|
||
// 开始跑。在那里收 stdin,确认卡弹出来时已经是 EOF,自动应答器根本没机会回
|
||
// approve——实测就这样把一轮跑成了 pending-confirmation。
|
||
expect(swarmAutoPilotSitsAtPrompt('你> ')).toBe(true);
|
||
expect(swarmAutoPilotShouldCloseInput('你> ', 0)).toBe(false);
|
||
expect(swarmAutoPilotShouldCloseInput('你> ', 1)).toBe(true);
|
||
// 总控判定直接回复那条路径没有 turn 回执,同样靠这个提示符收口。
|
||
expect(swarmAutoPilotShouldCloseInput('[意图] reply\n\n你> ', 1)).toBe(
|
||
true,
|
||
);
|
||
// 提示符不在缓冲末尾、或本轮还在推进时不得收 stdin。
|
||
expect(
|
||
swarmAutoPilotSitsAtPrompt(
|
||
'你> [决策] Agent 正在判断直接回复或调用持久能力。',
|
||
),
|
||
).toBe(false);
|
||
expect(
|
||
swarmAutoPilotSitsAtPrompt('[状态] project-supervisor running/planning'),
|
||
).toBe(false);
|
||
});
|
||
|
||
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);
|
||
|
||
// Assert the separator invariants rather than fixed positions: cargo flags
|
||
// may be added before `--`, but everything after it must reach the CLI
|
||
// unchanged, and the manifest must stay this shell's own.
|
||
const separatorIndex = cargoArguments.indexOf('--');
|
||
expect(cargoArguments[0]).toBe('run');
|
||
expect(separatorIndex).toBeGreaterThan(0);
|
||
expect(cargoArguments.slice(separatorIndex + 1)).toEqual(cliArguments);
|
||
|
||
const manifestIndex = cargoArguments.indexOf('--manifest-path');
|
||
expect(manifestIndex).toBeGreaterThan(0);
|
||
expect(manifestIndex).toBeLessThan(separatorIndex);
|
||
expect(path.isAbsolute(cargoArguments[manifestIndex + 1])).toBe(true);
|
||
expect(path.relative(appRoot, cargoArguments[manifestIndex + 1])).toBe(
|
||
path.join('src-tauri', 'Cargo.toml'),
|
||
);
|
||
});
|
||
|
||
it('keeps cargo build chatter out of the run output', () => {
|
||
// The dead-code warnings are reprinted on every spawn; without --quiet they
|
||
// bury the swarm output this script exists to surface.
|
||
const cargoArguments = buildCargoCliArguments(['--llm-status']);
|
||
const separatorIndex = cargoArguments.indexOf('--');
|
||
|
||
expect(cargoArguments.slice(0, separatorIndex)).toContain('--quiet');
|
||
});
|
||
|
||
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()",
|
||
);
|
||
});
|
||
});
|