Files
Genarrative/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts
T
AIGameCreator App ddbca15e30
Project CI / Frontend tests (push) Failing after 50s
Project CI / Repository checks (push) Successful in 2m42s
Project CI / Backend tests (push) Successful in 6m58s
Project CI / Native shell tests (push) Failing after 11m11s
修复Agent Swarm聊天测试Runner冲突
为人工聊天测试复制隔离 AppData,避免复用正式 Runner
新增空闲 Runner 安全收束与临时目录清理
补齐初始化占位页校验、回归测试和开发文档
2026-07-23 13:52:06 +08:00

569 lines
18 KiB
TypeScript

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 { describe, expect, it } from 'vitest';
import {
appIdentifier,
buildCargoCliArguments,
cleanupSwarmTestProject,
cleanupSwarmTestRuntimeConfig,
configFileName,
defaultRuntimeConfigDirCandidates,
discoverRuntimeConfigDir,
hasGeneratedGameEntry,
localConfigFileName,
parseRunnerShutdownOutput,
parseSwarmTestArguments,
prepareSwarmTestProject,
prepareSwarmTestRuntimeConfig,
runnerEndpointFileName,
testProjectPrefix,
testProjectSentinelName,
testProjectSentinelSchema,
testRuntimeConfigPrefix,
testRuntimeConfigSentinelName,
testRuntimeConfigSentinelSchema,
ungeneratedGameEntryMarker,
validatePreviewUrl,
} from '../scripts/agent-swarm-test-chat.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;
},
);
}
describe('Swarm test argument parsing', () => {
it('returns the documented defaults', () => {
expect(parseSwarmTestArguments([])).toEqual({
configDir: null,
projectDir: null,
keepProject: false,
openBrowser: true,
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',
'--dry-run',
'--help',
]),
).toEqual({
configDir,
projectDir,
keepProject: true,
openBrowser: false,
dryRun: true,
help: true,
});
expect(parseSwarmTestArguments(['-h']).help).toBe(true);
});
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: '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('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('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('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 test commands exactly', async () => {
const [rootPackage, appPackage] = await Promise.all(
[
new URL('../../../package.json', import.meta.url),
new URL('../package.json', import.meta.url),
].map(async (packageUrl) =>
JSON.parse(await readFile(packageUrl, 'utf8')),
),
);
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(appPackage.scripts?.['test:chat']).toBe(
'node scripts/agent-swarm-test-chat.mjs',
);
});
});