Files
lhk229 2fa967a93a
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Successful in 3m15s
Project CI / Native shell tests (pull_request) Failing after 15m21s
删除策划 V1 的测试脚本入口并同步退役文档
删除 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 链路源码整体退役决策记录
2026-09-08 10:43:38 +00:00

1889 lines
63 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()",
);
});
});