333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
|
||
import {tmpdir} from 'node:os';
|
||
import {join} from 'node:path';
|
||
|
||
import {afterEach, describe, expect, test, vi} from 'vitest';
|
||
|
||
import {
|
||
DevRunner,
|
||
buildSpacetimePublishArgs,
|
||
createDevServerSpawnOptions,
|
||
createWatchConfigs,
|
||
isSpacetimePublishPermissionError,
|
||
parseArgs,
|
||
shouldAcceptWatchEvent,
|
||
} from './dev.mjs';
|
||
|
||
const originalFetch = globalThis.fetch;
|
||
|
||
afterEach(() => {
|
||
globalThis.fetch = originalFetch;
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe('dev scheduler argument routing', () => {
|
||
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',
|
||
);
|
||
});
|
||
});
|
||
|
||
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');
|
||
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');
|
||
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('本地发布 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;
|
||
}
|
||
}
|
||
});
|
||
});
|