Files
Genarrative/scripts/dev-stack-port-utils.test.ts
T
kdletters 83f07fc58d
Project CI / Repository checks (push) Failing after 1m2s
Project CI / Frontend tests (push) Successful in 3m19s
Project CI / Backend tests (push) Successful in 4m18s
Project CI / Native shell tests (push) Failing after 11m47s
统一 AGC 开发端口分配
将 AGC Vite 纳入 Linux 用户端口段第六槽位
同步 Tauri devUrl、Vite 监听和配套后端端口预留
兼容迁移旧五端口注册记录并阻止重复分配
补齐动态配置顺序、跨平台和进程生命周期回归测试
更新开发运维文档、端口 skill 与项目共享记忆
2026-08-08 16:18:45 +08:00

417 lines
12 KiB
TypeScript

import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
import {createServer} from 'node:net';
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);
});
});
}
async function reserveConsecutivePorts() {
for (let attempt = 0; attempt < 20; attempt += 1) {
const firstServer = await reservePort(0);
const firstPort = firstServer.address().port;
if (firstPort === 65535) {
await new Promise((resolve) => firstServer.close(resolve));
continue;
}
try {
const secondServer = await reservePort(firstPort + 1);
return {firstServer, secondServer, firstPort};
} catch {
await new Promise((resolve) => firstServer.close(resolve));
}
}
throw new Error('无法为测试保留两个连续端口');
}
describe('dev stack port utils', () => {
it('解析端口段并映射到五个 dev 服务和 AGC 端口', () => {
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,
bgfilterWorkerPort: 10004,
agcVitePort: 10005,
});
expect(() => parsePortRangeSpec('10000-10004')).toThrow(
'端口段至少需要 6 个端口',
);
});
it('使用端口可用性检查为被占用端口寻找后续可用端口', async () => {
const {firstServer, secondServer, firstPort} = await reserveConsecutivePorts();
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('严格端口模式在端口被占用时直接失败而不是漂移', async () => {
const server = await reservePort(0);
const port = server.address().port;
try {
await expect(
findAvailablePort({
host: '127.0.0.1',
preferredPort: port,
strict: true,
}),
).rejects.toThrow('无法严格使用该端口');
} finally {
await new Promise((resolve) => server.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},
bgfilterWorker: {host: '127.0.0.1', preferredPort: 0},
});
expect(new Set(Object.values(resolvedPorts)).size).toBe(5);
});
it('解析 dev 服务时跳过父启动器预留的 AGC Vite 端口', async () => {
const server = await reservePort(0);
const preferredPort = server.address().port;
await new Promise((resolve) => server.close(resolve));
const resolvedPorts = await resolveDevStackPorts(
{
api: {host: '127.0.0.1', preferredPort},
},
{reservedPorts: new Set([preferredPort])},
);
expect(resolvedPorts.api).toBeGreaterThan(preferredPort);
});
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 升级时保留旧 v4 五端口记录并阻止重复分配', async () => {
const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-'));
const registryPath = join(tempRoot, 'registry.json');
const lockPath = join(tempRoot, 'registry.lock');
try {
writeFileSync(
registryPath,
JSON.stringify({
version: 4,
updatedAt: '2026-08-01T00:00:00.000Z',
allocations: {
alice: {
username: 'alice',
range: {start: 10000, end: 10004, label: '10000-10004'},
claimedAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
source: 'manual',
},
},
}),
);
const bobAllocation = await reserveLinuxDevPortRange({
env: {
USER: 'bob',
LOGNAME: 'bob',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot,
},
username: 'bob',
requestedRange: null,
registryPath,
lockPath,
});
const registry = JSON.parse(readFileSync(registryPath, 'utf8'));
expect(bobAllocation.range.label).toBe('10100-10199');
expect(registry.allocations.alice.range.label).toBe('10000-10004');
expect(registry.allocations.bob.range.label).toBe('10100-10199');
} finally {
rmSync(tempRoot, {recursive: true, force: true});
}
});
linuxIt('Linux 当前用户的旧五端口记录会在无冲突时扩出 AGC 槽位', async () => {
const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-'));
const registryPath = join(tempRoot, 'registry.json');
const lockPath = join(tempRoot, 'registry.lock');
try {
writeFileSync(
registryPath,
JSON.stringify({
version: 4,
updatedAt: '2026-08-01T00:00:00.000Z',
allocations: {
alice: {
username: 'alice',
range: {start: 10000, end: 10004, label: '10000-10004'},
claimedAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
source: 'manual',
},
},
}),
);
const allocation = await reserveLinuxDevPortRange({
env: {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot,
},
username: 'alice',
requestedRange: null,
registryPath,
lockPath,
});
expect(allocation.range.label).toBe('10000-10005');
expect(
JSON.parse(readFileSync(registryPath, 'utf8')).allocations.alice.range
.label,
).toBe('10000-10005');
} finally {
rmSync(tempRoot, {recursive: true, force: true});
}
});
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});
}
});
});