Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes
# Conflicts: # .hermes/shared-memory/decision-log.md # .hermes/shared-memory/project-overview.md # docs/【开发运维】本地开发验证与生产运维-2026-05-15.md # scripts/dev.test.ts # server-rs/crates/api-server/src/creation_entry_config.rs # server-rs/crates/api-server/src/wooden_fish.rs # server-rs/crates/module-auth/src/lib.rs # server-rs/crates/spacetime-client/src/wooden_fish.rs # server-rs/crates/spacetime-module/src/auth/procedures.rs # src/components/custom-world-home/creationWorkShelf.ts # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx # src/components/rpg-entry/rpgEntryWorldPresentation.ts # src/services/miniGameDraftGenerationProgress.test.ts # src/services/miniGameDraftGenerationProgress.ts
This commit is contained in:
@@ -400,6 +400,10 @@ if [[ "${BUILD_WEB}" -eq 1 ]]; then
|
||||
MAINTENANCE_HTML
|
||||
fi
|
||||
|
||||
echo "[production-release] 规范 Web 静态资源权限"
|
||||
find "${WEB_DIR}" -type d -exec chmod 755 {} +
|
||||
find "${WEB_DIR}" -type f -exec chmod 644 {} +
|
||||
|
||||
echo "[production-release] 打包 Web 静态资源 -> ${TARGET_DIR}/web.tar.gz"
|
||||
tar -czf "${TARGET_DIR}/web.tar.gz" -C "${TARGET_DIR}" web
|
||||
write_sha256_file "${TARGET_DIR}/web.tar.gz"
|
||||
|
||||
@@ -98,6 +98,10 @@ echo "[production-web-deploy] 解压 Web 到: ${WEB_TARGET}"
|
||||
tar -xzf "${SOURCE_DIR}/web.tar.gz" -C "${RELEASE_DIR}"
|
||||
test -d "${WEB_TARGET}"
|
||||
|
||||
echo "[production-web-deploy] 规范 Web 静态资源权限"
|
||||
find "${WEB_TARGET}" -type d -exec chmod 755 {} +
|
||||
find "${WEB_TARGET}" -type f -exec chmod 644 {} +
|
||||
|
||||
if [[ -f "${SOURCE_DIR}/release-manifest.json" ]]; then
|
||||
cp "${SOURCE_DIR}/release-manifest.json" "${RELEASE_DIR}/release-manifest.web.json"
|
||||
fi
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import {hostname, userInfo} from 'node:os';
|
||||
import {dirname, resolve} from 'node:path';
|
||||
import {createServer} from 'node:net';
|
||||
|
||||
const LINUX_DEV_PORT_RANGE_REGISTRY_ROOT = '/var/tmp/genarrative-dev-port-ranges';
|
||||
const LINUX_DEV_PORT_RANGE_POOL_START = 10000;
|
||||
const LINUX_DEV_PORT_RANGE_POOL_END = 39999;
|
||||
const LINUX_DEV_PORT_RANGE_BLOCK_SIZE = 100;
|
||||
|
||||
function toListenHosts(host) {
|
||||
if (host === '0.0.0.0') {
|
||||
return ['0.0.0.0'];
|
||||
@@ -21,6 +38,416 @@ export function normalizePort(value, fallback) {
|
||||
return port;
|
||||
}
|
||||
|
||||
export function parsePortRangeSpec(value) {
|
||||
const spec = String(value ?? '').trim();
|
||||
if (!spec) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = /^(\d+)\s*[-:]\s*(\d+)$/u.exec(spec);
|
||||
if (!match) {
|
||||
throw new Error(`端口段格式错误: ${spec},应为 start-end 形式,如 10000-10099`);
|
||||
}
|
||||
|
||||
const start = normalizePort(match[1], -1);
|
||||
const end = normalizePort(match[2], -1);
|
||||
if (start < 1024 || end < 1024 || start > end) {
|
||||
throw new Error(`端口段无效: ${spec},端口必须在 1024-65535 且起始不大于结束`);
|
||||
}
|
||||
|
||||
if (end - start + 1 < 4) {
|
||||
throw new Error(`端口段至少需要 4 个端口: ${spec}`);
|
||||
}
|
||||
|
||||
return {start, end, label: `${start}-${end}`};
|
||||
}
|
||||
|
||||
function normalizePortRange(portRange) {
|
||||
if (!portRange) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof portRange === 'string') {
|
||||
return parsePortRangeSpec(portRange);
|
||||
}
|
||||
|
||||
return parsePortRangeSpec(`${portRange.start}-${portRange.end}`);
|
||||
}
|
||||
|
||||
export function getLinuxDevPortRangeRegistryPaths(env = process.env) {
|
||||
const registryRoot =
|
||||
String(env.GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR ?? '').trim() ||
|
||||
LINUX_DEV_PORT_RANGE_REGISTRY_ROOT;
|
||||
|
||||
return {
|
||||
root: registryRoot,
|
||||
registryPath: resolve(registryRoot, 'registry.json'),
|
||||
lockPath: resolve(registryRoot, 'registry.lock'),
|
||||
};
|
||||
}
|
||||
|
||||
export function getLinuxDevPortRangeUsername(env = process.env) {
|
||||
const candidates = [env.SUDO_USER, env.LOGNAME, env.USER, env.USERNAME];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const value = String(candidate ?? '').trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return userInfo().username;
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export function getLinuxDevPortRangeSpec(env = process.env) {
|
||||
return parsePortRangeSpec(env.GENARRATIVE_DEV_PORT_RANGE);
|
||||
}
|
||||
|
||||
export function mapDevPortsToPortRange(portRange) {
|
||||
const normalizedRange = normalizePortRange(portRange);
|
||||
if (!normalizedRange) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
webPort: normalizedRange.start,
|
||||
apiPort: normalizedRange.start + 1,
|
||||
spacetimePort: normalizedRange.start + 2,
|
||||
adminWebPort: normalizedRange.start + 3,
|
||||
range: normalizedRange,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureWorldWritableDirectory(path) {
|
||||
mkdirSync(path, {recursive: true});
|
||||
try {
|
||||
chmodSync(path, 0o777);
|
||||
} catch {
|
||||
// 目录已存在时若无法改权限,也不影响当前进程继续写入。
|
||||
}
|
||||
}
|
||||
|
||||
function randomToken(byteLength = 8) {
|
||||
return randomBytes(byteLength).toString('hex');
|
||||
}
|
||||
|
||||
function readJsonFile(path, fallbackValue) {
|
||||
if (!existsSync(path)) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const rawText = readFileSync(path, 'utf8').trim();
|
||||
if (!rawText) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(rawText);
|
||||
} catch (error) {
|
||||
throw new Error(`系统级端口段记录文件损坏: ${path}; ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function atomicWriteJsonFile(path, value) {
|
||||
const targetDir = dirname(path);
|
||||
ensureWorldWritableDirectory(targetDir);
|
||||
|
||||
const tempPath = resolve(
|
||||
targetDir,
|
||||
`.tmp.${process.pid}.${Date.now()}.${randomToken()}.json`,
|
||||
);
|
||||
|
||||
writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o666,
|
||||
});
|
||||
|
||||
try {
|
||||
chmodSync(tempPath, 0o666);
|
||||
} catch {
|
||||
// 先写成功再说,后续 rename 之后还能补一次。
|
||||
}
|
||||
|
||||
try {
|
||||
renameSync(tempPath, path);
|
||||
} catch (error) {
|
||||
try {
|
||||
rmSync(tempPath, {force: true});
|
||||
} catch {
|
||||
// ignore cleanup races
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
chmodSync(path, 0o666);
|
||||
} catch {
|
||||
// 忽略权限补写失败,记录已落盘即可。
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function getDefaultPortRangeRegistry() {
|
||||
return {
|
||||
version: 4,
|
||||
updatedAt: '',
|
||||
allocations: {},
|
||||
};
|
||||
}
|
||||
|
||||
function readLinuxPortRangeRegistry(registryPath) {
|
||||
const registry = readJsonFile(registryPath, getDefaultPortRangeRegistry());
|
||||
if (!registry || typeof registry !== 'object' || Array.isArray(registry)) {
|
||||
throw new Error(`系统级端口段记录文件格式错误: ${registryPath}`);
|
||||
}
|
||||
|
||||
if (!registry.allocations || typeof registry.allocations !== 'object') {
|
||||
registry.allocations = {};
|
||||
}
|
||||
|
||||
const normalizedAllocations = {};
|
||||
for (const [key, record] of Object.entries(registry.allocations)) {
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const username = String(record.username ?? key ?? '').trim();
|
||||
const range = safeNormalizePortRange(record.range);
|
||||
if (!username || !range) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedAllocations[username] = {
|
||||
username,
|
||||
range,
|
||||
claimedAt: String(record.claimedAt ?? '').trim(),
|
||||
updatedAt: String(record.updatedAt ?? record.claimedAt ?? '').trim(),
|
||||
source: String(record.source ?? 'auto').trim() || 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
registry.version = 4;
|
||||
registry.allocations = normalizedAllocations;
|
||||
return registry;
|
||||
}
|
||||
|
||||
function safeNormalizePortRange(portRange) {
|
||||
try {
|
||||
return normalizePortRange(portRange);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function rangesOverlap(left, right) {
|
||||
return left.start <= right.end && right.start <= left.end;
|
||||
}
|
||||
|
||||
function findRangeConflict(registry, portRange, excludingUsername = '') {
|
||||
const rangeToCheck = safeNormalizePortRange(portRange);
|
||||
if (!rangeToCheck) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const [username, record] of Object.entries(registry.allocations ?? {})) {
|
||||
if (username === excludingUsername) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const recordRange = safeNormalizePortRange(record?.range);
|
||||
if (!recordRange || !rangesOverlap(recordRange, rangeToCheck)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {username, record, range: recordRange};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function nextLinuxPortRangeCandidate(registry) {
|
||||
for (
|
||||
let start = LINUX_DEV_PORT_RANGE_POOL_START;
|
||||
start <= LINUX_DEV_PORT_RANGE_POOL_END;
|
||||
start += LINUX_DEV_PORT_RANGE_BLOCK_SIZE
|
||||
) {
|
||||
const end = start + LINUX_DEV_PORT_RANGE_BLOCK_SIZE - 1;
|
||||
if (end > LINUX_DEV_PORT_RANGE_POOL_END) {
|
||||
break;
|
||||
}
|
||||
|
||||
const label = `${start}-${end}`;
|
||||
if (!findRangeConflict(registry, {start, end, label})) {
|
||||
return {start, end, label};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function withLinuxPortRangeRegistryLock(lockPath, action) {
|
||||
ensureWorldWritableDirectory(dirname(lockPath));
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
pid: process.pid,
|
||||
hostname: hostname(),
|
||||
acquiredAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
{encoding: 'utf8', flag: 'wx', mode: 0o666},
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
let lockRecord = null;
|
||||
try {
|
||||
lockRecord = readJsonFile(lockPath, null);
|
||||
} catch {
|
||||
lockRecord = null;
|
||||
}
|
||||
|
||||
if (!lockRecord || !isProcessAlive(lockRecord.pid)) {
|
||||
try {
|
||||
rmSync(lockPath, {force: true});
|
||||
} catch {
|
||||
// ignore cleanup races
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await sleep(50);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
try {
|
||||
rmSync(lockPath, {force: true});
|
||||
} catch {
|
||||
// ignore cleanup races
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function reserveLinuxDevPortRange({
|
||||
env = process.env,
|
||||
username = getLinuxDevPortRangeUsername(env),
|
||||
requestedRange = getLinuxDevPortRangeSpec(env),
|
||||
registryPath = getLinuxDevPortRangeRegistryPaths(env).registryPath,
|
||||
lockPath = getLinuxDevPortRangeRegistryPaths(env).lockPath,
|
||||
} = {}) {
|
||||
if (process.platform !== 'linux') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedRequestedRange = normalizePortRange(requestedRange);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return await withLinuxPortRangeRegistryLock(lockPath, async () => {
|
||||
const registry = readLinuxPortRangeRegistry(registryPath);
|
||||
|
||||
const current = registry.allocations[username];
|
||||
if (current) {
|
||||
registry.updatedAt = now;
|
||||
registry.allocations[username] = {
|
||||
...current,
|
||||
username,
|
||||
range: current.range,
|
||||
updatedAt: now,
|
||||
};
|
||||
atomicWriteJsonFile(registryPath, registry);
|
||||
return registry.allocations[username];
|
||||
}
|
||||
|
||||
const rangeToUse = normalizedRequestedRange || nextLinuxPortRangeCandidate(registry);
|
||||
|
||||
if (!rangeToUse) {
|
||||
throw new Error(
|
||||
`无法为 Linux dev 分配端口段,范围 ${LINUX_DEV_PORT_RANGE_POOL_START}-${LINUX_DEV_PORT_RANGE_POOL_END} 已耗尽`,
|
||||
);
|
||||
}
|
||||
|
||||
const conflict = findRangeConflict(registry, rangeToUse.label, username);
|
||||
if (conflict) {
|
||||
if (conflict.range?.label === rangeToUse.label) {
|
||||
throw new Error(
|
||||
`Linux 端口段 ${rangeToUse.label} 已被用户 ${conflict.username} 占用,请换一段再启动`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Linux 端口段 ${rangeToUse.label} 已与用户 ${conflict.username} 的端口段 ${conflict.range?.label} 重叠,请换一段再启动`,
|
||||
);
|
||||
}
|
||||
|
||||
registry.allocations[username] = {
|
||||
username,
|
||||
range: rangeToUse,
|
||||
source: normalizedRequestedRange ? 'manual' : 'auto',
|
||||
claimedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
registry.updatedAt = now;
|
||||
atomicWriteJsonFile(registryPath, registry);
|
||||
|
||||
return registry.allocations[username];
|
||||
});
|
||||
}
|
||||
|
||||
export async function releaseLinuxDevPortRange({
|
||||
env = process.env,
|
||||
username = getLinuxDevPortRangeUsername(env),
|
||||
registryPath = getLinuxDevPortRangeRegistryPaths(env).registryPath,
|
||||
lockPath = getLinuxDevPortRangeRegistryPaths(env).lockPath,
|
||||
} = {}) {
|
||||
if (process.platform !== 'linux') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await withLinuxPortRangeRegistryLock(lockPath, async () => {
|
||||
const registry = readLinuxPortRangeRegistry(registryPath);
|
||||
if (!registry.allocations[username]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete registry.allocations[username];
|
||||
registry.updatedAt = new Date().toISOString();
|
||||
atomicWriteJsonFile(registryPath, registry);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function isPortAvailable({host, port}) {
|
||||
if (port === 0) {
|
||||
return true;
|
||||
@@ -46,16 +473,45 @@ export async function isPortAvailable({host, port}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function findAvailablePort({host, preferredPort, reservedPorts = new Set(), maxAttempts = 200}) {
|
||||
export async function findAvailablePort({
|
||||
host,
|
||||
preferredPort,
|
||||
reservedPorts = new Set(),
|
||||
maxAttempts = null,
|
||||
portRange = null,
|
||||
}) {
|
||||
const range = normalizePortRange(portRange);
|
||||
const startPort = normalizePort(preferredPort, 0);
|
||||
|
||||
if (startPort === 0 && range) {
|
||||
return await findAvailablePort({
|
||||
host,
|
||||
preferredPort: range.start,
|
||||
reservedPorts,
|
||||
maxAttempts: range.end - range.start,
|
||||
portRange: range,
|
||||
});
|
||||
}
|
||||
|
||||
if (startPort === 0) {
|
||||
return await reserveEphemeralPort(host, reservedPorts);
|
||||
}
|
||||
|
||||
for (let offset = 0; offset <= maxAttempts; offset += 1) {
|
||||
if (range && (startPort < range.start || startPort > range.end)) {
|
||||
throw new Error(`端口 ${startPort} 不在允许端口段 ${range.label} 内`);
|
||||
}
|
||||
|
||||
const boundedAttempts = range
|
||||
? Number.isFinite(maxAttempts)
|
||||
? Math.min(Math.max(0, maxAttempts), range.end - startPort)
|
||||
: range.end - startPort
|
||||
: Number.isFinite(maxAttempts)
|
||||
? Math.max(0, maxAttempts)
|
||||
: 200;
|
||||
|
||||
for (let offset = 0; offset <= boundedAttempts; offset += 1) {
|
||||
const candidate = startPort + offset;
|
||||
if (candidate > 65535) {
|
||||
if (candidate > 65535 || (range && candidate > range.end)) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -68,7 +524,8 @@ export async function findAvailablePort({host, preferredPort, reservedPorts = ne
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`无法从 ${host}:${startPort} 开始找到可用端口`);
|
||||
const rangeHint = range ? `,允许端口段 ${range.label}` : '';
|
||||
throw new Error(`无法从 ${host}:${startPort} 开始找到可用端口${rangeHint}`);
|
||||
}
|
||||
|
||||
async function reserveEphemeralPort(host, reservedPorts) {
|
||||
@@ -107,6 +564,7 @@ export async function resolveDevStackPorts(config) {
|
||||
host: portConfig.host,
|
||||
preferredPort: portConfig.preferredPort,
|
||||
reservedPorts,
|
||||
portRange: portConfig.portRange,
|
||||
});
|
||||
reservedPorts.add(resolvedPort);
|
||||
result[name] = resolvedPort;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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';
|
||||
|
||||
@@ -18,6 +24,20 @@ function reservePort(port) {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -38,6 +58,16 @@ describe('dev stack port utils', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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},
|
||||
@@ -48,4 +78,191 @@ describe('dev stack port utils', () => {
|
||||
|
||||
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});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
392
scripts/dev.mjs
392
scripts/dev.mjs
@@ -10,13 +10,17 @@ import {
|
||||
watch,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import {basename, relative, resolve} from 'node:path';
|
||||
import {basename, join, relative, resolve} from 'node:path';
|
||||
import {createInterface} from 'node:readline';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
import {
|
||||
formatPortDecision,
|
||||
getLinuxDevPortRangeRegistryPaths,
|
||||
getLinuxDevPortRangeUsername,
|
||||
mapDevPortsToPortRange,
|
||||
normalizePort,
|
||||
reserveLinuxDevPortRange,
|
||||
resolveDevStackPorts,
|
||||
} from './dev-stack-port-utils.mjs';
|
||||
import {
|
||||
@@ -27,6 +31,7 @@ import {
|
||||
} from './dev-utils.mjs';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const devStackStatePath = join(repoRoot, '.app/dev-stack.json');
|
||||
const serverRsDir = resolve(repoRoot, 'server-rs');
|
||||
const manifestPath = resolve(serverRsDir, 'Cargo.toml');
|
||||
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
|
||||
@@ -60,6 +65,7 @@ function usage() {
|
||||
--spacetime-port <port> SpacetimeDB 端口
|
||||
--spacetime-data-dir <path> SpacetimeDB 本地数据目录
|
||||
--database <name> SpacetimeDB 数据库名
|
||||
--port-range <start-end> Linux 用户端口段,手动指定示例 10000-10099;默认自动从 10000-10099 起分配
|
||||
--watch 文件改动后刷新/重启对应模块
|
||||
--no-interactive 关闭交互式手动命令
|
||||
|
||||
@@ -110,6 +116,7 @@ function parseArgs(argv, baseEnv) {
|
||||
spacetimePort: normalizePort(env.SPACETIME_PORT, 3101),
|
||||
spacetimeDataDir: resolve(serverRsDir, '.spacetimedb/local/data'),
|
||||
spacetimeServerUrl: String(env.GENARRATIVE_SPACETIME_SERVER_URL ?? '').trim(),
|
||||
portRangeSpec: String(env.GENARRATIVE_DEV_PORT_RANGE ?? '').trim(),
|
||||
database:
|
||||
readLocalSpacetimeDatabase() ||
|
||||
String(env.GENARRATIVE_SPACETIME_DATABASE ?? '').trim() ||
|
||||
@@ -185,6 +192,10 @@ function parseArgs(argv, baseEnv) {
|
||||
options.database = readValue();
|
||||
explicitOptions.add('database');
|
||||
break;
|
||||
case '--port-range':
|
||||
options.portRangeSpec = readValue();
|
||||
explicitOptions.add('portRangeSpec');
|
||||
break;
|
||||
case '--log':
|
||||
options.apiLog = readValue();
|
||||
break;
|
||||
@@ -246,6 +257,136 @@ function normalizeServiceName(rawName) {
|
||||
throw new Error(`未知模块: ${rawName}`);
|
||||
}
|
||||
|
||||
function resolveDevStackStatePath(root = repoRoot) {
|
||||
return join(root, '.app/dev-stack.json');
|
||||
}
|
||||
|
||||
function buildDevStackSnapshot(runner, updatedAt = new Date().toISOString()) {
|
||||
const services = {};
|
||||
for (const serviceName of SERVICE_NAMES) {
|
||||
services[serviceName] = buildDevStackServiceSnapshot(
|
||||
runner,
|
||||
serviceName,
|
||||
updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
command: runner.command ?? 'all',
|
||||
repoRoot,
|
||||
database: runner.options.database,
|
||||
watch: Boolean(runner.options.watch),
|
||||
updatedAt,
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDevStackServiceSnapshot(runner, serviceName, updatedAt) {
|
||||
const service = runner.services.get(serviceName);
|
||||
const runtime = service?.runtime ?? {};
|
||||
const endpoint = resolveDevStackServiceEndpoint(runner, serviceName);
|
||||
const isReusedSpacetime =
|
||||
serviceName === 'spacetime' && runner.state.spacetimeReused;
|
||||
const runtimeStatus = runtime.status ?? 'idle';
|
||||
const status =
|
||||
runtimeStatus === 'idle' && isReusedSpacetime ? 'reused' : runtimeStatus;
|
||||
const childPid =
|
||||
service?.child && Number.isInteger(service.child.pid)
|
||||
? service.child.pid
|
||||
: null;
|
||||
|
||||
return {
|
||||
status,
|
||||
pid:
|
||||
childPid ??
|
||||
runtime.pid ??
|
||||
(isReusedSpacetime ? (runner.state.spacetimePid ?? null) : null),
|
||||
host: runtime.host ?? endpoint.host,
|
||||
port: runtime.port ?? endpoint.port,
|
||||
url: runtime.url ?? endpoint.url,
|
||||
command: runtime.command ?? resolveDevStackServiceCommand(runner, serviceName),
|
||||
startedAt: runtime.startedAt ?? null,
|
||||
updatedAt: runtime.updatedAt ?? updatedAt,
|
||||
exitCode: runtime.exitCode ?? null,
|
||||
signal: runtime.signal ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDevStackServiceEndpoint(runner, serviceName) {
|
||||
const {options, state} = runner;
|
||||
switch (serviceName) {
|
||||
case 'spacetime':
|
||||
return {
|
||||
host: options.spacetimeHost,
|
||||
port: options.spacetimePort,
|
||||
url: state.spacetimeServer,
|
||||
};
|
||||
case 'api-server':
|
||||
return {
|
||||
host: options.apiHost,
|
||||
port: options.apiPort,
|
||||
url: state.apiTarget,
|
||||
};
|
||||
case 'web':
|
||||
return {
|
||||
host: options.webHost,
|
||||
port: options.webPort,
|
||||
url: `http://${resolveClientHost(options.webHost)}:${options.webPort}`,
|
||||
};
|
||||
case 'admin-web':
|
||||
return {
|
||||
host: options.adminWebHost,
|
||||
port: options.adminWebPort,
|
||||
url: `http://${state.adminWebTargetHost}:${options.adminWebPort}/admin/`,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
host: null,
|
||||
port: null,
|
||||
url: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDevStackServiceCommand(runner, serviceName) {
|
||||
const {options, state} = runner;
|
||||
switch (serviceName) {
|
||||
case 'spacetime':
|
||||
return state.spacetimeReused
|
||||
? `reuse spacetime standalone ${state.spacetimeServer}`
|
||||
: [
|
||||
'spacetime',
|
||||
'start',
|
||||
'--data-dir',
|
||||
options.spacetimeDataDir,
|
||||
'--listen-addr',
|
||||
`${options.spacetimeHost}:${options.spacetimePort}`,
|
||||
'--non-interactive',
|
||||
].join(' ');
|
||||
case 'api-server':
|
||||
return 'cargo run -p api-server --manifest-path server-rs/Cargo.toml';
|
||||
case 'web':
|
||||
return [
|
||||
'node',
|
||||
relative(repoRoot, viteCliPath),
|
||||
`--port=${options.webPort}`,
|
||||
`--host=${options.webHost}`,
|
||||
'--strictPort',
|
||||
].join(' ');
|
||||
case 'admin-web':
|
||||
return [
|
||||
'node',
|
||||
relative(adminWebDir, viteCliPath),
|
||||
`--host=${options.adminWebHost}`,
|
||||
`--port=${options.adminWebPort}`,
|
||||
'--strictPort',
|
||||
].join(' ');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function requireCommand(command) {
|
||||
const result = spawnSync(command, ['--version'], {
|
||||
cwd: repoRoot,
|
||||
@@ -375,14 +516,37 @@ function ensureRequiredFiles(command) {
|
||||
}
|
||||
|
||||
class DevService {
|
||||
constructor(name, startFn) {
|
||||
constructor(name, startFn, onStateChange = null) {
|
||||
this.name = name;
|
||||
this.startFn = startFn;
|
||||
this.onStateChange = onStateChange;
|
||||
this.child = null;
|
||||
this.children = [];
|
||||
this.logStream = null;
|
||||
this.stopping = false;
|
||||
this.restartTimer = null;
|
||||
this.runtime = {
|
||||
status: 'idle',
|
||||
pid: null,
|
||||
host: null,
|
||||
port: null,
|
||||
url: null,
|
||||
command: null,
|
||||
startedAt: null,
|
||||
updatedAt: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
};
|
||||
}
|
||||
|
||||
updateRuntimeState(patch) {
|
||||
const updatedAt = new Date().toISOString();
|
||||
this.runtime = {
|
||||
...this.runtime,
|
||||
...patch,
|
||||
updatedAt,
|
||||
};
|
||||
this.onStateChange?.();
|
||||
}
|
||||
|
||||
async start() {
|
||||
@@ -390,17 +554,36 @@ class DevService {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startFn(this);
|
||||
this.updateRuntimeState({status: 'starting', exitCode: null, signal: null});
|
||||
try {
|
||||
await this.startFn(this);
|
||||
} catch (error) {
|
||||
this.updateRuntimeState({status: 'failed', pid: null});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
registerChild(child) {
|
||||
this.child = child;
|
||||
this.updateRuntimeState({
|
||||
status: 'running',
|
||||
pid: Number.isInteger(child.pid) ? child.pid : null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
startedAt: this.runtime.startedAt ?? new Date().toISOString(),
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (this.logStream && !this.logStream.destroyed) {
|
||||
this.logStream.end();
|
||||
}
|
||||
|
||||
this.child = null;
|
||||
this.updateRuntimeState({
|
||||
status: this.stopping ? 'stopped' : code === 0 ? 'stopped' : 'failed',
|
||||
pid: null,
|
||||
exitCode: code ?? null,
|
||||
signal: signal ?? null,
|
||||
});
|
||||
if (this.stopping) {
|
||||
this.stopping = false;
|
||||
return;
|
||||
@@ -419,6 +602,9 @@ class DevService {
|
||||
|
||||
const processes = [this.child, ...this.children].filter(Boolean);
|
||||
this.stopping = processes.length > 0;
|
||||
if (processes.length > 0) {
|
||||
this.updateRuntimeState({status: 'stopping'});
|
||||
}
|
||||
this.child = null;
|
||||
this.children = [];
|
||||
|
||||
@@ -431,6 +617,9 @@ class DevService {
|
||||
}
|
||||
this.logStream = null;
|
||||
this.stopping = false;
|
||||
if (processes.length > 0) {
|
||||
this.updateRuntimeState({status: 'stopped', pid: null});
|
||||
}
|
||||
}
|
||||
|
||||
scheduleRestart(delayMs = 250, restartFn = null, actionLabel = '重启') {
|
||||
@@ -550,6 +739,8 @@ class DevRunner {
|
||||
adminWebTargetHost: resolveClientHost(options.adminWebHost),
|
||||
spacetimeServer: initialSpacetimeServer,
|
||||
apiTarget: `http://${resolveClientHost(options.apiHost)}:${options.apiPort}`,
|
||||
portRange: null,
|
||||
portRangeReservation: null,
|
||||
};
|
||||
this.services = new Map();
|
||||
this.watchers = [];
|
||||
@@ -573,10 +764,56 @@ class DevRunner {
|
||||
ensureSpacetimeToolVersionMatchesWorkspace();
|
||||
}
|
||||
|
||||
await this.prepareLinuxPortRange(command);
|
||||
await this.tryReuseExistingSpacetime(command);
|
||||
await this.resolvePorts(command);
|
||||
this.registerServices();
|
||||
this.printSummary(command);
|
||||
this.writeDevStackState();
|
||||
}
|
||||
|
||||
async prepareLinuxPortRange(command) {
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedRange = String(this.options.portRangeSpec ?? '').trim();
|
||||
const allocation = await reserveLinuxDevPortRange({
|
||||
env: {
|
||||
...this.baseEnv,
|
||||
GENARRATIVE_DEV_PORT_RANGE: requestedRange,
|
||||
},
|
||||
username: getLinuxDevPortRangeUsername(this.baseEnv),
|
||||
});
|
||||
|
||||
if (!allocation) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.portRangeReservation = allocation;
|
||||
this.state.portRange = allocation.range;
|
||||
this.baseEnv.GENARRATIVE_DEV_PORT_RANGE = allocation.range.label;
|
||||
|
||||
const mappedPorts = mapDevPortsToPortRange(allocation.range);
|
||||
if (!mappedPorts) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.explicitOptions.has('webPort')) {
|
||||
this.options.webPort = mappedPorts.webPort;
|
||||
}
|
||||
if (!this.explicitOptions.has('apiPort')) {
|
||||
this.options.apiPort = mappedPorts.apiPort;
|
||||
}
|
||||
if (!this.explicitOptions.has('spacetimePort')) {
|
||||
this.options.spacetimePort = mappedPorts.spacetimePort;
|
||||
}
|
||||
if (!this.explicitOptions.has('adminWebPort')) {
|
||||
this.options.adminWebPort = mappedPorts.adminWebPort;
|
||||
}
|
||||
|
||||
this.state.spacetimeServer = `http://${this.options.spacetimeHost}:${this.options.spacetimePort}`;
|
||||
this.state.apiTarget = `http://${this.state.apiTargetHost}:${this.options.apiPort}`;
|
||||
}
|
||||
|
||||
shouldValidateSpacetimeToolVersion(command) {
|
||||
@@ -627,6 +864,10 @@ class DevRunner {
|
||||
]);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!this.isCandidateSpacetimeWithinAssignedRange(candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pingUrl = buildUrl(candidate, '/v1/ping');
|
||||
if (!pingUrl || !(await isHttpReady(pingUrl))) {
|
||||
continue;
|
||||
@@ -643,6 +884,9 @@ class DevRunner {
|
||||
}
|
||||
this.state.spacetimeServer = candidate;
|
||||
this.state.spacetimeReused = true;
|
||||
this.state.spacetimePid = Number.isInteger(pidState.pid)
|
||||
? pidState.pid
|
||||
: null;
|
||||
const pidLabel = Number.isInteger(pidState.pid) ? ` pid=${pidState.pid}` : '';
|
||||
console.log(`[dev:spacetime] 复用已启动实例${pidLabel}: ${candidate}`);
|
||||
return;
|
||||
@@ -654,6 +898,21 @@ class DevRunner {
|
||||
);
|
||||
}
|
||||
|
||||
isCandidateSpacetimeWithinAssignedRange(candidateUrl) {
|
||||
const range = this.state.portRange;
|
||||
if (!range) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(candidateUrl);
|
||||
const port = Number(url.port);
|
||||
return Number.isInteger(port) && port >= range.start && port <= range.end;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async resolvePorts(command) {
|
||||
const {options} = this;
|
||||
const portConfig = {};
|
||||
@@ -663,6 +922,7 @@ class DevRunner {
|
||||
portConfig.spacetime = {
|
||||
host: options.spacetimeHost,
|
||||
preferredPort: options.spacetimePort,
|
||||
portRange: this.state.portRange,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -671,6 +931,7 @@ class DevRunner {
|
||||
portConfig.api = {
|
||||
host: options.apiHost,
|
||||
preferredPort: options.apiPort,
|
||||
portRange: this.state.portRange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -678,6 +939,7 @@ class DevRunner {
|
||||
portConfig.web = {
|
||||
host: options.webHost,
|
||||
preferredPort: options.webPort,
|
||||
portRange: this.state.portRange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -685,6 +947,7 @@ class DevRunner {
|
||||
portConfig.adminWeb = {
|
||||
host: options.adminWebHost,
|
||||
preferredPort: options.adminWebPort,
|
||||
portRange: this.state.portRange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -728,19 +991,48 @@ class DevRunner {
|
||||
}
|
||||
|
||||
registerServices() {
|
||||
const onStateChange = () => this.writeDevStackState();
|
||||
this.services.set(
|
||||
'spacetime',
|
||||
new DevService('spacetime', async (service) => this.startSpacetime(service)),
|
||||
new DevService(
|
||||
'spacetime',
|
||||
async (service) => this.startSpacetime(service),
|
||||
onStateChange,
|
||||
),
|
||||
);
|
||||
this.services.set(
|
||||
'api-server',
|
||||
new DevService('api-server', async (service) => this.startApiServer(service)),
|
||||
new DevService(
|
||||
'api-server',
|
||||
async (service) => this.startApiServer(service),
|
||||
onStateChange,
|
||||
),
|
||||
);
|
||||
this.services.set(
|
||||
'web',
|
||||
new DevService('web', async (service) => this.startWeb(service), onStateChange),
|
||||
);
|
||||
this.services.set('web', new DevService('web', async (service) => this.startWeb(service)));
|
||||
this.services.set(
|
||||
'admin-web',
|
||||
new DevService('admin-web', async (service) => this.startAdminWeb(service)),
|
||||
new DevService(
|
||||
'admin-web',
|
||||
async (service) => this.startAdminWeb(service),
|
||||
onStateChange,
|
||||
),
|
||||
);
|
||||
|
||||
if (this.state.spacetimeReused) {
|
||||
const spacetimeService = this.services.get('spacetime');
|
||||
const endpoint = resolveDevStackServiceEndpoint(this, 'spacetime');
|
||||
spacetimeService?.updateRuntimeState({
|
||||
status: 'reused',
|
||||
pid: this.state.spacetimePid ?? null,
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
url: endpoint.url,
|
||||
command: resolveDevStackServiceCommand(this, 'spacetime'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
printSummary(command) {
|
||||
@@ -748,6 +1040,15 @@ class DevRunner {
|
||||
console.log(`[dev] repo: ${repoRoot}`);
|
||||
console.log(`[dev] command: ${command}`);
|
||||
console.log(`[dev] watch: ${options.watch ? 'on' : 'off'}`);
|
||||
if (state.portRange) {
|
||||
const owner =
|
||||
state.portRangeReservation?.username ||
|
||||
getLinuxDevPortRangeUsername(this.baseEnv);
|
||||
console.log(`[dev] port-range: ${state.portRange.label} (${owner})`);
|
||||
console.log(
|
||||
`[dev] port-range-registry: ${getLinuxDevPortRangeRegistryPaths(this.baseEnv).registryPath}`,
|
||||
);
|
||||
}
|
||||
console.log(`[dev] web: http://127.0.0.1:${options.webPort}`);
|
||||
console.log(`[dev] admin web: http://${state.adminWebTargetHost}:${options.adminWebPort}/admin/`);
|
||||
console.log(`[dev] api-server: ${state.apiTarget}`);
|
||||
@@ -755,6 +1056,16 @@ class DevRunner {
|
||||
console.log(`[dev] database: ${options.database}`);
|
||||
}
|
||||
|
||||
writeDevStackState() {
|
||||
try {
|
||||
ensureParentDir(devStackStatePath);
|
||||
const snapshot = buildDevStackSnapshot(this);
|
||||
writeFileSync(devStackStatePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
|
||||
} catch (error) {
|
||||
console.warn(`[dev] 写入 ${devStackStatePath} 失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async startCommand(command) {
|
||||
if (command === 'all') {
|
||||
await this.startSpacetimeForFullStack();
|
||||
@@ -828,6 +1139,17 @@ class DevRunner {
|
||||
);
|
||||
|
||||
console.log(`[dev:spacetime] log: ${logFile}`);
|
||||
service.updateRuntimeState({
|
||||
status: 'starting',
|
||||
pid: null,
|
||||
host: options.spacetimeHost,
|
||||
port: options.spacetimePort,
|
||||
url: this.state.spacetimeServer,
|
||||
command: resolveDevStackServiceCommand(this, 'spacetime'),
|
||||
startedAt: new Date().toISOString(),
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
});
|
||||
const env = {
|
||||
...this.baseEnv,
|
||||
};
|
||||
@@ -881,6 +1203,11 @@ class DevRunner {
|
||||
this.options.spacetimePort = port;
|
||||
this.state.spacetimeServer = `http://${this.options.spacetimeHost}:${port}`;
|
||||
recordSpacetimeUrl(this.options.spacetimeDataDir, this.state.spacetimeServer);
|
||||
this.services.get('spacetime')?.updateRuntimeState({
|
||||
host: this.options.spacetimeHost,
|
||||
port,
|
||||
url: this.state.spacetimeServer,
|
||||
});
|
||||
console.log(`[dev:spacetime] actual: ${this.state.spacetimeServer}`);
|
||||
}
|
||||
|
||||
@@ -977,6 +1304,17 @@ class DevRunner {
|
||||
console.log(
|
||||
`[dev:api-server] SpacetimeDB ${this.options.database} @ ${this.state.spacetimeServer}`,
|
||||
);
|
||||
service.updateRuntimeState({
|
||||
status: 'starting',
|
||||
pid: null,
|
||||
host: this.options.apiHost,
|
||||
port: this.options.apiPort,
|
||||
url: this.state.apiTarget,
|
||||
command: resolveDevStackServiceCommand(this, 'api-server'),
|
||||
startedAt: new Date().toISOString(),
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
});
|
||||
|
||||
const child = spawn(
|
||||
'cargo',
|
||||
@@ -1019,6 +1357,7 @@ class DevRunner {
|
||||
|
||||
startWeb(service) {
|
||||
const apiTarget = this.resolveFrontendApiTarget();
|
||||
const endpoint = resolveDevStackServiceEndpoint(this, 'web');
|
||||
const env = {
|
||||
...this.baseEnv,
|
||||
RUST_SERVER_TARGET: apiTarget,
|
||||
@@ -1029,6 +1368,17 @@ class DevRunner {
|
||||
};
|
||||
|
||||
console.log(`[dev:web] api target: ${apiTarget}`);
|
||||
service.updateRuntimeState({
|
||||
status: 'starting',
|
||||
pid: null,
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
url: endpoint.url,
|
||||
command: resolveDevStackServiceCommand(this, 'web'),
|
||||
startedAt: new Date().toISOString(),
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
});
|
||||
const child = spawn(
|
||||
'node',
|
||||
[
|
||||
@@ -1054,6 +1404,7 @@ class DevRunner {
|
||||
|
||||
startAdminWeb(service) {
|
||||
const apiTarget = this.resolveFrontendApiTarget({admin: true});
|
||||
const endpoint = resolveDevStackServiceEndpoint(this, 'admin-web');
|
||||
const env = {
|
||||
...this.baseEnv,
|
||||
ADMIN_API_TARGET: apiTarget,
|
||||
@@ -1062,6 +1413,17 @@ class DevRunner {
|
||||
};
|
||||
|
||||
console.log(`[dev:admin-web] api target: ${apiTarget}`);
|
||||
service.updateRuntimeState({
|
||||
status: 'starting',
|
||||
pid: null,
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
url: endpoint.url,
|
||||
command: resolveDevStackServiceCommand(this, 'admin-web'),
|
||||
startedAt: new Date().toISOString(),
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
});
|
||||
const child = spawn(
|
||||
'node',
|
||||
[
|
||||
@@ -1685,6 +2047,10 @@ function normalizePath(path) {
|
||||
return path.replace(/\\/gu, '/');
|
||||
}
|
||||
|
||||
function normalizeDirectExecutionPath(path) {
|
||||
return normalizePath(path).replace(/^\/([A-Za-z]:\/)/u, '$1');
|
||||
}
|
||||
|
||||
function safeRealpath(pathValue) {
|
||||
try {
|
||||
return realpathSync(pathValue);
|
||||
@@ -1699,9 +2065,15 @@ function isDirectModuleExecution(argv1, moduleUrl, resolvePath = safeRealpath) {
|
||||
}
|
||||
|
||||
try {
|
||||
return resolvePath(argv1) === resolvePath(fileURLToPath(moduleUrl));
|
||||
return (
|
||||
normalizeDirectExecutionPath(resolvePath(argv1)) ===
|
||||
normalizeDirectExecutionPath(resolvePath(fileURLToPath(moduleUrl)))
|
||||
);
|
||||
} catch {
|
||||
return resolve(argv1) === fileURLToPath(moduleUrl);
|
||||
return (
|
||||
normalizeDirectExecutionPath(resolve(argv1)) ===
|
||||
normalizeDirectExecutionPath(fileURLToPath(moduleUrl))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1752,6 +2124,7 @@ export {
|
||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||
assertSpacetimeToolVersionMatchesWorkspace,
|
||||
buildApiServerProcessEnv,
|
||||
buildDevStackSnapshot,
|
||||
buildSpacetimePublishArgs,
|
||||
createDevServerSpawnOptions,
|
||||
createWatchConfigs,
|
||||
@@ -1759,6 +2132,7 @@ export {
|
||||
isDirectModuleExecution,
|
||||
parseSpacetimeToolVersion,
|
||||
parseArgs,
|
||||
resolveDevStackStatePath,
|
||||
shouldAcceptWatchEvent,
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||
assertSpacetimeToolVersionMatchesWorkspace,
|
||||
buildApiServerProcessEnv,
|
||||
buildDevStackSnapshot,
|
||||
buildSpacetimePublishArgs,
|
||||
createDevServerSpawnOptions,
|
||||
createWatchConfigs,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
isSpacetimePublishPermissionError,
|
||||
parseSpacetimeToolVersion,
|
||||
parseArgs,
|
||||
resolveDevStackStatePath,
|
||||
shouldAcceptWatchEvent,
|
||||
} from './dev.mjs';
|
||||
|
||||
@@ -36,6 +38,8 @@ function workspaceSpacetimeVersionForTest() {
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -102,6 +106,67 @@ describe('dev scheduler argument routing', () => {
|
||||
'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.state.apiTarget).toBe('http://127.0.0.1:22001');
|
||||
expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:22002');
|
||||
} 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);
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler api-server env', () => {
|
||||
@@ -119,6 +184,79 @@ describe('dev scheduler api-server env', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
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',
|
||||
},
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev scheduler spacetime reuse guard', () => {
|
||||
test('记录 URL 可 ping 但没有 spacetime.pid 时不复用宿主', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-'));
|
||||
@@ -266,18 +404,18 @@ describe('dev scheduler watch routing', () => {
|
||||
describe('dev scheduler spacetime refresh', () => {
|
||||
test('解析 spacetime --version 输出里的 tool version', () => {
|
||||
const version = parseSpacetimeToolVersion(`
|
||||
A new version of SpacetimeDB is available: v2.2.0 (current: v2.1.0)
|
||||
spacetimedb tool version 2.2.0; spacetimedb-lib version 2.2.0;
|
||||
A new version of SpacetimeDB is available: v2.3.0 (current: v2.2.0)
|
||||
spacetimedb tool version 2.3.0; spacetimedb-lib version 2.3.0;
|
||||
`);
|
||||
|
||||
expect(version).toBe('2.2.0');
|
||||
expect(version).toBe('2.3.0');
|
||||
});
|
||||
|
||||
test('本机 spacetime 版本和 workspace 锁定版本不一致时直接报清楚', () => {
|
||||
expect(() =>
|
||||
assertSpacetimeToolVersionMatchesWorkspace({
|
||||
toolVersion: '2.1.0',
|
||||
workspaceVersion: '2.2.0',
|
||||
workspaceVersion: '2.3.0',
|
||||
}),
|
||||
).toThrow('procedure 返回值 BSATN 反序列化失败');
|
||||
});
|
||||
|
||||
37
scripts/jenkins-prepare-cargo-env.sh
Normal file → Executable file
37
scripts/jenkins-prepare-cargo-env.sh
Normal file → Executable file
@@ -8,6 +8,16 @@ set -euo pipefail
|
||||
|
||||
ORIGINAL_HOME="${HOME:-}"
|
||||
CARGO_BUILD_HOME="${CARGO_BUILD_HOME:-$(dirname "${CARGO_HOME}")/home}"
|
||||
USER_CARGO_CONFIG=""
|
||||
WORKSPACE_TMP_ROOT="${WORKSPACE_TMP:-}"
|
||||
if [[ -z "${WORKSPACE_TMP_ROOT}" && -n "${WORKSPACE:-}" ]]; then
|
||||
WORKSPACE_TMP_ROOT="${WORKSPACE}@tmp"
|
||||
fi
|
||||
if [[ -z "${WORKSPACE_TMP_ROOT}" ]]; then
|
||||
WORKSPACE_TMP_ROOT="${CARGO_BUILD_HOME}"
|
||||
fi
|
||||
SCCACHE_SERVER_UDS="${SCCACHE_SERVER_UDS:-${WORKSPACE_TMP_ROOT}/sccache.sock}"
|
||||
SCCACHE_IDLE_TIMEOUT="${SCCACHE_IDLE_TIMEOUT:-0}"
|
||||
|
||||
mkdir -p "${CARGO_HOME}" "${CARGO_TARGET_DIR}" "${SCCACHE_DIR}" "${CARGO_BUILD_HOME}"
|
||||
|
||||
@@ -25,20 +35,45 @@ for tool_dir in "${ORIGINAL_HOME}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/
|
||||
fi
|
||||
done
|
||||
|
||||
for candidate in \
|
||||
"${ORIGINAL_HOME}/.cargo/config.toml" \
|
||||
"${ORIGINAL_HOME}/.cargo/config" \
|
||||
"/data/jenkins/.cargo/config.toml" \
|
||||
"/data/jenkins/.cargo/config" \
|
||||
"/etc/cargo/config.toml" \
|
||||
"/etc/cargo/config"
|
||||
do
|
||||
if [[ -f "${candidate}" ]]; then
|
||||
USER_CARGO_CONFIG="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
export HOME="${CARGO_BUILD_HOME}"
|
||||
export CARGO_HOME
|
||||
export CARGO_TARGET_DIR
|
||||
export SCCACHE_DIR
|
||||
export SCCACHE_SERVER_UDS
|
||||
export SCCACHE_IDLE_TIMEOUT
|
||||
|
||||
cat >"${CARGO_HOME}/config.toml" <<'CARGO_CONFIG'
|
||||
if [[ -n "${USER_CARGO_CONFIG}" ]]; then
|
||||
install -m 0644 "${USER_CARGO_CONFIG}" "${CARGO_HOME}/config.toml"
|
||||
else
|
||||
cat >"${CARGO_HOME}/config.toml" <<'CARGO_CONFIG'
|
||||
[registries.crates-io]
|
||||
protocol = "sparse"
|
||||
CARGO_CONFIG
|
||||
fi
|
||||
|
||||
echo "[cargo-env] HOME=${HOME}"
|
||||
echo "[cargo-env] CARGO_HOME=${CARGO_HOME}"
|
||||
echo "[cargo-env] CARGO_TARGET_DIR=${CARGO_TARGET_DIR}"
|
||||
echo "[cargo-env] SCCACHE_DIR=${SCCACHE_DIR}"
|
||||
echo "[cargo-env] SCCACHE_SERVER_UDS=${SCCACHE_SERVER_UDS}"
|
||||
echo "[cargo-env] SCCACHE_IDLE_TIMEOUT=${SCCACHE_IDLE_TIMEOUT}"
|
||||
if [[ -n "${USER_CARGO_CONFIG}" ]]; then
|
||||
echo "[cargo-env] USER_CARGO_CONFIG=${USER_CARGO_CONFIG}"
|
||||
fi
|
||||
if [[ -n "${RUSTUP_HOME:-}" ]]; then
|
||||
echo "[cargo-env] RUSTUP_HOME=${RUSTUP_HOME}"
|
||||
fi
|
||||
|
||||
@@ -17,6 +17,8 @@ type MiniProgramPage = {
|
||||
data: Record<string, unknown>;
|
||||
setData: (patch: Record<string, unknown>) => void;
|
||||
onLoad: (query?: Record<string, string>) => Promise<void>;
|
||||
onShow: () => void;
|
||||
consumePayResult: () => void;
|
||||
};
|
||||
|
||||
function createWxMock() {
|
||||
@@ -44,6 +46,10 @@ function loadWebViewPage(
|
||||
Page(config: Record<string, unknown>) {
|
||||
pageConfig = config;
|
||||
},
|
||||
setTimeout(callback: () => void) {
|
||||
callback();
|
||||
return 1;
|
||||
},
|
||||
require(requestPath: string) {
|
||||
if (requestPath === '../../config') {
|
||||
return {
|
||||
@@ -85,22 +91,40 @@ describe('mini-program web-view auth page', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('默认进入时直接打开 web-view,不触发微信登录请求', async () => {
|
||||
test('默认进入时刷新微信小程序登录态后打开 web-view', async () => {
|
||||
const wxMock = createWxMock();
|
||||
wxMock.login.mockImplementation(({ success }) => {
|
||||
success({ code: 'wx-login-code' });
|
||||
});
|
||||
wxMock.request.mockImplementation(({ success }) => {
|
||||
success({
|
||||
statusCode: 200,
|
||||
data: {
|
||||
token: 'jwt-active-wechat',
|
||||
bindingStatus: 'active',
|
||||
},
|
||||
});
|
||||
});
|
||||
const page = loadWebViewPage(wxMock);
|
||||
|
||||
await page.onLoad({});
|
||||
|
||||
expect(wxMock.login).not.toHaveBeenCalled();
|
||||
expect(wxMock.request).not.toHaveBeenCalled();
|
||||
expect(wxMock.login).toHaveBeenCalledTimes(1);
|
||||
expect(wxMock.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://www.genarrative.world/api/auth/wechat/miniprogram-login',
|
||||
method: 'POST',
|
||||
data: { code: 'wx-login-code' },
|
||||
}),
|
||||
);
|
||||
expect(page.data.loading).toBe(false);
|
||||
expect(page.data.phoneBindingRequired).toBe(false);
|
||||
expect(page.data.webViewUrl).toBe(
|
||||
'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program',
|
||||
'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program#auth_provider=wechat&auth_token=jwt-active-wechat&auth_binding_status=active',
|
||||
);
|
||||
});
|
||||
|
||||
test('默认匿名进入 web-view 不依赖 API_BASE_URL 配置', async () => {
|
||||
test('默认匿名进入 web-view 仍不依赖 API_BASE_URL 配置', async () => {
|
||||
const wxMock = createWxMock();
|
||||
const page = loadWebViewPage(wxMock, {
|
||||
API_BASE_URL: '',
|
||||
@@ -116,6 +140,27 @@ describe('mini-program web-view auth page', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('onShow 二次检查支付结果并写回 web-view hash', () => {
|
||||
const wxMock = createWxMock();
|
||||
wxMock.getStorageSync.mockImplementation((key) =>
|
||||
key === 'genarrative:wechat-pay-result'
|
||||
? 'request-1:success:order-1'
|
||||
: '',
|
||||
);
|
||||
const page = loadWebViewPage(wxMock);
|
||||
page.data.webViewUrl =
|
||||
'https://www.genarrative.world/?clientType=mini_program#tab=profile';
|
||||
|
||||
page.onShow();
|
||||
|
||||
expect(wxMock.removeStorageSync).toHaveBeenCalledWith(
|
||||
'genarrative:wechat-pay-result',
|
||||
);
|
||||
expect(page.data.webViewUrl).toBe(
|
||||
'https://www.genarrative.world/?clientType=mini_program#tab=profile&wx_pay_result=request-1%3Asuccess%3Aorder-1',
|
||||
);
|
||||
});
|
||||
|
||||
test('H5 请求登录时才启动微信小程序登录并进入手机号授权态', async () => {
|
||||
const wxMock = createWxMock();
|
||||
wxMock.login.mockImplementation(({ success }) => {
|
||||
|
||||
Reference in New Issue
Block a user