Files
Genarrative/scripts/dev.test.ts
T
suzmii ea03c2b5c8
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m15s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 56s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 1m57s
Project CI / Repository checks (pull_request) Failing after 16s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m23s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m1s
Project CI / Native shell tests (pull_request) Successful in 5m45s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m37s
本地 dev 不再把默认 CLI 配置里的远程 token 当作本地发布凭据
- shouldTrustExistingSpacetimeToken:本地(loopback)服务器额外要求 token 由本机签发(JWT iss 为 localhost/127.0.0.1/::1);默认 CLI 配置里的远程账号 token 形如有效但验签必然失败,会在 pre-publish check 抛 401 InvalidSignature
- 显式 shell 覆盖 GENARRATIVE_SPACETIME_TOKEN 的语义保持不变,仍然优先
- 单测:远程签发 token 在本地服务器上不被信任、本机签发 token 被信任
- 验证:scripts/dev.test.ts 70 passed
- 未覆盖(后续):本机签发但已失效的旧 token(数据目录重建后签名失效,iss 仍为 localhost)仍需手工清理 dev-cli/cli.toml 与 dev-api-identities/local-node.json;自动自愈需要把发布调用改成非致命,因为当前 runForeground 失败会直接结束进程,调用层 catch 拿不到
2026-09-28 11:52:02 +08:00

