Files
Genarrative/scripts/dev.test.ts
T
kdletters 6bf8b0051b 完善画板生成器与素材导出体验
图片改造改为独立 quick-edit 生成器占位并补齐提交链路
生成完成应用项目快照后同步刷新素材库
序列帧导出补充 Spine JSON 与独立验证器
序列帧播放预加载帧资源以减少加载闪烁
本地 dev 默认启用 all 进程角色并更新文档
2026-06-25 00:12:21 +08:00

714 lines
24 KiB
TypeScript

import {mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {afterEach, describe, expect, test, vi} from 'vitest';
import {
assertReusableSpacetimeProcessVersionMatchesWorkspace,
assertSpacetimeToolVersionMatchesWorkspace,
buildApiServerProcessEnv,
buildDevStackSnapshot,
buildLocalRustProcessEnv,
buildSpacetimePublishArgs,
createDevServerSpawnOptions,
createWatchConfigs,
DevRunner,
isDirectModuleExecution,
isSpacetimePublishPermissionError,
normalizeCargoVersionRequirement,
parseArgs,
parseSpacetimeToolVersion,
resolveDevStackStatePath,
shouldAcceptWatchEvent,
} 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('单独 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.state.apiTarget).toBe('http://127.0.0.1:22001');
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1: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);
} 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);
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
}
});
});
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('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 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,
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',
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(1);
expect(snapshot.command).toBe('web');
expect(snapshot.database).toBe('genarrative-test');
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',
});
});
});
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()}\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()}\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.5.0')).toBe('2.5.0');
expect(normalizeCargoVersionRequirement('2.5.0')).toBe('2.5.0');
});
test('解析 spacetime --version 输出里的 tool version', () => {
const version = parseSpacetimeToolVersion(`
A new version of SpacetimeDB is available: v2.5.0 (current: v2.4.1)
spacetimedb tool version 2.5.0; spacetimedb-lib version 2.5.0;
`);
expect(version).toBe('2.5.0');
});
test('本机 spacetime 版本和 workspace 锁定版本不一致时直接报清楚', () => {
expect(() =>
assertSpacetimeToolVersionMatchesWorkspace({
toolVersion: '2.1.0',
workspaceVersion: '2.5.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('本地发布 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({
database: 'xushi-p4wfr',
preserveDatabase: false,
server: 'http://127.0.0.1:3101',
});
expect(args).toContain('--no-config');
expect(args).toEqual(
expect.arrayContaining([
'publish',
'xushi-p4wfr',
'--server',
'http://127.0.0.1:3101',
'-c=on-conflict',
]),
);
});
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-server 前为空 token 自动创建本地 Web identity', async () => {
const {explicitOptions, options} = parseArgs([], {
GENARRATIVE_SPACETIME_TOKEN: '',
});
const runner = new DevRunner(options, {}, explicitOptions);
runner.state.spacetimeServer = 'http://127.0.0.1:3101';
globalThis.fetch = vi.fn(async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
identity: 'c200localidentity',
token: 'local-web-token',
}),
})) as unknown as typeof fetch;
await runner.ensureApiServerSpacetimeToken();
expect(runner.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBe('local-web-token');
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://127.0.0.1:3101/v1/identity',
expect.objectContaining({
method: 'POST',
}),
);
});
test('本地 SpacetimeDB 不信任 env 文件中的陈旧 token', async () => {
const originalToken = process.env.GENARRATIVE_SPACETIME_TOKEN;
delete process.env.GENARRATIVE_SPACETIME_TOKEN;
try {
const {explicitOptions, options} = parseArgs([], {
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.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBe('fresh-web-token');
} finally {
if (originalToken === undefined) {
delete process.env.GENARRATIVE_SPACETIME_TOKEN;
} else {
process.env.GENARRATIVE_SPACETIME_TOKEN = originalToken;
}
}
});
});