import { createHash } from 'node:crypto'; import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { assertReusableSpacetimeProcessVersionMatchesWorkspace, assertSpacetimeToolVersionMatchesWorkspace, buildApiServerProcessEnv, buildBgfilterWorkerProcessEnv, buildDevStackSnapshot, buildFrontendProcessEnv, buildLocalRustProcessEnv, buildSpacetimePublishArgs, createDevServerSpawnOptions, createWatchConfigs, DevRunner, isDirectModuleExecution, isSpacetimePublishPermissionError, isStaleExternalGenerationWorkerProcess, normalizeCargoVersionRequirement, parseArgs, parseProcessEnvBlock, parseSpacetimeToolCommit, parseSpacetimeToolVersion, resolveDevStackStatePath, resolveLocalSpacetimeApiIdentityPath, resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath, shouldAcceptWatchEvent, shouldTrustExistingSpacetimeToken, } from './dev.mjs'; 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', () => { 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 一致的 env token', () => { expect( shouldTrustExistingSpacetimeToken( 'owner-cli-token', 'http://127.0.0.1:3101', { env: {}, resolveCliToken: () => 'owner-cli-token', }, ), ).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('完整栈只为两个 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('local dev Rust env bypasses project sccache wrapper', () => { const env = buildLocalRustProcessEnv( { RUSTC_WRAPPER: '/usr/bin/sccache', CARGO_BUILD_RUSTC_WRAPPER: 'sccache', }, { log: false }, ); expect(env.RUSTC_WRAPPER).not.toBe('/usr/bin/sccache'); expect(env.RUSTC_WRAPPER).not.toBe('sccache'); expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER); }); test('local dev Rust env keeps healthy custom wrapper untouched', () => { const env = buildLocalRustProcessEnv( { RUSTC_WRAPPER: 'custom-wrapper', CARGO_BUILD_RUSTC_WRAPPER: 'sccache', }, { log: false }, ); expect(env.RUSTC_WRAPPER).toBe('custom-wrapper'); expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper'); }); test('Windows 下本地 dev Rust env 用空 wrapper 覆盖项目 sccache', () => { 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 }, ); expect(env.RUSTC_WRAPPER).toBe(''); expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(''); } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform); } } }); }); 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 = { 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.command).toBe('web'); expect(snapshot.database).toBe('genarrative-test'); expect(snapshot.spacetimeDataDir).toBe( resolve('server-rs/.spacetimedb/local/data'), ); expect(snapshot.services.web).toMatchObject({ 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('本地 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 = {}; 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 = {}; 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 = {}; 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('不复用权限宽松、格式非法或空的本地运行服务 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 }); } }); });