569 lines
18 KiB
TypeScript
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',
|
|
);
|
|
});
|
|
});
|