1881 lines
62 KiB
TypeScript
Raw 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 { createHash } from 'node:crypto';
import {
chmodSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
assertStandaloneVersionMatchesCli,
buildApiServerProcessEnv,
buildBgfilterWorkerProcessEnv,
buildDevStackSnapshot,
buildFrontendProcessEnv,
buildLocalRustProcessEnv,
buildSpacetimePublishArgs,
createDevServerSpawnOptions,
createWatchConfigs,
DevRunner,
isDirectModuleExecution,
isLocallyIssuedSpacetimeToken,
isSpacetimePublishPermissionError,
isStaleExternalGenerationWorkerProcess,
normalizeCargoVersionRequirement,
parseArgs,
parseProcessEnvBlock,
parseSpacetimeToolCommit,
parseSpacetimeToolVersion,
resolveDevStackStatePath,
resolveLocalSpacetimeApiIdentityPath,
resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath,
shouldAcceptWatchEvent,
shouldTrustExistingSpacetimeToken,
} from './dev.mjs';
/// 造一个带指定 iss 的假 JWT(只用于断言签发者判定)。
const fakeJwt = (issuer: string): string =>
[
Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'),
Buffer.from(JSON.stringify({ iss: issuer, sub: 'fake' })).toString(
'base64url',
),
'signature',
].join('.');
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
function workspaceSpacetimeVersionForTest() {
const manifestText = readFileSync('server-rs/Cargo.toml', 'utf8');
const match = /^spacetimedb\s*=\s*"([^"]+)"/mu.exec(manifestText);
if (!match) {
throw new Error('无法读取测试用 SpacetimeDB 版本');
}
return normalizeCargoVersionRequirement(match[1]);
}
describe('dev scheduler argument routing', () => {
test('拒绝 CLI 已升级但 standalone 仍为旧版本的工具链', () => {
expect(() => assertStandaloneVersionMatchesCli('2.8.3', '2.7.0')).toThrow(
'不能只更新 CLI',
);
expect(() =>
assertStandaloneVersionMatchesCli('2.8.3', '2.8.3'),
).not.toThrow();
});
test('backend 模式避开已占用的 worker 端口并同步代理目标', async () => {
const occupied = createServer();
await new Promise<void>((resolveReady) =>
occupied.listen(0, '127.0.0.1', resolveReady),
);
const address = occupied.address();
if (!address || typeof address === 'string')
throw new Error('监听地址无效');
try {
const { command, explicitOptions, options } = parseArgs(['backend'], {});
options.apiPort = 0;
options.bgfilterWorkerPort = address.port;
const runner = new DevRunner(options, {}, explicitOptions);
runner.state.spacetimeReused = true;
await runner.resolvePorts(command);
expect(runner.options.bgfilterWorkerPort).not.toBe(address.port);
expect(runner.options.bgfilterWorkerPort).not.toBe(
runner.options.apiPort,
);
expect(runner.state.bgfilterWorkerTarget).toBe(
`http://127.0.0.1:${runner.options.bgfilterWorkerPort}`,
);
} finally {
await new Promise<void>((resolveClosed) =>
occupied.close(() => resolveClosed()),
);
}
});
const linuxTest = process.platform === 'linux' ? test : test.skip;
test('Windows junction 路径下的直接执行入口也能识别为当前模块', () => {
const moduleUrl =
'file:///F:/DevWorktrees/codex/worktrees/f584/Genarrative/scripts/dev.mjs';
const argv1 =
'C:\\Users\\wuxiangwanzi\\.codex\\worktrees\\f584\\Genarrative\\scripts\\dev.mjs';
const resolvePath = (value) =>
value.startsWith('C:\\Users\\')
? 'F:\\DevWorktrees\\codex\\worktrees\\f584\\Genarrative\\scripts\\dev.mjs'
: value;
expect(isDirectModuleExecution(argv1, moduleUrl, resolvePath)).toBe(true);
});
test('完整 dev 栈覆盖前端代理到本次解析出的 api-server 地址', () => {
const { command, explicitOptions, options } = parseArgs([], {
GENARRATIVE_API_PORT: '8090',
GENARRATIVE_RUNTIME_SERVER_TARGET: 'http://127.0.0.1:3100',
RUST_SERVER_TARGET: 'http://127.0.0.1:3100',
GENARRATIVE_API_TARGET: 'http://127.0.0.1:3100',
});
const runner = new DevRunner(options, {}, explicitOptions);
runner.command = command;
expect(runner.resolveFrontendApiTarget()).toBe('http://127.0.0.1:8090');
});
test('独立 BgFilter worker 命令解析内部监听地址', () => {
const { command, explicitOptions, options } = parseArgs(
[
'bgfilter-worker',
'--bgfilter-worker-host',
'127.0.0.2',
'--bgfilter-worker-port',
'18083',
],
{},
);
expect(command).toBe('bgfilter-worker');
expect(explicitOptions).toEqual(
new Set(['bgfilterWorkerHost', 'bgfilterWorkerPort']),
);
expect(options.bgfilterWorkerHost).toBe('127.0.0.2');
expect(options.bgfilterWorkerPort).toBe(18083);
});
test('单独 dev:web 未显式指定 api 参数时沿用已有 Rust target', () => {
const testEnv = {
RUST_SERVER_TARGET: 'http://127.0.0.1:3100',
GENARRATIVE_API_PORT: '8082',
};
const { command, explicitOptions, options } = parseArgs(['web'], testEnv);
const runner = new DevRunner(options, testEnv, explicitOptions);
runner.command = command;
expect(runner.resolveFrontendApiTarget()).toBe('http://127.0.0.1:3100');
});
test('单独 dev:web 显式指定 api-port 时覆盖代理目标', () => {
const testEnv = {
RUST_SERVER_TARGET: 'http://127.0.0.1:3100',
GENARRATIVE_API_PORT: '8082',
};
const { command, explicitOptions, options } = parseArgs(
['web', '--api-port', '9090'],
testEnv,
);
const runner = new DevRunner(options, testEnv, explicitOptions);
runner.command = command;
expect(runner.resolveFrontendApiTarget()).toBe('http://127.0.0.1:9090');
});
test('单独 dev:admin-web 优先沿用 ADMIN_API_TARGET', () => {
const testEnv = {
ADMIN_API_TARGET: 'http://127.0.0.1:3100',
RUST_SERVER_TARGET: 'http://127.0.0.1:8082',
};
const { command, explicitOptions, options } = parseArgs(
['admin-web'],
testEnv,
);
const runner = new DevRunner(options, testEnv, explicitOptions);
runner.command = command;
expect(runner.resolveFrontendApiTarget({ admin: true })).toBe(
'http://127.0.0.1:3100',
);
});
linuxTest('Linux 启动时按系统级端口段映射五个 dev 端口', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-dev-port-range-'));
try {
const { command, explicitOptions, options } = parseArgs([], {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
});
const runner = new DevRunner(
options,
{
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
},
explicitOptions,
);
await runner.prepareLinuxPortRange(command);
expect(runner.state.portRange.label).toBe('22000-22099');
expect(runner.options.webPort).toBe(22000);
expect(runner.options.apiPort).toBe(22001);
expect(runner.options.spacetimePort).toBe(22002);
expect(runner.options.adminWebPort).toBe(22003);
expect(runner.options.bgfilterWorkerPort).toBe(22004);
expect(runner.state.apiTarget).toBe('http://127.0.0.1:22001');
expect(runner.state.bgfilterWorkerTarget).toBe('http://127.0.0.1:22004');
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:22002');
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
linuxTest(
'Linux 配套后端不会漂移到父启动器预留的 AGC Vite 端口',
async () => {
const tempDir = mkdtempSync(
join(tmpdir(), 'genarrative-dev-port-range-'),
);
const baseEnv = {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
GENARRATIVE_AGC_VITE_PORT: '22002',
};
try {
const { command, explicitOptions, options } = parseArgs(
['backend'],
baseEnv,
);
const runner = new DevRunner(options, baseEnv, explicitOptions);
await runner.prepareLinuxPortRange(command);
await runner.resolvePorts(command);
expect(runner.options.spacetimePort).not.toBe(22002);
expect(runner.options.apiPort).not.toBe(22002);
expect(runner.options.bgfilterWorkerPort).not.toBe(22002);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
},
);
linuxTest(
'Linux 桌面壳显式指定 web-port 时不被系统级端口段改写',
async () => {
const tempDir = mkdtempSync(
join(tmpdir(), 'genarrative-dev-port-range-'),
);
try {
const { command, explicitOptions, options } = parseArgs(
['web', '--web-port', '3000', '--strict-web-port'],
{
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
},
);
const runner = new DevRunner(
options,
{
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
},
explicitOptions,
);
await runner.prepareLinuxPortRange(command);
expect(runner.state.portRange.label).toBe('22000-22099');
expect(runner.options.webPort).toBe(3000);
expect(runner.options.apiPort).toBe(22001);
expect(runner.options.spacetimePort).toBe(22002);
expect(runner.options.adminWebPort).toBe(22003);
expect(runner.options.bgfilterWorkerPort).toBe(22004);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
},
);
test('Windows 仍沿用原有端口解析,不启用 Linux 端口段登记', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(
process,
'platform',
);
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32',
});
try {
const { command, explicitOptions, options } = parseArgs([], {
USER: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
});
const runner = new DevRunner(
options,
{
USER: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
},
explicitOptions,
);
await runner.prepareLinuxPortRange(command);
expect(runner.state.portRange).toBeNull();
expect(runner.options.webPort).toBe(3000);
expect(runner.options.apiPort).toBe(8082);
expect(runner.options.spacetimePort).toBe(3101);
expect(runner.options.adminWebPort).toBe(3102);
expect(runner.options.bgfilterWorkerPort).toBe(8083);
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
}
});
test('本地 SpacetimeDB 信任与当前 CLI 一致且由本机签发的 token', () => {
const localToken = fakeJwt('localhost');
expect(
shouldTrustExistingSpacetimeToken(localToken, 'http://127.0.0.1:3101', {
env: {},
resolveCliToken: () => localToken,
}),
).toBe(true);
});
});
describe('dev scheduler api-server env', () => {
test('dev 脚本默认打开密码入口自动注册', () => {
const { options } = parseArgs(['api-server', '--api-port', '9091'], {});
const env = buildApiServerProcessEnv({
baseEnv: {},
options,
state: { spacetimeServer: 'http://127.0.0.1:3199' },
});
expect(env.GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED).toBe(
'true',
);
expect(env.GENARRATIVE_PROCESS_ROLE).toBe('all');
expect(env.GENARRATIVE_API_PORT).toBe('9091');
expect(env.GENARRATIVE_SPACETIME_SERVER_URL).toBe('http://127.0.0.1:3199');
});
test('dev 脚本保留显式 api-server 进程角色', () => {
const { options } = parseArgs(['api-server'], {});
const env = buildApiServerProcessEnv({
baseEnv: { GENARRATIVE_PROCESS_ROLE: 'api' },
options,
state: { spacetimeServer: 'http://127.0.0.1:3199' },
});
expect(env.GENARRATIVE_PROCESS_ROLE).toBe('api');
});
test('父 API 与独立 BgFilter worker 共享实际 URL 和内部 token', () => {
const { options } = parseArgs([], {});
options.bgfilterWorkerPort = 18083;
const state = {
spacetimeServer: 'http://127.0.0.1:3199',
bgfilterWorkerTarget: 'http://127.0.0.1:18083',
};
const internalToken = 'local-bgfilter-token';
const apiEnv = buildApiServerProcessEnv({
baseEnv: {},
options,
state,
bgfilterInternalToken: internalToken,
processRole: 'all',
});
const workerEnv = buildBgfilterWorkerProcessEnv({
baseEnv: {},
options,
state,
bgfilterInternalToken: internalToken,
});
expect(apiEnv.GENARRATIVE_PROCESS_ROLE).toBe('all');
expect(workerEnv.GENARRATIVE_PROCESS_ROLE).toBe('bgfilter-worker');
expect(apiEnv.GENARRATIVE_BGFILTER_WORKER_BASE_URL).toBe(
state.bgfilterWorkerTarget,
);
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_BASE_URL).toBe(
state.bgfilterWorkerTarget,
);
expect(apiEnv.GENARRATIVE_BGFILTER_INTERNAL_TOKEN).toBe(internalToken);
expect(workerEnv.GENARRATIVE_BGFILTER_INTERNAL_TOKEN).toBe(internalToken);
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_HOST).toBe('127.0.0.1');
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_PORT).toBe('18083');
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_CONCURRENCY).toBe('16');
expect(workerEnv.GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS).toBe(
'5000',
);
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS).toBe('2048');
});
test('Windows 本地 dev 自动注入已安装的 FFmpeg 路径', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-ffmpeg-'));
try {
const binDir = join(tempDir, 'Genarrative', 'ffmpeg', 'bin');
const ffmpegPath = join(binDir, 'ffmpeg.exe');
const ffprobePath = join(binDir, 'ffprobe.exe');
mkdirSync(binDir, { recursive: true });
writeFileSync(ffmpegPath, '', 'utf8');
writeFileSync(ffprobePath, '', 'utf8');
const { options } = parseArgs(['api-server'], {});
const env = buildApiServerProcessEnv({
baseEnv: { LOCALAPPDATA: tempDir, Path: 'C:\\Windows\\System32' },
options,
state: { spacetimeServer: 'http://127.0.0.1:3199' },
platform: 'win32',
});
expect(env.CHARACTER_ANIMATION_FFMPEG_PATH).toBe(ffmpegPath);
expect(env.CHARACTER_ANIMATION_FFPROBE_PATH).toBe(ffprobePath);
expect(env.GENARRATIVE_CHARACTER_ANIMATION_FFMPEG_PATH).toBe(ffmpegPath);
expect(env.Path?.split(';')[0]).toBe(binDir);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test('Windows 本地 dev 保留显式 FFmpeg 配置', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-ffmpeg-'));
try {
const binDir = join(tempDir, 'Genarrative', 'ffmpeg', 'bin');
mkdirSync(binDir, { recursive: true });
writeFileSync(join(binDir, 'ffmpeg.exe'), '', 'utf8');
writeFileSync(join(binDir, 'ffprobe.exe'), '', 'utf8');
const { options } = parseArgs(['api-server'], {});
const env = buildApiServerProcessEnv({
baseEnv: {
LOCALAPPDATA: tempDir,
CHARACTER_ANIMATION_FFMPEG_PATH: 'D:\\tools\\ffmpeg.exe',
CHARACTER_ANIMATION_FFPROBE_PATH: 'D:\\tools\\ffprobe.exe',
},
options,
state: { spacetimeServer: 'http://127.0.0.1:3199' },
platform: 'win32',
});
expect(env.CHARACTER_ANIMATION_FFMPEG_PATH).toBe('D:\\tools\\ffmpeg.exe');
expect(env.CHARACTER_ANIMATION_FFPROBE_PATH).toBe(
'D:\\tools\\ffprobe.exe',
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe('dev scheduler Rust service orchestration', () => {
test('Rust 双进程重启时先全部停止,再先 ready BgFilter、后 ready API', async () => {
const { explicitOptions, options } = parseArgs([], {});
const runner = new DevRunner(options, {}, explicitOptions);
const events: string[] = [];
runner.command = 'all';
runner.windowsApiServerCleanupCompleted = true;
runner.services = new Map([
[
'api-server',
{
stop: async () => events.push('stop-api'),
start: async () => events.push('start-api'),
},
],
[
'bgfilter-worker',
{
stop: async () => events.push('stop-bgfilter'),
start: async () => events.push('start-bgfilter'),
},
],
]);
vi.spyOn(runner, 'waitForBgfilterWorker').mockImplementation(async () => {
events.push('ready-bgfilter');
});
vi.spyOn(runner, 'waitForApiServer').mockImplementation(async () => {
events.push('ready-api');
});
await runner.restartRustServicePair();
expect(events).toEqual([
'stop-api',
'stop-bgfilter',
'start-bgfilter',
'ready-bgfilter',
'start-api',
'ready-api',
]);
});
test('dev:api-server 安全自动带起同 runner 的 BgFilter worker', async () => {
const { explicitOptions, options } = parseArgs(['api-server'], {});
const runner = new DevRunner(options, {}, explicitOptions);
const startPair = vi
.spyOn(runner, 'startRustServicePair')
.mockResolvedValue(undefined);
const startWatchers = vi
.spyOn(runner, 'startWatchers')
.mockImplementation(() => {});
await runner.startCommand('api-server');
expect(startPair).toHaveBeenCalledOnce();
expect(startWatchers).toHaveBeenCalledWith([
'api-server',
'bgfilter-worker',
]);
});
test('backend 模式复用 Rust 双进程并纳入三服务 watcher', async () => {
const { explicitOptions, options } = parseArgs(['backend'], {});
const runner = new DevRunner(options, {}, explicitOptions);
const startSpacetime = vi
.spyOn(runner, 'startSpacetimeForFullStack')
.mockResolvedValue(undefined);
const startPair = vi
.spyOn(runner, 'startRustServicePair')
.mockResolvedValue(undefined);
const startWatchers = vi
.spyOn(runner, 'startWatchers')
.mockImplementation(() => {});
await runner.startCommand('backend');
expect(startSpacetime).toHaveBeenCalledOnce();
expect(startPair).toHaveBeenCalledOnce();
expect(startWatchers).toHaveBeenCalledWith([
'spacetime',
'api-server',
'bgfilter-worker',
]);
});
test('完整栈只为两个 Rust 角色创建一套组合 watcher', () => {
const { explicitOptions, options } = parseArgs(['--watch'], {});
const runner = new DevRunner(options, {}, explicitOptions);
runner.command = 'all';
runner.registerServices();
try {
runner.startWatchers(['api-server', 'bgfilter-worker']);
expect(runner.watchers).toHaveLength(1);
} finally {
for (const watcher of runner.watchers) {
watcher.close();
}
runner.watchers = [];
}
});
test('BgFilter worker 在 readiness 前退出时立即失败', async () => {
const { explicitOptions, options } = parseArgs([], {});
const runner = new DevRunner(options, {}, explicitOptions);
runner.services = new Map([
['bgfilter-worker', { runtime: { status: 'failed' } }],
]);
globalThis.fetch = vi.fn(async () => ({
status: 503,
})) as unknown as typeof fetch;
await expect(runner.waitForBgfilterWorker()).rejects.toThrow(
'bgfilter-worker 在 readiness 前退出',
);
});
});
describe('dev scheduler local worker cleanup', () => {
const expected = {
expectedDatabase: 'xushi-p4wfr',
expectedExePath: '/repo/Genarrative/server-rs/target/debug/api-server',
expectedRepoRoot: '/repo/Genarrative',
expectedSpacetimeServer: 'http://127.0.0.1:10002',
};
test('解析 /proc environ 的 NUL 分隔格式', () => {
expect(parseProcessEnvBlock('A=1\0B=two=parts\0\0')).toEqual({
A: '1',
B: 'two=parts',
});
});
test('识别同仓库同库的旧 external-generation-worker', () => {
expect(
isStaleExternalGenerationWorkerProcess({
...expected,
cwd: '/repo/Genarrative',
env: {
GENARRATIVE_PROCESS_ROLE: 'external-generation-worker',
GENARRATIVE_SPACETIME_DATABASE: 'xushi-p4wfr',
GENARRATIVE_SPACETIME_SERVER_URL: 'http://127.0.0.1:10002',
},
exe: '/repo/Genarrative/server-rs/target/debug/api-server (deleted)',
pid: 12345,
}),
).toBe(true);
});
test('不同数据库或仓库的 worker 不会被当成本地旧进程', () => {
expect(
isStaleExternalGenerationWorkerProcess({
...expected,
cwd: '/repo/Genarrative',
env: {
GENARRATIVE_PROCESS_ROLE: 'external-generation-worker',
GENARRATIVE_SPACETIME_DATABASE: 'other-db',
GENARRATIVE_SPACETIME_SERVER_URL: 'http://127.0.0.1:10002',
},
exe: '/repo/Genarrative/server-rs/target/debug/api-server',
pid: 12345,
}),
).toBe(false);
expect(
isStaleExternalGenerationWorkerProcess({
...expected,
cwd: '/repo/Other',
env: {
GENARRATIVE_PROCESS_ROLE: 'external-generation-worker',
GENARRATIVE_SPACETIME_DATABASE: 'xushi-p4wfr',
GENARRATIVE_SPACETIME_SERVER_URL: 'http://127.0.0.1:10002',
},
exe: '/repo/Other/server-rs/target/debug/api-server',
pid: 12345,
}),
).toBe(false);
});
});
describe('dev scheduler Rust build env', () => {
test('Windows 下未显式配置 wrapper 时默认关闭 sccache', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(
process,
'platform',
);
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32',
});
try {
const env = buildLocalRustProcessEnv(
{},
{ log: false, sccacheAvailable: true },
);
expect(env.RUSTC_WRAPPER).toBe('');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('');
} finally {
if (originalPlatform)
Object.defineProperty(process, 'platform', originalPlatform);
}
});
test('POSIX 显式配置 sccache 时日志说明实际绕过', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(
process,
'platform',
);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
try {
const env = buildLocalRustProcessEnv(
{ RUSTC_WRAPPER: 'sccache', CARGO_BUILD_RUSTC_WRAPPER: 'sccache' },
{ log: true },
);
expect(env.RUSTC_WRAPPER).toBe('/usr/bin/env');
expect(warn).toHaveBeenCalledWith(
'[dev:rust] POSIX 本地 dev 构建绕过 sccache,使用直接 rustc。',
);
} finally {
warn.mockRestore();
if (originalPlatform)
Object.defineProperty(process, 'platform', originalPlatform);
}
});
test('local dev Rust env enables available sccache wrapper', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(
process,
'platform',
);
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32',
});
try {
const env = buildLocalRustProcessEnv(
{
RUSTC_WRAPPER: 'sccache',
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
},
{ log: false, sccacheAvailable: true },
);
expect(env.RUSTC_WRAPPER).toBe('sccache');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER);
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
}
});
test('Rust wrapper 配置为同一个自定义 wrapper 时保持不变', () => {
const env = buildLocalRustProcessEnv(
{
RUSTC_WRAPPER: 'custom-wrapper',
CARGO_BUILD_RUSTC_WRAPPER: 'custom-wrapper',
},
{ log: false },
);
expect(env.RUSTC_WRAPPER).toBe('custom-wrapper');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper');
});
test('Windows 下未安装 sccache 时回退到空 wrapper', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(
process,
'platform',
);
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32',
});
try {
const env = buildLocalRustProcessEnv(
{
RUSTC_WRAPPER: 'sccache',
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
},
{ log: false, sccacheAvailable: false },
);
expect(env.RUSTC_WRAPPER).toBe('');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('');
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
}
});
test('Rust wrapper 配置冲突时回退到空 wrapper', () => {
const env = buildLocalRustProcessEnv(
{ RUSTC_WRAPPER: 'sccache', CARGO_BUILD_RUSTC_WRAPPER: 'custom-wrapper' },
{ log: false },
);
expect(env.RUSTC_WRAPPER).toBe('');
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('');
});
});
describe('dev scheduler stack state file', () => {
test('状态文件路径固定在根目录 .app/dev-stack.json', () => {
expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe(
join('C:\\repo\\Genarrative', '.app/dev-stack.json'),
);
});
test('状态快照记录服务 pid、端口、URL 和当前命令', () => {
const updatedAt = '2026-05-29T00:00:00.000Z';
const runner = {
instanceId: 'test-instance',
command: 'web',
options: {
apiHost: '127.0.0.1',
apiPort: 8090,
bgfilterWorkerHost: '127.0.0.1',
bgfilterWorkerPort: 8091,
webHost: '0.0.0.0',
webPort: 3010,
adminWebHost: '127.0.0.1',
adminWebPort: 3110,
spacetimeHost: '127.0.0.1',
spacetimePort: 3120,
spacetimeDataDir: 'server-rs/.spacetimedb/local/data',
database: 'genarrative-test',
watch: false,
},
state: {
apiTarget: 'http://127.0.0.1:8090',
bgfilterWorkerTarget: 'http://127.0.0.1:8091',
adminWebTargetHost: '127.0.0.1',
spacetimeServer: 'http://127.0.0.1:3120',
},
services: new Map([
[
'web',
{
child: { pid: 4321 },
runtime: {
status: 'running',
pid: 4321,
host: '0.0.0.0',
port: 3010,
url: 'http://127.0.0.1:3010',
command: 'node scripts/vite-cli.mjs --port=3010',
startedAt: updatedAt,
updatedAt,
exitCode: null,
signal: null,
},
},
],
]),
};
const snapshot = buildDevStackSnapshot(runner, updatedAt);
expect(snapshot.schemaVersion).toBe(2);
expect(snapshot.instanceId).toBe('test-instance');
expect(snapshot.command).toBe('web');
expect(snapshot.database).toBe('genarrative-test');
expect(snapshot.spacetimeDataDir).toBe(
resolve('server-rs/.spacetimedb/local/data'),
);
expect(snapshot.services.web).toMatchObject({
repoRoot: resolve('.'),
instanceId: 'test-instance',
dataDir: null,
status: 'running',
pid: 4321,
host: '0.0.0.0',
port: 3010,
url: 'http://127.0.0.1:3010',
command: 'node scripts/vite-cli.mjs --port=3010',
});
expect(snapshot.services['api-server']).toMatchObject({
status: 'idle',
pid: null,
port: 8090,
url: 'http://127.0.0.1:8090',
});
expect(snapshot.services['bgfilter-worker']).toMatchObject({
status: 'idle',
pid: null,
port: 8091,
url: 'http://127.0.0.1:8091',
});
});
});
describe('dev scheduler spacetime reuse guard', () => {
test('记录 URL 可 ping 但没有 spacetime.pid 时不复用宿主', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));
try {
writeFileSync(
join(tempDir, 'dev-spacetime-url'),
'http://127.0.0.1:3199\n',
'utf8',
);
globalThis.fetch = vi.fn(async () => ({
status: 200,
})) as unknown as typeof fetch;
const { command, explicitOptions, options } = parseArgs(
['--spacetime-data-dir', tempDir],
{},
);
const runner = new DevRunner(options, {}, explicitOptions);
await runner.tryReuseExistingSpacetime(command);
expect(runner.state.spacetimeReused).toBeUndefined();
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:3101');
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test('记录 URL 可 ping 且 spacetime.pid 存活时复用宿主', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));
try {
writeFileSync(
join(tempDir, 'dev-spacetime-url'),
'http://127.0.0.1:3199\n',
'utf8',
);
writeFileSync(join(tempDir, 'spacetime.pid'), `${process.pid}\n`, 'utf8');
writeFileSync(
join(tempDir, 'dev-spacetime-tool-version'),
`${workspaceSpacetimeVersionForTest()}\n8e410d2842147bd8e5a32a9589cc00c19f7478e2\n`,
'utf8',
);
globalThis.fetch = vi.fn(async () => ({
status: 200,
})) as unknown as typeof fetch;
const { command, explicitOptions, options } = parseArgs(
['--spacetime-data-dir', tempDir],
{},
);
const runner = new DevRunner(options, {}, explicitOptions);
await runner.tryReuseExistingSpacetime(command);
expect(runner.state.spacetimeReused).toBe(true);
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:3199');
expect(runner.options.spacetimePort).toBe(3199);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test('没有 URL 记录但 spacetime.pid 存活时复用默认宿主', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));
try {
writeFileSync(join(tempDir, 'spacetime.pid'), `${process.pid}\n`, 'utf8');
writeFileSync(
join(tempDir, 'dev-spacetime-tool-version'),
`${workspaceSpacetimeVersionForTest()}\n8e410d2842147bd8e5a32a9589cc00c19f7478e2\n`,
'utf8',
);
globalThis.fetch = vi.fn(async () => ({
status: 200,
})) as unknown as typeof fetch;
const { command, explicitOptions, options } = parseArgs(
['--spacetime-data-dir', tempDir, '--spacetime-port', '3198'],
{},
);
const runner = new DevRunner(options, {}, explicitOptions);
await runner.tryReuseExistingSpacetime(command);
expect(runner.state.spacetimeReused).toBe(true);
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:3198');
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:3198/v1/ping',
expect.any(Object),
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test('spacetime.pid 存活但候选地址不可访问时不继续启动第二个宿主', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));
try {
writeFileSync(join(tempDir, 'spacetime.pid'), `${process.pid}\n`, 'utf8');
globalThis.fetch = vi.fn(async () => ({
status: 503,
})) as unknown as typeof fetch;
const { command, explicitOptions, options } = parseArgs(
['--spacetime-data-dir', tempDir, '--spacetime-port', '3198'],
{},
);
const runner = new DevRunner(options, {}, explicitOptions);
await expect(runner.tryReuseExistingSpacetime(command)).rejects.toThrow(
'检测到 spacetime.pid',
);
expect(runner.state.spacetimeReused).toBeUndefined();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe('dev scheduler interactive input', () => {
test('前端 dev server 不继承 stdin,避免吞掉 rs 重启命令', () => {
const options = createDevServerSpawnOptions({
cwd: repoRootForTest(),
env: { A: 'B' },
});
expect(options.stdio).toEqual(['ignore', 'pipe', 'pipe']);
expect(options.env).toEqual({ A: 'B' });
});
});
function repoRootForTest() {
return process.cwd();
}
describe('dev scheduler watch routing', () => {
test('watch 模式不重启 web/admin-web,交给 Vite 自身 watch', () => {
const configs = createWatchConfigs();
expect(configs.web).toEqual([]);
expect(configs['admin-web']).toEqual([]);
});
test('watch 过滤依赖缓存和构建产物,避免自触发循环', () => {
const config = {
path: join(process.cwd(), 'apps/admin-web'),
filter: () => true,
};
expect(
shouldAcceptWatchEvent(
config,
join(process.cwd(), 'apps/admin-web/src/App.tsx'),
),
).toBe(true);
expect(
shouldAcceptWatchEvent(
config,
join(
process.cwd(),
'apps/admin-web/node_modules/.vite/deps/_metadata.json',
),
),
).toBe(false);
expect(
shouldAcceptWatchEvent(
config,
join(process.cwd(), 'apps/admin-web/dist/assets/app.js'),
),
).toBe(false);
});
});
describe('dev scheduler spacetime refresh', () => {
test('解析 Cargo 精确版本要求时用于 CLI 校验的版本号不带等号', () => {
expect(normalizeCargoVersionRequirement('=2.6.0')).toBe('2.6.0');
expect(normalizeCargoVersionRequirement('2.6.0')).toBe('2.6.0');
});
test('解析 spacetime --version 输出里的 tool version', () => {
const output = `
A new version of SpacetimeDB is available: v2.6.1 (current: v2.5.0)
Commit: 8e410d2842147bd8e5a32a9589cc00c19f7478e2
spacetimedb tool version 2.8.3; spacetimedb-lib version 2.8.3;
`;
expect(parseSpacetimeToolVersion(output)).toBe('2.8.3');
expect(parseSpacetimeToolCommit(output)).toBe(
'8e410d2842147bd8e5a32a9589cc00c19f7478e2',
);
});
test('同为 2.8.3 时拒绝 commit 不匹配并接受锁定 commit', () => {
expect(() =>
assertSpacetimeToolVersionMatchesWorkspace({
toolVersion: '2.8.3',
toolCommit: '0000000000000000000000000000000000000000',
workspaceVersion: '2.8.3',
}),
).toThrow('构建 commit');
expect(() =>
assertSpacetimeToolVersionMatchesWorkspace({
toolVersion: '2.8.3',
toolCommit: '8e410d2842147bd8e5a32a9589cc00c19f7478e2',
workspaceVersion: '2.8.3',
}),
).not.toThrow();
});
test('本机 spacetime 版本和 workspace 锁定版本不一致时直接报清楚', () => {
expect(() =>
assertSpacetimeToolVersionMatchesWorkspace({
toolVersion: '2.1.0',
workspaceVersion: '2.6.0',
}),
).toThrow('procedure 返回值 BSATN 反序列化失败');
});
test('复用本地 SpacetimeDB standalone 前校验启动时版本记录', () => {
const tempDir = mkdtempSync(
join(tmpdir(), 'genarrative-spacetime-version-'),
);
try {
writeFileSync(
join(tempDir, 'dev-spacetime-tool-version'),
'2.1.0\n',
'utf8',
);
expect(() =>
assertReusableSpacetimeProcessVersionMatchesWorkspace({
dataDir: tempDir,
serverUrl: 'http://127.0.0.1:3101',
}),
).toThrow('SpacetimeDB procedure 调用超时');
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test('复用本地 SpacetimeDB standalone 时拒绝同版本但 commit 不匹配的记录', () => {
const tempDir = mkdtempSync(
join(tmpdir(), 'genarrative-spacetime-commit-'),
);
try {
writeFileSync(
join(tempDir, 'dev-spacetime-tool-version'),
'2.8.3\n0000000000000000000000000000000000000000\n',
'utf8',
);
expect(() =>
assertReusableSpacetimeProcessVersionMatchesWorkspace({
dataDir: tempDir,
serverUrl: 'http://127.0.0.1:3101',
}),
).toThrow('构建 commit');
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test('本地发布 403 时识别为身份权限问题,避免误杀 standalone', () => {
const error = new Error(
'Pre-publish check failed with status 403 Forbidden: c200... is not authorized to perform action on database c200...: update database',
);
expect(isSpacetimePublishPermissionError(error)).toBe(true);
expect(
isSpacetimePublishPermissionError(
new Error('No database target matches'),
),
).toBe(false);
});
test('发布 spacetime-module 时使用隔离身份配置并忽略 spacetime.json', () => {
const args = buildSpacetimePublishArgs({
cliConfigPath: '/tmp/genarrative-cli.toml',
database: 'xushi-p4wfr',
preserveDatabase: false,
server: 'http://127.0.0.1:3101',
});
expect(args).toContain('--no-config');
expect(args).not.toContain('--anonymous');
expect(args).toEqual(
expect.arrayContaining([
'--config-path',
'/tmp/genarrative-cli.toml',
'publish',
'xushi-p4wfr',
'--server',
'http://127.0.0.1:3101',
'-c=on-conflict',
]),
);
});
test('远程 SpacetimeDB 发布继续使用默认登录身份', () => {
const args = buildSpacetimePublishArgs({
database: 'xushi-p4wfr',
preserveDatabase: true,
server: 'https://spacetime.example.com',
});
expect(args).not.toContain('--anonymous');
expect(args).not.toContain('--config-path');
});
test('手动刷新 spacetime 只重新发布模块,不重启 standalone 进程', async () => {
const { explicitOptions, options } = parseArgs([], {});
const runner = new DevRunner(options, {}, explicitOptions);
const restart = vi.fn();
runner.services.set('spacetime', { restart });
runner.waitForSpacetime = vi.fn(async () => {});
runner.publishSpacetimeModule = vi.fn(async () => {});
await runner.restartService('spacetime');
expect(restart).not.toHaveBeenCalled();
expect(runner.waitForSpacetime).toHaveBeenCalledTimes(1);
expect(runner.publishSpacetimeModule).toHaveBeenCalledTimes(1);
});
test('skip-publish 时 spacetime 刷新不会重启或发布', async () => {
const { explicitOptions, options } = parseArgs(['--skip-publish'], {});
const runner = new DevRunner(options, {}, explicitOptions);
const restart = vi.fn();
runner.services.set('spacetime', { restart });
runner.waitForSpacetime = vi.fn(async () => {});
runner.publishSpacetimeModule = vi.fn(async () => {});
await runner.restartService('spacetime');
expect(restart).not.toHaveBeenCalled();
expect(runner.waitForSpacetime).not.toHaveBeenCalled();
expect(runner.publishSpacetimeModule).not.toHaveBeenCalled();
});
test('本地 API identity 路径只绑定 data dir', () => {
const first = resolveLocalSpacetimeApiIdentityPath(
'/tmp/genarrative-data-a',
);
const sameDataDir = resolveLocalSpacetimeApiIdentityPath(
'/tmp/genarrative-data-a',
);
const otherDataDir = resolveLocalSpacetimeApiIdentityPath(
'/tmp/genarrative-data-b',
);
expect(sameDataDir).toBe(first);
expect(otherDataDir).not.toBe(first);
expect(first).toContain(join('genarrative-data-a', 'dev-api-identities'));
});
test('启动 api-server 前创建并跨 dev 进程复用本地 API identity', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
try {
const { explicitOptions, options } = parseArgs(
['--spacetime-data-dir', tempDir],
{},
);
const firstRunner = new DevRunner(options, {}, explicitOptions);
firstRunner.state.spacetimeServer = 'http://127.0.0.1:3101';
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
globalThis.fetch = vi.fn(async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
identity: 'c200localidentity',
token: 'local-api-token',
}),
})) as unknown as typeof fetch;
await firstRunner.ensureApiServerSpacetimeToken();
const identityPath = resolveLocalSpacetimeApiIdentityPath(tempDir);
expect(firstRunner.spacetimeApiToken).toBe('local-api-token');
expect(firstRunner.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBeUndefined();
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:3101/v1/identity',
expect.objectContaining({ method: 'POST' }),
);
expect(JSON.parse(readFileSync(identityPath, 'utf8'))).toMatchObject({
schemaVersion: 2,
scope: 'local-data-dir',
identity: 'c200localidentity',
token: 'local-api-token',
});
if (process.platform !== 'win32') {
expect(statSync(identityPath).mode & 0o777).toBe(0o600);
chmodSync(identityPath, 0o644);
}
const secondRunner = new DevRunner(options, {}, explicitOptions);
secondRunner.state.spacetimeServer = 'http://127.0.0.1:3199';
globalThis.fetch = vi.fn();
await secondRunner.ensureApiServerSpacetimeToken();
expect(secondRunner.spacetimeApiToken).toBe('local-api-token');
expect(secondRunner.state.spacetimeIdentity).toBe('c200localidentity');
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(logSpy.mock.calls.flat().join('\n')).not.toContain(
'local-api-token',
);
if (process.platform !== 'win32') {
expect(statSync(identityPath).mode & 0o777).toBe(0o600);
}
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test('旧端口作用域 API identity 会迁移为 data dir 作用域', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
try {
const legacyServer = 'http://127.0.0.1:3101';
const legacyKey = createHash('sha256').update(legacyServer).digest('hex');
const legacyPath = join(
tempDir,
'dev-api-identities',
`${legacyKey}.json`,
);
mkdirSync(dirname(legacyPath), { recursive: true });
writeFileSync(
legacyPath,
`${JSON.stringify({
schemaVersion: 1,
server: legacyServer,
identity: 'legacy-owner-identity',
token: 'legacy-owner-token',
})}\n`,
{ mode: 0o600 },
);
const { explicitOptions, options } = parseArgs(
['--spacetime-data-dir', tempDir],
{},
);
const runner = new DevRunner(options, {}, explicitOptions);
runner.state.spacetimeServer = 'http://127.0.0.1:3199';
globalThis.fetch = vi.fn();
await runner.ensureApiServerSpacetimeToken();
expect(runner.spacetimeApiToken).toBe('legacy-owner-token');
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(
JSON.parse(
readFileSync(resolveLocalSpacetimeApiIdentityPath(tempDir), 'utf8'),
),
).toMatchObject({
schemaVersion: 2,
scope: 'local-data-dir',
identity: 'legacy-owner-identity',
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test('同一 data dir 存在多个旧 identity 时失败关闭', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
try {
for (const [port, identity] of [
[3101, 'legacy-owner-a'],
[3199, 'legacy-owner-b'],
] as const) {
const server = `http://127.0.0.1:${port}`;
const legacyPath = join(
tempDir,
'dev-api-identities',
`${createHash('sha256').update(server).digest('hex')}.json`,
);
mkdirSync(dirname(legacyPath), { recursive: true });
writeFileSync(
legacyPath,
`${JSON.stringify({
schemaVersion: 1,
server,
identity,
token: `${identity}-token`,
})}\n`,
{ mode: 0o600 },
);
}
const { explicitOptions, options } = parseArgs(
['--spacetime-data-dir', tempDir],
{},
);
const runner = new DevRunner(options, {}, explicitOptions);
globalThis.fetch = vi.fn();
await expect(runner.ensureApiServerSpacetimeToken()).rejects.toThrow(
'无法安全判断数据库 owner',
);
expect(globalThis.fetch).not.toHaveBeenCalled();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test('外部显式 token 优先于已持久化的本地 API identity', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
const originalToken = process.env.GENARRATIVE_SPACETIME_TOKEN;
try {
const { explicitOptions, options } = parseArgs(
['--spacetime-data-dir', tempDir],
{},
);
const initialRunner = new DevRunner(options, {}, explicitOptions);
globalThis.fetch = vi.fn(async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
identity: 'c200persistedidentity',
token: 'persisted-api-token',
}),
})) as unknown as typeof fetch;
await initialRunner.ensureApiServerSpacetimeToken();
process.env.GENARRATIVE_SPACETIME_TOKEN = 'external-api-token';
const explicitRunner = new DevRunner(
options,
{ GENARRATIVE_SPACETIME_TOKEN: 'external-api-token' },
explicitOptions,
);
globalThis.fetch = vi.fn();
await explicitRunner.ensureApiServerSpacetimeToken();
expect(explicitRunner.spacetimeApiToken).toBe('external-api-token');
expect(
explicitRunner.baseEnv.GENARRATIVE_SPACETIME_TOKEN,
).toBeUndefined();
expect(globalThis.fetch).not.toHaveBeenCalled();
} finally {
if (originalToken === undefined) {
delete process.env.GENARRATIVE_SPACETIME_TOKEN;
} else {
process.env.GENARRATIVE_SPACETIME_TOKEN = originalToken;
}
rmSync(tempDir, { recursive: true, force: true });
}
});
test.skipIf(process.platform === 'win32')(
'本地 API identity 不跟随符号链接记录',
async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
try {
const { explicitOptions, options } = parseArgs(
['--spacetime-data-dir', tempDir],
{},
);
const runner = new DevRunner(options, {}, explicitOptions);
runner.state.spacetimeServer = 'http://127.0.0.1:3101';
const identityPath = resolveLocalSpacetimeApiIdentityPath(tempDir);
mkdirSync(dirname(identityPath), { recursive: true });
const linkedRecordPath = join(tempDir, 'linked-api-identity.json');
writeFileSync(
linkedRecordPath,
JSON.stringify({
schemaVersion: 1,
server: runner.state.spacetimeServer,
identity: 'linked-identity',
token: 'linked-token',
}),
{ mode: 0o600 },
);
symlinkSync(linkedRecordPath, identityPath);
globalThis.fetch = vi.fn(async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
identity: 'fresh-identity',
token: 'fresh-token',
}),
})) as unknown as typeof fetch;
await runner.ensureApiServerSpacetimeToken();
expect(runner.spacetimeApiToken).toBe('fresh-token');
expect(lstatSync(identityPath).isSymbolicLink()).toBe(false);
expect(
JSON.parse(readFileSync(linkedRecordPath, 'utf8')),
).toMatchObject({
identity: 'linked-identity',
token: 'linked-token',
});
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('本地 API identity 记录不可用,将重新创建'),
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
},
);
test('dev:spacetime 将运行服务 bootstrap secret 以 0600 持久化,独立 api-server 可复用', () => {
const tempDir = mkdtempSync(
join(tmpdir(), 'genarrative-runtime-bootstrap-secret-'),
);
try {
const { explicitOptions, options } = parseArgs(
['--spacetime-data-dir', tempDir],
{},
);
const firstRunner = new DevRunner(options, {}, explicitOptions);
firstRunner.state.spacetimeServer = 'http://127.0.0.1:3101';
const publishEnv: Record<string, string> = {};
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
firstRunner.prepareMigrationBootstrapSecret(publishEnv);
const secretPath = resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath(
tempDir,
firstRunner.state.spacetimeServer,
options.database,
);
const persisted = JSON.parse(readFileSync(secretPath, 'utf8'));
const secondRunner = new DevRunner(options, {}, explicitOptions);
secondRunner.state.spacetimeServer = 'http://127.0.0.1:3101';
expect(publishEnv).not.toHaveProperty(
'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET',
);
expect(
publishEnv.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256,
).toBe(
createHash('sha256')
.update(firstRunner.runtimeServiceBootstrapSecret)
.digest('hex'),
);
expect(persisted).toMatchObject({
schemaVersion: 1,
server: 'http://127.0.0.1:3101',
database: options.database,
secret: firstRunner.runtimeServiceBootstrapSecret,
});
expect(secondRunner.resolveRuntimeServiceBootstrapSecret()).toBe(
firstRunner.runtimeServiceBootstrapSecret,
);
expect(logSpy.mock.calls.flat().join('\n')).not.toContain(
firstRunner.runtimeServiceBootstrapSecret,
);
if (process.platform !== 'win32') {
expect(statSync(secretPath).mode & 0o777).toBe(0o600);
}
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test('显式运行服务 bootstrap secret 优先于本地记录并同步给 spacetime publish', () => {
const tempDir = mkdtempSync(
join(tmpdir(), 'genarrative-runtime-bootstrap-secret-'),
);
try {
const { explicitOptions, options } = parseArgs(
['--spacetime-data-dir', tempDir],
{},
);
const persistedRunner = new DevRunner(options, {}, explicitOptions);
persistedRunner.state.spacetimeServer = 'http://127.0.0.1:3101';
persistedRunner.prepareMigrationBootstrapSecret({});
const explicitSecret = 'Aa01Bb23Cc45Dd67'.repeat(4);
const explicitRunner = new DevRunner(
options,
{
GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET:
explicitSecret,
},
explicitOptions,
);
explicitRunner.state.spacetimeServer = 'http://127.0.0.1:3101';
const publishEnv: Record<string, string> = {};
explicitRunner.prepareMigrationBootstrapSecret(publishEnv);
const secretPath = resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath(
tempDir,
explicitRunner.state.spacetimeServer,
options.database,
);
expect(explicitRunner.runtimeServiceBootstrapSecret).toBe(explicitSecret);
expect(publishEnv).not.toHaveProperty(
'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET',
);
expect(
publishEnv.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256,
).toBe(createHash('sha256').update(explicitSecret).digest('hex'));
expect(JSON.parse(readFileSync(secretPath, 'utf8')).secret).toBe(
explicitSecret,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test('手动 migration bootstrap secret 接受大小写十六进制并按原值计算摘要', () => {
const tempDir = mkdtempSync(
join(tmpdir(), 'genarrative-migration-bootstrap-secret-'),
);
try {
const secret = 'FfEedDccBbaa0099'.repeat(4);
const { explicitOptions, options } = parseArgs(
[
'--spacetime-data-dir',
tempDir,
'--migration-bootstrap-secret',
secret,
],
{},
);
const runner = new DevRunner(options, {}, explicitOptions);
runner.state.spacetimeServer = 'http://127.0.0.1:3101';
const publishEnv: Record<string, string> = {};
runner.prepareMigrationBootstrapSecret(publishEnv);
const secretPath = resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath(
tempDir,
runner.state.spacetimeServer,
options.database,
);
expect(runner.runtimeServiceBootstrapSecret).toBe(secret);
expect(JSON.parse(readFileSync(secretPath, 'utf8')).secret).toBe(secret);
expect(
publishEnv.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256,
).toBe(createHash('sha256').update(secret).digest('hex'));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test.each([
['63 个字符', 'a'.repeat(63)],
['65 个字符', 'a'.repeat(65)],
['包含非十六进制字符', `${'a'.repeat(63)}g`],
])('手动 migration bootstrap secret 拒绝%s', (_label, secret) => {
const { explicitOptions, options } = parseArgs(
['--migration-bootstrap-secret', secret],
{},
);
const runner = new DevRunner(options, {}, explicitOptions);
expect(() => runner.prepareMigrationBootstrapSecret({})).toThrow(
'迁移引导密钥必须是 64 个十六进制字符',
);
});
test.each([
['63 个字符', 'a'.repeat(63)],
['65 个字符', 'a'.repeat(65)],
['包含非十六进制字符', `${'a'.repeat(63)}g`],
])('显式 runtime bootstrap secret 拒绝%s', (_label, secret) => {
const { explicitOptions, options } = parseArgs([], {});
const runner = new DevRunner(
options,
{ GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET: secret },
explicitOptions,
);
expect(() => runner.prepareMigrationBootstrapSecret({})).toThrow(
'运行服务 bootstrap secret 必须是 64 个十六进制字符',
);
});
test.skipIf(process.platform === 'win32')(
'不复用权限宽松、格式非法或空的本地运行服务 bootstrap secret 记录',
() => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const tempDir = mkdtempSync(
join(tmpdir(), 'genarrative-runtime-bootstrap-secret-'),
);
try {
const { explicitOptions, options } = parseArgs(
['--spacetime-data-dir', tempDir],
{},
);
const runner = new DevRunner(options, {}, explicitOptions);
runner.state.spacetimeServer = 'http://127.0.0.1:3101';
const secretPath =
resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath(
tempDir,
runner.state.spacetimeServer,
options.database,
);
mkdirSync(join(tempDir, 'dev-runtime-service-bootstrap-secrets'), {
recursive: true,
});
const untrustedSecret = 'Ab'.repeat(32);
writeFileSync(
secretPath,
`${JSON.stringify({
schemaVersion: 1,
server: runner.state.spacetimeServer,
database: options.database,
secret: untrustedSecret,
})}\n`,
{ mode: 0o644 },
);
chmodSync(secretPath, 0o644);
runner.prepareMigrationBootstrapSecret({});
expect(runner.runtimeServiceBootstrapSecret).not.toBe(untrustedSecret);
const invalidSecret = `${'a'.repeat(63)}g`;
writeFileSync(
secretPath,
`${JSON.stringify({
schemaVersion: 1,
server: runner.state.spacetimeServer,
database: options.database,
secret: invalidSecret,
})}\n`,
{ mode: 0o600 },
);
chmodSync(secretPath, 0o600);
const invalidRunner = new DevRunner(options, {}, explicitOptions);
invalidRunner.state.spacetimeServer = 'http://127.0.0.1:3101';
invalidRunner.prepareMigrationBootstrapSecret({});
expect(invalidRunner.runtimeServiceBootstrapSecret).not.toBe(
invalidSecret,
);
expect(invalidRunner.runtimeServiceBootstrapSecret).toMatch(
/^[0-9a-f]{64}$/u,
);
writeFileSync(secretPath, '', { mode: 0o600 });
chmodSync(secretPath, 0o600);
const emptyRunner = new DevRunner(options, {}, explicitOptions);
emptyRunner.state.spacetimeServer = 'http://127.0.0.1:3101';
emptyRunner.prepareMigrationBootstrapSecret({});
expect(emptyRunner.runtimeServiceBootstrapSecret).toHaveLength(64);
expect(readFileSync(secretPath, 'utf8')).not.toBe('');
if (process.platform !== 'win32') {
expect(statSync(secretPath).mode & 0o777).toBe(0o600);
}
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'本地运行服务 bootstrap secret 记录不可用,将重新生成',
),
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
},
);
test('Vite 子进程环境不包含 SpacetimeDB token 或 bootstrap secret', () => {
const env = buildFrontendProcessEnv(
{
GENARRATIVE_SPACETIME_TOKEN: 'api-token',
GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET: 'migration-secret',
GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256:
'migration-secret-hash',
GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET:
'runtime-secret',
GENARRATIVE_BGFILTER_INTERNAL_TOKEN: 'bgfilter-token',
GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE: 'bgfilter-token-file',
SAFE_VALUE: 'kept',
},
{ RUST_SERVER_TARGET: 'http://127.0.0.1:8082' },
);
expect(env).not.toHaveProperty('GENARRATIVE_SPACETIME_TOKEN');
expect(env).not.toHaveProperty(
'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET',
);
expect(env).not.toHaveProperty(
'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256',
);
expect(env).not.toHaveProperty(
'GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET',
);
expect(env).not.toHaveProperty('GENARRATIVE_BGFILTER_INTERNAL_TOKEN');
expect(env).not.toHaveProperty('GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE');
expect(env.SAFE_VALUE).toBe('kept');
});
test('SpacetimeDB identity 契约错误不把 token 写入异常', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
try {
const { explicitOptions, options } = parseArgs(
['--spacetime-data-dir', tempDir],
{},
);
const runner = new DevRunner(options, {}, explicitOptions);
globalThis.fetch = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({ token: 'must-not-leak' }),
})) as unknown as typeof fetch;
let caughtError: unknown;
try {
await runner.ensureApiServerSpacetimeToken();
} catch (error) {
caughtError = error;
}
expect(caughtError).toBeInstanceOf(Error);
expect((caughtError as Error).message).toContain('缺少 identity/token');
expect((caughtError as Error).message).not.toContain('must-not-leak');
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test('本地 SpacetimeDB 不信任 env 文件中的陈旧 token', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
const originalToken = process.env.GENARRATIVE_SPACETIME_TOKEN;
delete process.env.GENARRATIVE_SPACETIME_TOKEN;
try {
const { explicitOptions, options } = parseArgs(
['--spacetime-data-dir', tempDir],
{ GENARRATIVE_SPACETIME_TOKEN: 'stale-env-file-token' },
);
const runner = new DevRunner(
options,
{ GENARRATIVE_SPACETIME_TOKEN: 'stale-env-file-token' },
explicitOptions,
);
runner.state.spacetimeServer = 'http://127.0.0.1:3101';
globalThis.fetch = vi.fn(async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
identity: 'c200freshidentity',
token: 'fresh-web-token',
}),
})) as unknown as typeof fetch;
await runner.ensureApiServerSpacetimeToken();
expect(runner.spacetimeApiToken).toBe('fresh-web-token');
expect(runner.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBeUndefined();
} finally {
if (originalToken === undefined) {
delete process.env.GENARRATIVE_SPACETIME_TOKEN;
} else {
process.env.GENARRATIVE_SPACETIME_TOKEN = originalToken;
}
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe('本地 token 信任判定', () => {
it('拒绝远程账号签发的 token 用于本地服务器(曾经的 401 InvalidSignature)', () => {
expect(
isLocallyIssuedSpacetimeToken(fakeJwt('https://api.spacetimedb.com')),
).toBe(false);
expect(isLocallyIssuedSpacetimeToken('not-a-jwt')).toBe(false);
expect(
shouldTrustExistingSpacetimeToken(
fakeJwt('https://api.spacetimedb.com'),
'http://127.0.0.1:3101',
{
env: {},
resolveCliToken: () => fakeJwt('https://api.spacetimedb.com'),
},
),
).toBe(false);
});
it('接受本机签发的 token', () => {
expect(isLocallyIssuedSpacetimeToken(fakeJwt('localhost'))).toBe(true);
expect(
isLocallyIssuedSpacetimeToken(fakeJwt('http://127.0.0.1:3101')),
).toBe(true);
expect(
shouldTrustExistingSpacetimeToken(
fakeJwt('localhost'),
'http://127.0.0.1:3101',
{
env: {},
resolveCliToken: () => fakeJwt('localhost'),
},
),
).toBe(true);
});
});