新增 Web Project runtime job、持久日志、lease、取消、expired、stale 和 active preview guard 状态机 接入 api-server Web Project runtime worker 与 TempDirBuildRuntime 构建执行链路 补齐 SpacetimeDB procedure、spacetime-client facade、shared contracts 和前端 web-project client 契约 更新 /editor/agent 的 runtime job 恢复、日志回填、SSE 重连、取消按钮和 active preview 刷新恢复 新增 P2 dev smoke 脚本,并让完整 npm run dev 默认以 all 角色启动 P2 worker 补充 P2 自动化测试、浏览器 smoke 验收记录、开发运维文档和 Hermes 踩坑记忆
780 lines
26 KiB
TypeScript
780 lines
26 KiB
TypeScript
import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
|
||
import {tmpdir} from 'node:os';
|
||
import {join, resolve} from 'node:path';
|
||
|
||
import {afterEach, describe, expect, test, vi} from 'vitest';
|
||
|
||
import {
|
||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||
assertSpacetimeToolVersionMatchesWorkspace,
|
||
authorizeWebProjectServiceIdentity,
|
||
buildApiServerProcessEnv,
|
||
buildDevStackSnapshot,
|
||
buildLocalRustProcessEnv,
|
||
buildSpacetimeProcedureUrl,
|
||
buildSpacetimePublishArgs,
|
||
cacheSpacetimeIdentity,
|
||
createDevServerSpawnOptions,
|
||
createWatchConfigs,
|
||
DevRunner,
|
||
isDirectModuleExecution,
|
||
isSpacetimePublishPermissionError,
|
||
normalizeCargoVersionRequirement,
|
||
parseWebProjectServiceIdentityResult,
|
||
parseArgs,
|
||
parseSpacetimeToolVersion,
|
||
readCachedSpacetimeIdentity,
|
||
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.options.webProjectPreviewPort).toBe(22004);
|
||
expect(runner.state.apiTarget).toBe('http://127.0.0.1:22001');
|
||
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:22002');
|
||
expect(runner.state.webProjectPreviewPublicBaseUrl).toBe('http://127.0.0.1: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.webProjectPreviewPort).toBe(3104);
|
||
} 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_API_PORT).toBe('9091');
|
||
expect(env.GENARRATIVE_SPACETIME_SERVER_URL).toBe('http://127.0.0.1:3199');
|
||
expect(env.GENARRATIVE_WEB_PROJECT_PREVIEW_HOST).toBe('127.0.0.1');
|
||
expect(env.GENARRATIVE_WEB_PROJECT_PREVIEW_PORT).toBe('3104');
|
||
expect(env.GENARRATIVE_WEB_PROJECT_PREVIEW_PUBLIC_BASE_URL).toBe('http://127.0.0.1:3104');
|
||
expect(env.GENARRATIVE_WEB_PROJECT_PREVIEW_FRAME_ANCESTORS).toBe(
|
||
'http://127.0.0.1:3000 http://localhost:3000',
|
||
);
|
||
});
|
||
|
||
test('dev 脚本默认注入 Web Project runner 二进制路径', () => {
|
||
const {options} = parseArgs(['api-server'], {});
|
||
const env = buildApiServerProcessEnv({
|
||
baseEnv: {},
|
||
options,
|
||
state: {spacetimeServer: 'http://127.0.0.1:3101'},
|
||
});
|
||
const runnerName =
|
||
process.platform === 'win32' ? 'web-project-runner.exe' : 'web-project-runner';
|
||
|
||
expect(env.GENARRATIVE_WEB_PROJECT_RUNNER_BIN).toBe(
|
||
resolve('server-rs', 'target/debug', runnerName),
|
||
);
|
||
});
|
||
|
||
test('完整 dev 栈默认以 all 角色启动 api-server 和 P2 worker', () => {
|
||
const {options} = parseArgs([], {});
|
||
const env = buildApiServerProcessEnv({
|
||
baseEnv: {},
|
||
options,
|
||
state: {spacetimeServer: 'http://127.0.0.1:3101'},
|
||
processRole: 'all',
|
||
});
|
||
|
||
expect(env.GENARRATIVE_PROCESS_ROLE).toBe('all');
|
||
});
|
||
|
||
test('dev 脚本保留显式 GENARRATIVE_PROCESS_ROLE', () => {
|
||
const {options} = parseArgs([], {});
|
||
const env = buildApiServerProcessEnv({
|
||
baseEnv: {GENARRATIVE_PROCESS_ROLE: 'web-project-runtime-worker'},
|
||
options,
|
||
state: {spacetimeServer: 'http://127.0.0.1:3101'},
|
||
processRole: 'all',
|
||
});
|
||
|
||
expect(env.GENARRATIVE_PROCESS_ROLE).toBe('web-project-runtime-worker');
|
||
});
|
||
|
||
test('单独 dev:api-server 不默认注入进程角色', () => {
|
||
const {options} = parseArgs(['api-server'], {});
|
||
const env = buildApiServerProcessEnv({
|
||
baseEnv: {},
|
||
options,
|
||
state: {spacetimeServer: 'http://127.0.0.1:3101'},
|
||
});
|
||
|
||
expect(env.GENARRATIVE_PROCESS_ROLE).toBeUndefined();
|
||
});
|
||
|
||
test('dev 脚本保留显式 Web Project runner 二进制路径', () => {
|
||
const {options} = parseArgs(['api-server'], {});
|
||
const env = buildApiServerProcessEnv({
|
||
baseEnv: {
|
||
GENARRATIVE_WEB_PROJECT_RUNNER_BIN: 'C:\\tools\\web-project-runner.exe',
|
||
},
|
||
options,
|
||
state: {spacetimeServer: 'http://127.0.0.1:3101'},
|
||
});
|
||
|
||
expect(env.GENARRATIVE_WEB_PROJECT_RUNNER_BIN).toBe(
|
||
'C:\\tools\\web-project-runner.exe',
|
||
);
|
||
});
|
||
});
|
||
|
||
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);
|
||
expect(env.RUSTC_WRAPPER).toBe(process.platform === 'win32' ? '' : '/usr/bin/env');
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|
||
|
||
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,
|
||
webProjectPreviewHost: '127.0.0.1',
|
||
webProjectPreviewPort: 3112,
|
||
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',
|
||
webProjectPreviewPublicBaseUrl: 'http://127.0.0.1:3112',
|
||
},
|
||
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',
|
||
});
|
||
expect(snapshot.services['web-project-preview']).toMatchObject({
|
||
status: 'idle',
|
||
pid: null,
|
||
host: '127.0.0.1',
|
||
port: 3112,
|
||
url: 'http://127.0.0.1:3112',
|
||
command: 'api-server embedded Web Project preview gateway',
|
||
});
|
||
});
|
||
});
|
||
|
||
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-module 时准备 Web Project 服务身份引导密钥', () => {
|
||
const {explicitOptions, options} = parseArgs([], {});
|
||
const runner = new DevRunner(options, {}, explicitOptions);
|
||
const env = {};
|
||
|
||
runner.prepareWebProjectServiceBootstrapSecret(env);
|
||
|
||
expect(env.GENARRATIVE_SPACETIME_WEB_PROJECT_SERVICE_BOOTSTRAP_SECRET).toHaveLength(64);
|
||
expect(runner.state.webProjectServiceBootstrapSecretPrepared).toBe(true);
|
||
});
|
||
|
||
test('Web Project 服务身份授权使用本地 Web token 调用 procedure', async () => {
|
||
globalThis.fetch = vi.fn(async () => ({
|
||
ok: true,
|
||
status: 200,
|
||
text: async () =>
|
||
JSON.stringify({
|
||
ok: true,
|
||
service_identity_hex: 'c200localidentity',
|
||
error_message: null,
|
||
}),
|
||
})) as unknown as typeof fetch;
|
||
|
||
await authorizeWebProjectServiceIdentity({
|
||
serverUrl: 'http://127.0.0.1:3101',
|
||
database: 'genarrative-dev',
|
||
token: 'local-web-token',
|
||
bootstrapSecret: '0123456789abcdef',
|
||
serviceIdentity: 'c200localidentity',
|
||
});
|
||
|
||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||
buildSpacetimeProcedureUrl(
|
||
'http://127.0.0.1:3101',
|
||
'genarrative-dev',
|
||
'authorize_web_project_service_identity',
|
||
),
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
headers: expect.objectContaining({
|
||
Authorization: 'Bearer local-web-token',
|
||
}),
|
||
body: JSON.stringify([
|
||
{
|
||
bootstrap_secret: '0123456789abcdef',
|
||
service_identity_hex: 'c200localidentity',
|
||
note: 'local api-server web project',
|
||
},
|
||
]),
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('Web Project 服务身份授权返回值兼容 BSATN 数组形态', () => {
|
||
expect(parseWebProjectServiceIdentityResult('[true,[0,"c200localidentity"],[1]]')).toEqual({
|
||
ok: true,
|
||
service_identity_hex: 'c200localidentity',
|
||
error_message: null,
|
||
});
|
||
});
|
||
|
||
test('本地 api-server SpacetimeDB identity 写入并复用 data-dir 缓存', () => {
|
||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
|
||
try {
|
||
cacheSpacetimeIdentity(tempDir, 'http://127.0.0.1:3101', {
|
||
identity: 'c200cachedidentity',
|
||
token: 'cached-token',
|
||
});
|
||
|
||
expect(readCachedSpacetimeIdentity(tempDir, 'http://127.0.0.1:3101')).toEqual({
|
||
identity: 'c200cachedidentity',
|
||
token: 'cached-token',
|
||
});
|
||
expect(readCachedSpacetimeIdentity(tempDir, 'http://127.0.0.1:3199')).toBeNull();
|
||
} finally {
|
||
rmSync(tempDir, {recursive: true, force: true});
|
||
}
|
||
});
|
||
|
||
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 tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
|
||
try {
|
||
const {explicitOptions, options} = parseArgs(
|
||
['--spacetime-data-dir', tempDir],
|
||
{
|
||
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(readCachedSpacetimeIdentity(tempDir, 'http://127.0.0.1:3101')).toEqual({
|
||
identity: 'c200localidentity',
|
||
token: 'local-web-token',
|
||
});
|
||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||
'http://127.0.0.1:3101/v1/identity',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
}),
|
||
);
|
||
} finally {
|
||
rmSync(tempDir, {recursive: true, force: true});
|
||
}
|
||
});
|
||
|
||
test('本地 SpacetimeDB 不信任 env 文件中的陈旧 token', async () => {
|
||
const originalToken = process.env.GENARRATIVE_SPACETIME_TOKEN;
|
||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
|
||
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.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBe('fresh-web-token');
|
||
} finally {
|
||
rmSync(tempDir, {recursive: true, force: true});
|
||
if (originalToken === undefined) {
|
||
delete process.env.GENARRATIVE_SPACETIME_TOKEN;
|
||
} else {
|
||
process.env.GENARRATIVE_SPACETIME_TOKEN = originalToken;
|
||
}
|
||
}
|
||
});
|
||
});
|