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, createDevServerSpawnOptions, createWatchConfigs, 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('手动刷新 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(); }); });