4f3f0f24ff
新增后台 AGC 模型目录、别名、启停和默认项管理 客户端设置页恢复原状,对话框右下角按别名选择模型 服务端按稳定模型标识映射并校验实际模型白名单 修复 AGC 配套后端端口漂移、启动等待和 SpacetimeDB 版本检查 补充迁移、文档、启动与模型选择测试
1752 lines
58 KiB
TypeScript
1752 lines
58 KiB
TypeScript
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,
|
|
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', () => {
|
|
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 一致的 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('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('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<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('不复用权限宽松、格式非法或空的本地运行服务 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 });
|
|
}
|
|
});
|
|
});
|