import { spawn } from 'node:child_process'; import { lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, symlink, writeFile, } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { deflateSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; import { appIdentifier, buildCargoCliArguments, buildMissingConfigWizardArguments, canPromptForMissingRuntimeConfig, childExitWithTimeout, cleanupSwarmTestProject, cleanupSwarmTestRuntimeConfig, configFileName, defaultRealSwarmTestTask, defaultRuntimeConfigDirCandidates, discoverRuntimeConfigDir, hasConfiguredEditorApiKey, hasGeneratedGameEntry, hasIncompleteArtifactMarker, inspectSwarmProjectArtifacts, localConfigFileName, parseRunnerShutdownOutput, parseSettledSwarmTurnReport, parseSwarmTestArguments, prepareSwarmTestProject, prepareSwarmTestRuntimeConfig, removeDirectoryWithTimeout, requiredSwarmManifestTaskIds, resolveSwarmTestTimeoutMs, runnerEndpointFileName, shouldStartPersistentPreview, terminateChildTree, testProjectPrefix, testProjectSentinelName, testProjectSentinelSchema, testRuntimeConfigPrefix, testRuntimeConfigSentinelName, testRuntimeConfigSentinelSchema, ungeneratedGameEntryMarker, validatePngBytes, validatePreviewUrl, validateSwarmProjectArtifacts, } from '../scripts/agent-swarm-test-chat.mjs'; import { buildGameCreatorWizardConfig, gameCreatorProviderPresets, normalizeWizardBaseUrl, parseConfigWizardArguments, readGameCreatorWizardConfigState, resolveGameCreatorAppConfigDir, writeGameCreatorConfigAtomically, writeGameCreatorWizardConfig, } from '../scripts/game-creator-config-wizard.mjs'; const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); async function withTemporaryRoot( run: (root: string) => Promise, ): Promise { 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 { return lstat(targetPath).then( () => true, (error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return false; throw error; }, ); } const minimumFormalArtifactContents: Record = { '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': '\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 { 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 { const databasePath = path.join(root, '.agent', 'agent.db'); const database = await readFile(databasePath, 'utf8'); const records: Array> = []; for (const taskId of requiredSwarmManifestTaskIds) { const runId = `autonomous-ready-${taskId}-fixture`; const taskDirectory = path.join(root, '.agent', 'runtime', 'tasks'); await mkdir(taskDirectory, { recursive: true }); await writeFile( path.join(taskDirectory, `${taskId}.jsonl`), `${JSON.stringify({ agentId: taskId, taskId, runId, source: 'agent-ready-task-scheduler', runProfile: 'autonomous-game-build', parentAgentId: 'project-supervisor', parentRunId, status: 'completed', phase: 'completed', })}\n`, ); records.push( { recordType: 'agent.runtime.background_task', agentId: taskId, taskId, runId, source: 'agent-ready-task-scheduler', }, { recordType: 'agent.runtime.background_task.completed', agentId: taskId, taskId, runId, source: 'agent-ready-task-scheduler', }, { recordType: 'agent.runtime.autonomous_ready_task.manifest_projected', agentId: taskId, taskId, runId, source: 'agent-ready-task-scheduler', parentAgentId: 'project-supervisor', parentRunId, terminalPhase: 'completed', manifestStatus: 'completed', }, ); } await writeFile( databasePath, `${database.trimEnd()}\n${records.map((record) => JSON.stringify(record)).join('\n')}\n`, ); } describe('terminal configuration wizard arguments', () => { it('parses the supported options and documented defaults', () => { const configDir = path.resolve('fixture-config'); expect(parseConfigWizardArguments([])).toEqual({ configDir: null, configureOnly: false, help: false, }); expect( parseConfigWizardArguments([ '--config-dir', configDir, '--configure-only', '--help', ]), ).toEqual({ configDir, configureOnly: true, help: true }); expect(parseConfigWizardArguments(['-h']).help).toBe(true); }); it.each([ ['separate API key argument', ['--api-key', 'fixture-secret'], 'API Key'], ['inline API key argument', ['--api-key=fixture-secret'], 'API Key'], ['relative config directory', ['--config-dir', 'relative'], '绝对路径'], ['missing config directory', ['--config-dir'], '缺少目录路径'], [ 'duplicate config directory', ['--config-dir', '/first', '--config-dir', '/second'], '只能指定一次', ], ['unknown option', ['--unknown'], '未知选项'], ])('rejects %s', (_label, args, marker) => { expect(() => parseConfigWizardArguments(args)).toThrow(marker); }); }); describe('terminal configuration wizard AppData paths', () => { it.each([ { label: 'Linux XDG config', context: { platform: 'linux', environment: { XDG_CONFIG_HOME: '/fixture/xdg' }, homeDirectory: '/fixture/home', }, candidate: path.posix.join('/fixture/xdg', appIdentifier), }, { label: 'macOS Application Support', context: { platform: 'darwin', environment: {}, homeDirectory: '/Users/fixture', }, candidate: path.posix.join( '/Users/fixture', 'Library', 'Application Support', appIdentifier, ), }, { label: 'Windows roaming AppData', context: { platform: 'win32', environment: { APPDATA: 'C:\\Users\\fixture\\AppData\\Roaming', LOCALAPPDATA: 'C:\\Users\\fixture\\AppData\\Local', }, homeDirectory: 'C:\\Users\\fixture', }, candidate: path.win32.join( 'C:\\Users\\fixture\\AppData\\Roaming', appIdentifier, ), }, ])( 'resolves the GUI-compatible $label directory', ({ context, candidate }) => { expect(resolveGameCreatorAppConfigDir(context)).toBe(candidate); }, ); it('uses an explicit absolute directory and rejects a relative one', () => { const explicitConfigDir = path.resolve('explicit-config'); expect(resolveGameCreatorAppConfigDir({ explicitConfigDir })).toBe( explicitConfigDir, ); expect(() => resolveGameCreatorAppConfigDir({ explicitConfigDir: 'relative-config' }), ).toThrow('绝对路径'); }); }); describe('terminal configuration wizard providers', () => { it('publishes the supported provider presets exactly', () => { expect(gameCreatorProviderPresets).toEqual([ { id: 'openai', label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4.1', apiKind: 'openai_responses', }, { id: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat', apiKind: 'openai_chat', }, { id: 'anthropic', label: 'Anthropic', baseUrl: 'https://api.anthropic.com', model: 'claude-3-5-sonnet-latest', apiKind: 'anthropic', }, { id: 'ark', label: '火山 Ark', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-seed-1-6', apiKind: 'openai_chat', }, { id: 'custom', label: '自定义', baseUrl: '', model: '', apiKind: 'openai_chat', }, ]); expect(Object.isFrozen(gameCreatorProviderPresets)).toBe(true); }); }); describe('terminal configuration wizard config merge', () => { it('updates the default LLM while retaining per-Agent, Editor API, and MCP config', () => { const existingConfig = { schemaVersion: 3, llm: { apiKey: 'old-fixture-secret', model: 'old-model', maxRetries: 2, }, agentLlm: { 'project-supervisor': { providerId: 'supervisor-provider' }, }, editorApi: { apiKey: 'fixture-editor-secret', baseUrl: 'https://editor.example.test/v1', }, mcpServers: [{ id: 'fixture-mcp', enabled: true }], }; const originalConfig = structuredClone(existingConfig); const merged = buildGameCreatorWizardConfig(existingConfig, { apiKey: ' new-fixture-secret ', baseUrl: 'https://llm.example.test/v1/', model: ' fixture-model ', apiKind: 'openai_chat', }); expect(merged).toEqual({ ...originalConfig, llm: { apiKey: 'new-fixture-secret', model: 'fixture-model', maxRetries: 2, baseUrl: 'https://llm.example.test/v1', apiKind: 'openai_chat', reasoningEffort: 'high', }, }); expect(merged.agentLlm).toEqual(originalConfig.agentLlm); expect(merged.editorApi).toEqual(originalConfig.editorApi); expect(merged.mcpServers).toEqual(originalConfig.mcpServers); expect(existingConfig).toEqual(originalConfig); }); it('uses provider-default reasoning for Anthropic', () => { expect( buildGameCreatorWizardConfig( { llm: { webSearchEnabled: true } }, { apiKey: 'fixture-secret', baseUrl: 'https://api.anthropic.com/', model: 'claude-fixture', apiKind: 'anthropic', }, ).llm, ).toMatchObject({ reasoningEffort: 'default', webSearchEnabled: false }); }); }); describe('terminal configuration wizard URL safety', () => { it.each([ ['https://provider.example.test/v1///', 'https://provider.example.test/v1'], ['http://127.0.0.1:8080/v1/', 'http://127.0.0.1:8080/v1'], ['http://localhost:8080/', 'http://localhost:8080'], ['http://[::1]:8080/v1/', 'http://[::1]:8080/v1'], ])('accepts %s', (input, expected) => { expect(normalizeWizardBaseUrl(input)).toBe(expected); }); it.each([ '', 'not-a-url', 'http://provider.example.test/v1', 'ftp://provider.example.test/v1', 'https://user:password@provider.example.test/v1', 'https://provider.example.test/v1?token=fixture', 'https://provider.example.test/v1#fragment', ])('rejects unsafe Base URL %s', (input) => { expect(() => normalizeWizardBaseUrl(input)).toThrowError(); }); }); describe('terminal configuration wizard persistence', () => { it('atomically replaces a private AppData config file', async () => { await withTemporaryRoot(async (root) => { const configDir = path.join(root, 'nested', appIdentifier); const configPath = path.join(configDir, configFileName); const firstConfig = { llm: { model: 'first-fixture-model' } }; const finalConfig = { llm: { model: 'final-fixture-model', apiKey: 'fixture-secret' }, agentLlm: { 'project-supervisor': { model: 'agent-fixture-model' } }, }; await expect( writeGameCreatorConfigAtomically(configPath, firstConfig), ).resolves.toBe(configPath); const firstMetadata = await lstat(configPath); await expect( writeGameCreatorConfigAtomically(configPath, finalConfig), ).resolves.toBe(configPath); const [directoryMetadata, finalMetadata, entries, contents] = await Promise.all([ lstat(configDir), lstat(configPath), readdir(configDir), readFile(configPath, 'utf8'), ]); expect(JSON.parse(contents)).toEqual(finalConfig); expect(contents.endsWith('\n')).toBe(true); expect(entries).toEqual([configFileName]); expect(finalMetadata.isFile()).toBe(true); expect(finalMetadata.isSymbolicLink()).toBe(false); if (process.platform !== 'win32') { expect(directoryMetadata.mode & 0o077).toBe(0); expect(finalMetadata.mode & 0o077).toBe(0); expect([finalMetadata.dev, finalMetadata.ino]).not.toEqual([ firstMetadata.dev, firstMetadata.ino, ]); } }); }); it('moves the default LLM to primary config and lets later GUI saves win', async () => { await withTemporaryRoot(async (root) => { const configDir = path.join(root, appIdentifier); const primaryPath = path.join(configDir, configFileName); const localPath = path.join(configDir, localConfigFileName); await mkdir(configDir); await writeFile( primaryPath, '{"llm":{"model":"primary","requestTimeoutMs":12345},"editorApi":{"apiKey":"canvas"}}\n', ); await writeFile( localPath, '{"llm":{"model":"stale-local","stream":true},"agentLlm":{"planner":{"model":"planner"}},"mcpServers":{"local":{"command":"fixture"}}}\n', ); const state = await readGameCreatorWizardConfigState(configDir); expect(state.configPath).toBe(primaryPath); expect(state.effectiveConfig.llm.model).toBe('stale-local'); const wizardConfig = buildGameCreatorWizardConfig(state.writeConfig, { apiKey: 'wizard-key', baseUrl: 'https://provider.example.test/v1', model: 'wizard-model', apiKind: 'openai_chat', }); await writeGameCreatorWizardConfig(state, wizardConfig); const sanitizedLocal = JSON.parse(await readFile(localPath, 'utf8')); expect(sanitizedLocal.llm).toBeUndefined(); expect(sanitizedLocal.agentLlm.planner.model).toBe('planner'); expect(sanitizedLocal.mcpServers.local.command).toBe('fixture'); const afterWizard = await readGameCreatorWizardConfigState(configDir); expect(afterWizard.effectiveConfig.llm).toMatchObject({ apiKey: 'wizard-key', model: 'wizard-model', stream: true, requestTimeoutMs: 12345, }); const guiConfig = JSON.parse(await readFile(primaryPath, 'utf8')); guiConfig.llm = { ...guiConfig.llm, apiKey: 'gui-key', model: 'gui-model', }; await writeGameCreatorConfigAtomically(primaryPath, guiConfig); const afterGui = await readGameCreatorWizardConfigState(configDir); expect(afterGui.effectiveConfig.llm.apiKey).toBe('gui-key'); expect(afterGui.effectiveConfig.llm.model).toBe('gui-model'); }); }); }); describe('Swarm test argument parsing', () => { it('returns the documented defaults', () => { expect(parseSwarmTestArguments([])).toEqual({ configDir: null, projectDir: null, keepProject: false, openBrowser: true, task: null, timeoutMinutes: null, dryRun: false, help: false, }); }); it('parses every supported option', () => { const configDir = path.resolve('fixture-config'); const projectDir = path.resolve('fixture-project'); expect( parseSwarmTestArguments([ '--config-dir', configDir, '--project-dir', projectDir, '--keep-project', '--no-open', '--task', '生成一款可试玩的塔防游戏', '--timeout-minutes', '75', '--dry-run', '--help', ]), ).toEqual({ configDir, projectDir, keepProject: true, openBrowser: false, task: '生成一款可试玩的塔防游戏', timeoutMinutes: 75, dryRun: true, help: true, }); expect(parseSwarmTestArguments(['-h']).help).toBe(true); expect(() => parseSwarmTestArguments(['--task', ''])).toThrowError(); }); it('keeps persistent preview only for manual chat mode', () => { expect(shouldStartPersistentPreview(parseSwarmTestArguments([]))).toBe( true, ); expect( shouldStartPersistentPreview( parseSwarmTestArguments(['--task', '生成一款可试玩的塔防游戏']), ), ).toBe(false); }); it('applies a bounded default only to non-interactive tasks', () => { expect(resolveSwarmTestTimeoutMs(parseSwarmTestArguments([]))).toBeNull(); expect( resolveSwarmTestTimeoutMs( parseSwarmTestArguments(['--task', '生成原创塔防游戏']), ), ).toBe(50 * 60_000); expect( resolveSwarmTestTimeoutMs( parseSwarmTestArguments(['--timeout-minutes', '12']), ), ).toBe(12 * 60_000); }); it.each([ { label: 'duplicate config directory', args: ['--config-dir', '/first', '--config-dir', '/second'], marker: '--config-dir', }, { label: 'duplicate project directory', args: ['--project-dir', '/first', '--project-dir', '/second'], marker: '--project-dir', }, { label: 'missing config directory', args: ['--config-dir'], marker: '--config-dir', }, { label: 'missing project directory', args: ['--project-dir', '--dry-run'], marker: '--project-dir', }, { label: 'duplicate timeout', args: ['--timeout-minutes', '10', '--timeout-minutes', '20'], marker: '--timeout-minutes', }, { label: 'invalid timeout', args: ['--timeout-minutes', '0'], marker: '1-1440', }, { label: 'unknown option', args: ['--unsupported'], marker: '--unsupported', }, ])('rejects $label', ({ args, marker }) => { expect(() => parseSwarmTestArguments(args)).toThrow(marker); }); }); describe('runtime config directory candidates', () => { it('uses an absolute Linux XDG config root', () => { expect( defaultRuntimeConfigDirCandidates({ platform: 'linux', environment: { XDG_CONFIG_HOME: '/fixture/xdg' }, homeDirectory: '/fixture/home', }), ).toEqual([path.posix.join('/fixture/xdg', appIdentifier)]); }); it('falls back to the Linux home config root', () => { expect( defaultRuntimeConfigDirCandidates({ platform: 'linux', environment: { XDG_CONFIG_HOME: 'relative-xdg' }, homeDirectory: '/fixture/home', }), ).toEqual([path.posix.join('/fixture/home', '.config', appIdentifier)]); }); it('uses the macOS Application Support directory', () => { expect( defaultRuntimeConfigDirCandidates({ platform: 'darwin', environment: {}, homeDirectory: '/Users/fixture', }), ).toEqual([ path.posix.join( '/Users/fixture', 'Library', 'Application Support', appIdentifier, ), ]); }); it('uses both Windows roaming and local AppData directories', () => { const appData = 'C:\\Users\\fixture\\AppData\\Roaming'; const localAppData = 'C:\\Users\\fixture\\AppData\\Local'; expect( defaultRuntimeConfigDirCandidates({ platform: 'win32', environment: { APPDATA: appData, LOCALAPPDATA: localAppData, }, homeDirectory: 'C:\\Users\\fixture', }), ).toEqual([ path.win32.join(appData, appIdentifier), path.win32.join(localAppData, appIdentifier), ]); }); }); describe('runtime config discovery', () => { it('passes an empty explicit config directory through the TTY wizard only', () => { const explicitConfigDir = path.resolve('empty-explicit-config'); const argumentsForWizard = buildMissingConfigWizardArguments(explicitConfigDir); expect(argumentsForWizard.slice(1)).toEqual([ '--configure-only', '--config-dir', explicitConfigDir, ]); expect(argumentsForWizard[0]).toMatch(/game-creator-config-wizard\.mjs$/u); expect(canPromptForMissingRuntimeConfig(true, true)).toBe(true); expect(canPromptForMissingRuntimeConfig(false, true)).toBe(false); expect(canPromptForMissingRuntimeConfig(true, false)).toBe(false); }); it('discovers an explicitly selected config directory', async () => { await withTemporaryRoot(async (root) => { const configDir = path.join(root, 'explicit-config'); await mkdir(configDir); await writeFile(path.join(configDir, configFileName), '{}\n'); await expect( discoverRuntimeConfigDir(configDir, { platform: 'linux', environment: { XDG_CONFIG_HOME: path.join(root, 'unused') }, homeDirectory: path.join(root, 'unused-home'), }), ).resolves.toBe(await realpath(configDir)); }); }); it('discovers a config directory from an isolated XDG root', async () => { await withTemporaryRoot(async (root) => { const xdgRoot = path.join(root, 'xdg'); const configDir = path.join(xdgRoot, appIdentifier); await mkdir(configDir, { recursive: true }); await writeFile(path.join(configDir, configFileName), '{}\n'); await expect( discoverRuntimeConfigDir(null, { platform: 'linux', environment: { XDG_CONFIG_HOME: xdgRoot }, homeDirectory: path.join(root, 'unused-home'), }), ).resolves.toBe(await realpath(configDir)); }); }); it('rejects symlinked and non-file config entries', async () => { await withTemporaryRoot(async (root) => { const target = path.join(root, 'config-target.json'); await writeFile(target, '{}\n'); const symlinkConfigDir = path.join(root, 'symlink-config'); await mkdir(symlinkConfigDir); await symlink(target, path.join(symlinkConfigDir, configFileName)); await expect(discoverRuntimeConfigDir(symlinkConfigDir)).rejects.toThrow( configFileName, ); const directoryConfigDir = path.join(root, 'directory-config'); await mkdir(path.join(directoryConfigDir, configFileName), { recursive: true, }); await expect( discoverRuntimeConfigDir(directoryConfigDir), ).rejects.toThrow(configFileName); }); }); it('rejects a relative explicit config directory', async () => { await expect(discoverRuntimeConfigDir('relative-config')).rejects.toThrow( '--config-dir', ); }); }); describe('isolated Swarm runtime config', () => { it('privately copies only active config files and removes the owned directory', async () => { await withTemporaryRoot(async (root) => { const sourceConfigDir = path.join(root, 'source-config'); await mkdir(sourceConfigDir); await writeFile( path.join(sourceConfigDir, configFileName), '{"llm":{"apiKey":"fixture-credential"}}\n', { mode: 0o600 }, ); await writeFile( path.join(sourceConfigDir, localConfigFileName), '{"llm":{"model":"fixture-model"}}\n', { mode: 0o600 }, ); await writeFile( path.join(sourceConfigDir, runnerEndpointFileName), '{"mustNotCopy":true}\n', ); await writeFile( path.join(sourceConfigDir, 'agent-runner.lock'), 'must-not-copy\n', ); await writeFile( path.join(sourceConfigDir, `.${configFileName}.previous`), 'must-not-copy\n', ); const sourceMetadata = await lstat( path.join(sourceConfigDir, configFileName), ); const runtimeConfig = await prepareSwarmTestRuntimeConfig( sourceConfigDir, root, ); const runtimeEntries = (await readdir(runtimeConfig.path)).sort(); const runtimeDirectoryMetadata = await lstat(runtimeConfig.path); const primaryMetadata = await lstat( path.join(runtimeConfig.path, configFileName), ); const localMetadata = await lstat( path.join(runtimeConfig.path, localConfigFileName), ); const sentinel = JSON.parse( await readFile( path.join(runtimeConfig.path, testRuntimeConfigSentinelName), 'utf8', ), ); expect(runtimeConfig.sourcePath).toBe(await realpath(sourceConfigDir)); expect(path.basename(runtimeConfig.path)).toMatch( new RegExp(`^${testRuntimeConfigPrefix}`), ); expect(runtimeEntries).toEqual( [ configFileName, localConfigFileName, testRuntimeConfigSentinelName, ].sort(), ); expect( await readFile(path.join(runtimeConfig.path, configFileName), 'utf8'), ).toBe('{"llm":{"apiKey":"fixture-credential"}}\n'); expect( await readFile( path.join(runtimeConfig.path, localConfigFileName), 'utf8', ), ).toBe('{"llm":{"model":"fixture-model"}}\n'); expect(sentinel).toEqual({ schemaVersion: testRuntimeConfigSentinelSchema, token: runtimeConfig.sentinelToken, }); if (process.platform !== 'win32') { expect(runtimeDirectoryMetadata.mode & 0o077).toBe(0); expect(primaryMetadata.mode & 0o077).toBe(0); expect(localMetadata.mode & 0o077).toBe(0); expect([primaryMetadata.dev, primaryMetadata.ino]).not.toEqual([ sourceMetadata.dev, sourceMetadata.ino, ]); } expect( await pathExists(path.join(runtimeConfig.path, runnerEndpointFileName)), ).toBe(false); await expect(cleanupSwarmTestRuntimeConfig(runtimeConfig)).resolves.toBe( true, ); expect(await pathExists(runtimeConfig.path)).toBe(false); expect(await pathExists(sourceConfigDir)).toBe(true); expect( await readFile(path.join(sourceConfigDir, configFileName), 'utf8'), ).toBe('{"llm":{"apiKey":"fixture-credential"}}\n'); }); }); it('rejects a symlinked local override without leaving a temporary directory', async () => { if (process.platform === 'win32') return; await withTemporaryRoot(async (root) => { const sourceConfigDir = path.join(root, 'source-config'); const localTarget = path.join(root, 'local-target.json'); await mkdir(sourceConfigDir); await writeFile(path.join(sourceConfigDir, configFileName), '{}\n'); await writeFile(localTarget, '{}\n'); await symlink( localTarget, path.join(sourceConfigDir, localConfigFileName), ); await expect( prepareSwarmTestRuntimeConfig(sourceConfigDir, root), ).rejects.toThrow(localConfigFileName); expect( (await readdir(root)).filter((entry) => entry.startsWith(testRuntimeConfigPrefix), ), ).toEqual([]); }); }); it('refuses to delete an isolated config while its Runner endpoint exists', async () => { await withTemporaryRoot(async (root) => { const sourceConfigDir = path.join(root, 'source-config'); await mkdir(sourceConfigDir); await writeFile(path.join(sourceConfigDir, configFileName), '{}\n'); const runtimeConfig = await prepareSwarmTestRuntimeConfig( sourceConfigDir, root, ); const endpointPath = path.join( runtimeConfig.path, runnerEndpointFileName, ); await writeFile(endpointPath, '{}\n'); await expect( cleanupSwarmTestRuntimeConfig(runtimeConfig), ).rejects.toThrow('Runner'); expect(await pathExists(runtimeConfig.path)).toBe(true); await rm(endpointPath); await expect(cleanupSwarmTestRuntimeConfig(runtimeConfig)).resolves.toBe( true, ); }); }); }); describe('Swarm test project ownership', () => { it('creates a sentinel-owned project and removes it during cleanup', async () => { await withTemporaryRoot(async (root) => { const project = await prepareSwarmTestProject(null, root); const sentinelPath = path.join(project.path, testProjectSentinelName); const sentinelMetadata = await lstat(sentinelPath); const sentinel = JSON.parse(await readFile(sentinelPath, 'utf8')); expect(project.owned).toBe(true); expect(project.sentinelToken).toEqual(expect.any(String)); expect(path.basename(project.path)).toMatch( new RegExp(`^${testProjectPrefix}`), ); expect(sentinelMetadata.isFile()).toBe(true); expect(sentinelMetadata.isSymbolicLink()).toBe(false); expect(sentinel).toEqual({ schemaVersion: testProjectSentinelSchema, token: project.sentinelToken, }); await expect(cleanupSwarmTestProject(project)).resolves.toBe(true); expect(await pathExists(project.path)).toBe(false); }); }); it('refuses cleanup after the sentinel identity is changed', async () => { await withTemporaryRoot(async (root) => { const project = await prepareSwarmTestProject(null, root); await writeFile( path.join(project.path, testProjectSentinelName), `${JSON.stringify({ schemaVersion: testProjectSentinelSchema, token: 'changed-token', })}\n`, ); await expect(cleanupSwarmTestProject(project)).rejects.toThrowError(); expect(await pathExists(project.path)).toBe(true); }); }); it('keeps an explicit empty directory unowned', async () => { await withTemporaryRoot(async (root) => { const explicitProjectDir = path.join(root, 'explicit-project'); await mkdir(explicitProjectDir); const project = await prepareSwarmTestProject(explicitProjectDir, root); expect(project).toEqual({ path: await realpath(explicitProjectDir), owned: false, sentinelToken: null, }); await expect(cleanupSwarmTestProject(project)).resolves.toBe(false); expect(await readdir(explicitProjectDir)).toEqual([]); }); }); it('rejects a non-empty uninitialized explicit directory', async () => { await withTemporaryRoot(async (root) => { const explicitProjectDir = path.join(root, 'uninitialized-project'); await mkdir(explicitProjectDir); await writeFile(path.join(explicitProjectDir, 'existing.txt'), 'fixture'); await expect( prepareSwarmTestProject(explicitProjectDir, root), ).rejects.toThrow('--project-dir'); }); }); }); describe('cargo CLI argument construction', () => { it('uses the shell manifest and separates cargo from application arguments', () => { const cliArguments = ['--config-dir', 'fixture-config', '--llm-status']; const cargoArguments = buildCargoCliArguments(cliArguments); expect(cargoArguments.slice(0, 2)).toEqual(['run', '--manifest-path']); expect(path.isAbsolute(cargoArguments[2])).toBe(true); expect(path.relative(appRoot, cargoArguments[2])).toBe( path.join('src-tauri', 'Cargo.toml'), ); expect(cargoArguments[3]).toBe('--'); expect(cargoArguments.slice(4)).toEqual(cliArguments); }); it('parses the idle Runner shutdown marker', () => { expect(parseRunnerShutdownOutput('runner.stopped=true\n')).toBe(true); expect(parseRunnerShutdownOutput('runner.stopped=false\n')).toBe(false); expect(() => parseRunnerShutdownOutput('runner.status=unknown\n')).toThrow( 'stopped', ); }); }); describe('bounded process-tree termination', () => { it('kills a POSIX process group after its leader exits with stdio still open', async () => { if (process.platform === 'win32') return; const child = spawn( process.execPath, [ '-e', `const { spawn } = require('node:child_process'); const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'inherit', }); grandchild.once('spawn', () => { process.stdout.write('grandchild-ready\\n'); setTimeout(() => process.exit(0), 50); }); grandchild.once('error', () => process.exit(2));`, ], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] }, ); try { child.stdout.setEncoding('utf8'); const grandchildReady = new Promise((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((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, `
${ungeneratedGameEntryMarker}
`, ); expect(await hasGeneratedGameEntry(root)).toBe(false); await writeFile(gameEntry, ''); 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, '\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()", ); }); });