import { execFileSync, spawn } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; const repoRoot = process.cwd(); const apiServerExePath = resolve( repoRoot, 'server-rs/target/debug/api-server.exe', ); const defaultHealthHost = '127.0.0.1'; const defaultHealthPort = '3100'; const healthTimeoutMs = Number(process.env.GENARRATIVE_API_SERVER_MAINCLOUD_SMOKE_TIMEOUT_SECONDS) * 1000 || 180_000; function loadEnvFile(path, target) { if (!existsSync(path)) { return; } const rawText = readFileSync(path, 'utf8'); for (const rawLine of rawText.split(/\r?\n/u)) { const line = rawLine.trim(); if (!line || line.startsWith('#')) { continue; } const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u); if (!match) { continue; } const [, key, rawValue] = match; if (target[key] !== undefined) { continue; } target[key] = rawValue.replace(/^['"]|['"]$/gu, ''); } } function stopExistingWindowsApiServer() { if (process.platform !== 'win32') { return; } // Windows 下 cargo 重编译不能覆盖正在运行的 exe,只清理本仓库 target 内的 api-server。 const command = [ '$ErrorActionPreference = "Continue"', '$target = [System.IO.Path]::GetFullPath($env:GENARRATIVE_API_SERVER_EXE_TARGET)', '$processes = Get-Process -Name api-server -ErrorAction SilentlyContinue | Where-Object {', ' $_.Path -and ([System.IO.Path]::GetFullPath($_.Path) -ieq $target)', '}', 'foreach ($process in $processes) {', ' try {', ' Stop-Process -Id $process.Id -Force -ErrorAction Stop', ' Wait-Process -Id $process.Id -Timeout 5 -ErrorAction SilentlyContinue', ' Write-Output $process.Id', ' } catch {', ' Write-Error "[api-server:maincloud] 忽略旧进程清理瞬时失败 pid=$($process.Id): $($_.Exception.Message)"', ' }', '}', 'exit 0', ].join('\n'); const output = execFileSync( 'powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], { encoding: 'utf8', env: { ...process.env, GENARRATIVE_API_SERVER_EXE_TARGET: apiServerExePath, }, }, ).trim(); if (output) { console.log(`[api-server:maincloud] 已停止旧 api-server 进程: ${output}`); } } function stopProcessTree(child) { if (!child || child.exitCode !== null || child.signalCode) { return; } if (process.platform === 'win32') { try { execFileSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', }); return; } catch { // taskkill 可能已经被进程自然退出抢先;继续走兜底清理。 } } child.kill('SIGTERM'); } async function waitForHealthz({ child, healthUrl }) { const deadline = Date.now() + healthTimeoutMs; let childExit = null; child.once('exit', (code, signal) => { childExit = { code, signal }; }); while (Date.now() < deadline) { if (childExit) { throw new Error( `api-server 在 healthz 就绪前退出:code=${childExit.code ?? ''} signal=${ childExit.signal ?? '' }`, ); } try { const response = await fetch(healthUrl, { signal: AbortSignal.timeout(1_000), }); const body = await response.text(); if (response.status === 200) { return body; } } catch { // 服务启动期间连接失败是预期状态,继续轮询。 } await delay(500); } throw new Error(`等待 /healthz 超时:${healthUrl}`); } const mergedEnv = { ...process.env }; loadEnvFile(resolve(repoRoot, '.env'), mergedEnv); loadEnvFile(resolve(repoRoot, '.env.local'), mergedEnv); loadEnvFile(resolve(repoRoot, '.env.secrets.local'), mergedEnv); mergedEnv.GENARRATIVE_API_HOST = mergedEnv.GENARRATIVE_API_HOST || defaultHealthHost; mergedEnv.GENARRATIVE_API_PORT = mergedEnv.GENARRATIVE_API_PORT || defaultHealthPort; mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL = mergedEnv.GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL || mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL || 'https://maincloud.spacetimedb.com'; mergedEnv.GENARRATIVE_SPACETIME_DATABASE = mergedEnv.GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE || mergedEnv.GENARRATIVE_SPACETIME_DATABASE || ''; mergedEnv.GENARRATIVE_SPACETIME_TOKEN = mergedEnv.GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN || mergedEnv.GENARRATIVE_SPACETIME_TOKEN || ''; if (!mergedEnv.GENARRATIVE_SPACETIME_DATABASE) { console.error( '[api-server:maincloud] 缺少 GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE 或 GENARRATIVE_SPACETIME_DATABASE。', ); process.exit(1); } try { stopExistingWindowsApiServer(); } catch (error) { console.error( `[api-server:maincloud] 清理旧 api-server 进程失败: ${error.message}`, ); process.exit(1); } console.log( `[api-server:maincloud] SpacetimeDB ${mergedEnv.GENARRATIVE_SPACETIME_DATABASE} @ ${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`, ); const child = spawn( 'cargo', ['run', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'], { cwd: repoRoot, env: mergedEnv, stdio: 'inherit', }, ); const cleanup = () => { stopProcessTree(child); try { stopExistingWindowsApiServer(); } catch { // 退出阶段只做 best-effort 清理,不能覆盖真实 smoke 结果。 } }; process.once('SIGINT', () => { cleanup(); process.exit(130); }); process.once('SIGTERM', () => { cleanup(); process.exit(143); }); try { const healthHost = mergedEnv.GENARRATIVE_API_HOST === '0.0.0.0' ? defaultHealthHost : mergedEnv.GENARRATIVE_API_HOST; const healthUrl = `http://${healthHost}:${mergedEnv.GENARRATIVE_API_PORT}/healthz`; const body = await waitForHealthz({ child, healthUrl }); console.log(`[api-server:maincloud] /healthz 通过:${body}`); cleanup(); } catch (error) { console.error(`[api-server:maincloud] smoke 失败:${error.message}`); cleanup(); process.exit(1); }