import {createServer} from 'node:net'; import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {describe, expect, it} from 'vitest'; import { findAvailablePort, mapDevPortsToPortRange, parsePortRangeSpec, reserveLinuxDevPortRange, resolveDevStackPorts, } from './dev-stack-port-utils.mjs'; function reservePort(port) { return new Promise((resolve, reject) => { const server = createServer(); server.once('error', reject); server.listen(port, '127.0.0.1', () => { server.off('error', reject); resolve(server); }); }); } describe('dev stack port utils', () => { it('解析端口段并映射到四个 dev 端口', () => { expect(parsePortRangeSpec('10000-10099')).toEqual({ start: 10000, end: 10099, label: '10000-10099', }); expect(mapDevPortsToPortRange('10000-10099')).toMatchObject({ webPort: 10000, apiPort: 10001, spacetimePort: 10002, adminWebPort: 10003, }); }); it('使用端口可用性检查为被占用端口寻找后续可用端口', async () => { const firstServer = await reservePort(0); const firstPort = firstServer.address().port; const secondServer = await reservePort(firstPort + 1); try { const availablePort = await findAvailablePort({ host: '127.0.0.1', preferredPort: firstPort, }); expect(availablePort).toBeGreaterThan(firstPort + 1); } finally { await Promise.all([ new Promise((resolve) => firstServer.close(resolve)), new Promise((resolve) => secondServer.close(resolve)), ]); } }); it('端口查找不会越过 Linux 用户端口段', async () => { await expect( findAvailablePort({ host: '127.0.0.1', preferredPort: 9999, portRange: {start: 10000, end: 10099, label: '10000-10099'}, }), ).rejects.toThrow('不在允许端口段'); }); it('为 npm run dev 的所有后续流程解析互不冲突的端口', async () => { const resolvedPorts = await resolveDevStackPorts({ spacetime: {host: '127.0.0.1', preferredPort: 0}, api: {host: '127.0.0.1', preferredPort: 0}, web: {host: '127.0.0.1', preferredPort: 0}, adminWeb: {host: '127.0.0.1', preferredPort: 0}, }); expect(new Set(Object.values(resolvedPorts)).size).toBe(4); }); it('端口段内会一直漂移到段尾,不会被默认 200 次尝试截断', async () => { const rangeStart = 10000; const rangeEnd = 10300; const reservedPorts = new Set( Array.from({length: 201}, (_, index) => rangeStart + index), ); const availablePort = await findAvailablePort({ host: '127.0.0.1', preferredPort: rangeStart, reservedPorts, portRange: {start: rangeStart, end: rangeEnd, label: `${rangeStart}-${rangeEnd}`}, }); expect(availablePort).toBeGreaterThan(rangeStart + 200); expect(availablePort).toBeLessThanOrEqual(rangeEnd); }); const linuxIt = process.platform === 'linux' ? it : it.skip; linuxIt('Linux 未手动指定端口段时从 10000 开始按 100 端口块自动分配', async () => { const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-')); const registryPath = join(tempRoot, 'registry.json'); const lockPath = join(tempRoot, 'registry.lock'); try { const aliceAllocation = await reserveLinuxDevPortRange({ env: { USER: 'alice', LOGNAME: 'alice', GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot, }, username: 'alice', requestedRange: null, registryPath, lockPath, }); const bobAllocation = await reserveLinuxDevPortRange({ env: { USER: 'bob', LOGNAME: 'bob', GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot, }, username: 'bob', requestedRange: null, registryPath, lockPath, }); expect(aliceAllocation.range.label).toBe('10000-10099'); expect(aliceAllocation.source).toBe('auto'); expect(bobAllocation.range.label).toBe('10100-10199'); expect(bobAllocation.source).toBe('auto'); } finally { rmSync(tempRoot, {recursive: true, force: true}); } }); linuxIt('Linux 系统级端口段记录会阻止两个用户拿到同一段', async () => { const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-')); const registryPath = join(tempRoot, 'registry.json'); const lockPath = join(tempRoot, 'registry.lock'); try { const baseEnv = { USER: 'alice', LOGNAME: 'alice', GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot, GENARRATIVE_DEV_PORT_RANGE: '10000-10099', }; const aliceAllocation = await reserveLinuxDevPortRange({ env: baseEnv, username: 'alice', registryPath, lockPath, }); expect(aliceAllocation.range.label).toBe('10000-10099'); const registryAfterAlice = JSON.parse(readFileSync(registryPath, 'utf8')); expect(registryAfterAlice.allocations.alice.range.label).toBe('10000-10099'); await expect( reserveLinuxDevPortRange({ env: { ...baseEnv, USER: 'bob', LOGNAME: 'bob', }, username: 'bob', requestedRange: '10000-10099', registryPath, lockPath, }), ).rejects.toThrow('已被用户 alice 占用'); const aliceReuse = await reserveLinuxDevPortRange({ env: { ...baseEnv, USER: 'alice', LOGNAME: 'alice', }, username: 'alice', registryPath, lockPath, }); expect(aliceReuse.range.label).toBe('10000-10099'); expect(aliceReuse.source).toBe('manual'); expect( ( await reserveLinuxDevPortRange({ env: { ...baseEnv, USER: 'alice', LOGNAME: 'alice', GENARRATIVE_DEV_PORT_RANGE: '10100-10199', }, username: 'alice', requestedRange: '10100-10199', registryPath, lockPath, }) ).range.label, ).toBe('10000-10099'); const registryAfterReuse = JSON.parse(readFileSync(registryPath, 'utf8')); expect(registryAfterReuse.allocations.alice.range.label).toBe('10000-10099'); const bobAllocation = await reserveLinuxDevPortRange({ env: { ...baseEnv, USER: 'bob', LOGNAME: 'bob', GENARRATIVE_DEV_PORT_RANGE: '10100-10199', }, username: 'bob', requestedRange: '10100-10199', registryPath, lockPath, }); expect(bobAllocation.range.label).toBe('10100-10199'); } finally { rmSync(tempRoot, {recursive: true, force: true}); } }); linuxIt('Linux 同一用户第二个 dev 会话会复用同一用户段并继续在段内漂移', async () => { const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-')); const registryPath = join(tempRoot, 'registry.json'); const lockPath = join(tempRoot, 'registry.lock'); try { const baseEnv = { USER: 'alice', LOGNAME: 'alice', GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot, GENARRATIVE_DEV_PORT_RANGE: '10000-10099', }; const first = await reserveLinuxDevPortRange({ env: baseEnv, username: 'alice', registryPath, lockPath, }); const second = await reserveLinuxDevPortRange({ env: baseEnv, username: 'alice', registryPath, lockPath, }); expect(first.range.label).toBe('10000-10099'); expect(second.range.label).toBe('10000-10099'); expect(second.username).toBe('alice'); expect(JSON.parse(readFileSync(registryPath, 'utf8')).allocations.alice.range.label).toBe( '10000-10099', ); } finally { rmSync(tempRoot, {recursive: true, force: true}); } }); });