#!/usr/bin/env node import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, ".."); const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; const LIME_SKIP_STARTUP_WINDOW_REVEAL = "LIME_SKIP_STARTUP_WINDOW_REVEAL"; const LIME_DISABLE_SINGLE_INSTANCE = "LIME_DISABLE_SINGLE_INSTANCE"; const LIME_WEB_BRIDGE_REUSE_EXISTING_ONLY = "LIME_WEB_BRIDGE_REUSE_EXISTING_ONLY"; const LIME_WEB_BRIDGE_URL = "LIME_WEB_BRIDGE_URL"; const DEFAULT_HEALTH_URL = "http://127.0.0.1:3030/health"; const ROOT_MARKERS = ["Lime", '
']; const SHARED_TAURI_TARGET_DIR = path.join(rootDir, "src-tauri", "target"); const ISOLATED_GUI_SMOKE_TARGET_DIR = path.join( os.tmpdir(), "lime-gui-smoke-target", ); const GUI_SMOKE_TARGET_REBUILD_PREFIX = "rebuild"; const GUI_SMOKE_TEMP_CONFIG_BASENAME_PREFIX = "lime-gui-smoke-tauri-"; const GUI_SMOKE_COLD_TIMEOUT_MS = 1_800_000; const GUI_SMOKE_WARM_TIMEOUT_MS = 600_000; const GUI_SMOKE_BRIDGE_HEARTBEAT_MS = 30_000; const GUI_SMOKE_COMPILE_GRACE_MS = 900_000; const GUI_SMOKE_MAX_COMPILE_GRACE_EXTENSIONS = 2; const GUI_SMOKE_BOOT_GRACE_MS = 60_000; const GUI_SMOKE_CHILD_EXIT_GRACE_MS = 30_000; const INVOKE_TIMEOUT_CEILING_MS = 180_000; const INVOKE_RETRY_COUNT = 10; const INVOKE_RETRY_DELAY_MS = 1_000; const HEADLESS_TAURI_CONFIG_PATH = path.join( rootDir, "src-tauri", "tauri.conf.headless.json", ); const tauriCommand = process.platform === "win32" ? path.join(rootDir, "node_modules", ".bin", "tauri.cmd") : path.join(rootDir, "node_modules", ".bin", "tauri"); const state = { child: null, cleanedUp: false, tempConfigPath: null, }; function resolveTargetBinaryPath(targetDir) { const appBinaryName = process.platform === "win32" ? "lime.exe" : "lime"; return path.join(targetDir, "debug", appBinaryName); } function listTargetLockHolderCommands(targetDir) { if (process.platform === "win32") { return []; } const lockPath = path.join(targetDir, "debug", ".cargo-lock"); const pidOutput = runQuietCommand("lsof", ["-t", "--", lockPath]); if (!pidOutput) { return []; } const pids = [ ...new Set( pidOutput .split("\n") .map((item) => item.trim()) .filter(Boolean), ), ]; return pids .map((pid) => runQuietCommand("ps", ["-p", pid, "-o", "command="])) .filter(Boolean); } function resolvePreferredCargoTargetDir() { const sharedTargetLockHolders = listTargetLockHolderCommands( SHARED_TAURI_TARGET_DIR, ); if (sharedTargetLockHolders.length === 0) { return SHARED_TAURI_TARGET_DIR; } return ISOLATED_GUI_SMOKE_TARGET_DIR; } function resolveDefaultTimeoutMs(cargoTargetDir) { const binaryPath = resolveTargetBinaryPath(cargoTargetDir); return fs.existsSync(binaryPath) ? GUI_SMOKE_WARM_TIMEOUT_MS : GUI_SMOKE_COLD_TIMEOUT_MS; } function listSqliteBuildOutputDirs(targetDir) { const buildDir = path.join(targetDir, "debug", "build"); if (!fs.existsSync(buildDir)) { return []; } return fs .readdirSync(buildDir, { withFileTypes: true }) .filter( (entry) => entry.isDirectory() && entry.name.startsWith("libsqlite3-sys-"), ) .map((entry) => path.join(buildDir, entry.name, "out")) .filter((outDir) => fs.existsSync(outDir) && fs.statSync(outDir).isDirectory()) .sort(); } function listCorruptedSqliteBindingOutputs(targetDir) { return listSqliteBuildOutputDirs(targetDir) .filter((outDir) => !fs.existsSync(path.join(outDir, "bindgen.rs"))) .map((outDir) => ({ outDir, relativeOutDir: path.relative(targetDir, outDir) || outDir, })); } function allocateFreshCargoTargetDir(targetDir) { const parentDir = path.dirname(targetDir); const baseName = path.basename(targetDir); const suffix = `${GUI_SMOKE_TARGET_REBUILD_PREFIX}-${Date.now()}-${process.pid}`; return path.join(parentDir, `${baseName}-${suffix}`); } function normalizeCargoTargetDir(parsedOptions) { const normalizedOptions = { ...parsedOptions, cargoTargetDir: path.resolve(parsedOptions.cargoTargetDir), cargoTargetDirRequested: path.resolve(parsedOptions.cargoTargetDir), cargoTargetDirFallbackReason: "", corruptedSqliteBindingOutputs: [], }; const corruptedOutputs = listCorruptedSqliteBindingOutputs( normalizedOptions.cargoTargetDir, ); if (corruptedOutputs.length > 0) { normalizedOptions.cargoTargetDir = allocateFreshCargoTargetDir( normalizedOptions.cargoTargetDir, ); normalizedOptions.cargoTargetDirFallbackReason = "detected-corrupted-sqlite-bindings"; normalizedOptions.corruptedSqliteBindingOutputs = corruptedOutputs; } if (!normalizedOptions.timeoutExplicit) { normalizedOptions.timeoutMs = resolveDefaultTimeoutMs( normalizedOptions.cargoTargetDir, ); } return normalizedOptions; } const DEFAULTS = { appUrl: "http://127.0.0.1:1420/", healthUrl: DEFAULT_HEALTH_URL, invokeUrl: "http://127.0.0.1:3030/invoke", cargoTargetDir: resolvePreferredCargoTargetDir(), intervalMs: 1_000, reuseRunning: false, sampleProjectName: "Lime Smoke Workspace", }; DEFAULTS.timeoutMs = resolveDefaultTimeoutMs(DEFAULTS.cargoTargetDir); function resolveInvokeUrl(healthUrl) { try { const url = new URL(healthUrl); url.pathname = "/invoke"; url.search = ""; url.hash = ""; return url.toString(); } catch { return "http://127.0.0.1:3030/invoke"; } } function printHelp() { console.log(` Lime GUI 冒烟入口 用途: 启动或复用 headless Tauri 环境,串联 GUI 壳、DevBridge 与默认 workspace 的最小回归校验。 用法: npm run verify:gui-smoke npm run verify:gui-smoke -- --reuse-running npm run verify:gui-smoke -- --timeout-ms 600000 选项: --app-url 前端地址,默认 http://127.0.0.1:1420/ --health-url DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health --invoke-url DevBridge invoke 地址,默认随 health-url 推导 /invoke --timeout-ms 等待 headless / bridge / smoke 的超时,默认冷启动 1800000 / 热启动 600000 --interval-ms 轮询间隔,默认 1000 --sample-project-name workspace 路径校验使用的示例项目名 --cargo-target-dir 指定 Cargo target 目录;默认优先复用 src-tauri/target,无锁时共享,否则回退独立 GUI smoke target --reuse-running 复用已启动的 headless Tauri,不主动拉起 -h, --help 显示帮助 `); } function parseArgs(argv) { const options = { ...DEFAULTS, timeoutExplicit: false }; let invokeUrlExplicit = false; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--app-url" && argv[index + 1]) { options.appUrl = String(argv[index + 1]).trim(); index += 1; continue; } if (arg === "--health-url" && argv[index + 1]) { options.healthUrl = String(argv[index + 1]).trim(); index += 1; continue; } if (arg === "--invoke-url" && argv[index + 1]) { options.invokeUrl = String(argv[index + 1]).trim(); invokeUrlExplicit = true; index += 1; continue; } if (arg === "--timeout-ms" && argv[index + 1]) { options.timeoutMs = Number(argv[index + 1]); options.timeoutExplicit = true; index += 1; continue; } if (arg === "--interval-ms" && argv[index + 1]) { options.intervalMs = Number(argv[index + 1]); index += 1; continue; } if (arg === "--sample-project-name" && argv[index + 1]) { options.sampleProjectName = String(argv[index + 1]).trim(); index += 1; continue; } if (arg === "--cargo-target-dir" && argv[index + 1]) { options.cargoTargetDir = String(argv[index + 1]).trim(); index += 1; continue; } if (arg === "--reuse-running") { options.reuseRunning = true; continue; } if (arg === "--help" || arg === "-h") { printHelp(); process.exit(0); } } if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1_000) { throw new Error("--timeout-ms 必须是 >= 1000 的数字"); } if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) { throw new Error("--interval-ms 必须是 >= 100 的数字"); } if (!options.appUrl) { throw new Error("--app-url 不能为空"); } if (!options.healthUrl) { throw new Error("--health-url 不能为空"); } if (!invokeUrlExplicit) { options.invokeUrl = resolveInvokeUrl(options.healthUrl); } if (!options.invokeUrl) { throw new Error("--invoke-url 不能为空"); } if (!options.sampleProjectName) { throw new Error("--sample-project-name 不能为空"); } if (!options.cargoTargetDir) { throw new Error("--cargo-target-dir 不能为空"); } return options; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function isTransientInvokeError(error) { return ( error?.name === "TimeoutError" || (error instanceof TypeError && error.message === "fetch failed") ); } async function invokeBridgeCommand(options, cmd, args) { const invokeTimeoutMs = Math.min(options.timeoutMs, INVOKE_TIMEOUT_CEILING_MS); const requestInit = { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify({ cmd, args }), signal: AbortSignal.timeout(invokeTimeoutMs), }; for (let attempt = 1; attempt <= INVOKE_RETRY_COUNT; attempt += 1) { try { const response = await fetch(options.invokeUrl, requestInit); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const payload = await response.json(); if (payload?.error) { throw new Error(String(payload.error)); } return payload?.result; } catch (error) { const detail = error instanceof Error ? error.message : String(error); if (!isTransientInvokeError(error) || attempt >= INVOKE_RETRY_COUNT) { if (error?.name === "TimeoutError") { throw new Error( `[verify:gui-smoke] ${cmd} 超时,${invokeTimeoutMs}ms 内未收到 DevBridge 响应`, ); } throw new Error(`[verify:gui-smoke] ${cmd} 请求失败: ${detail}`); } console.warn( `[verify:gui-smoke] ${cmd} 第 ${attempt} 次请求失败,${INVOKE_RETRY_DELAY_MS}ms 后重试: ${detail}`, ); await sleep(INVOKE_RETRY_DELAY_MS); } } throw new Error(`[verify:gui-smoke] ${cmd} 请求失败: unknown error`); } function assert(condition, message) { if (!condition) { throw new Error(message); } } function isLimeDevShell(html) { return ROOT_MARKERS.some((marker) => html.includes(marker)); } function formatCommand(command, args) { return [command, ...args].join(" "); } function trimTrailingSlash(value) { return value.endsWith("/") ? value.slice(0, -1) : value; } function buildNoopBeforeDevCommand() { return `"${process.execPath}" -e "process.exit(0)"`; } function runCommand(command, args, label, timeoutMs) { console.log(`\n[verify:gui-smoke] > ${formatCommand(command, args)}`); const result = spawnSync(command, args, { cwd: rootDir, stdio: "inherit", env: process.env, timeout: timeoutMs, }); if (result.error) { if (result.error.code === "ETIMEDOUT") { const error = new Error( `[verify:gui-smoke] ${label} 超时(>${timeoutMs}ms)`, ); error.exitCode = 124; throw error; } throw result.error; } if (typeof result.status === "number" && result.status !== 0) { const error = new Error(`[verify:gui-smoke] ${label} 失败`); error.exitCode = result.status; throw error; } } function createHeadlessTauriConfig(options, startupMode) { const rawConfig = fs.readFileSync(HEADLESS_TAURI_CONFIG_PATH, "utf8"); const config = JSON.parse(rawConfig); config.build = { ...(config.build || {}), devUrl: trimTrailingSlash(options.appUrl), }; if (startupMode.reuseExistingAppShell) { config.build.beforeDevCommand = buildNoopBeforeDevCommand(); } const tempConfigPath = path.join( os.tmpdir(), `lime-gui-smoke-tauri-${process.pid}.json`, ); fs.writeFileSync(tempConfigPath, JSON.stringify(config, null, 2)); state.tempConfigPath = tempConfigPath; return tempConfigPath; } function resolveUrlPort(url) { try { return new URL(url).port || (url.startsWith("https:") ? "443" : "80"); } catch { return ""; } } function runQuietCommand(command, args) { try { return spawnSync(command, args, { cwd: rootDir, stdio: ["ignore", "pipe", "pipe"], env: process.env, encoding: "utf8", }).stdout.trim(); } catch { return ""; } } function listProcessTable() { if (process.platform === "win32") { return []; } const output = runQuietCommand("ps", ["-axo", "pid=,ppid=,pgid=,command="]); if (!output) { return []; } return output .split("\n") .map((line) => line.trim()) .filter(Boolean) .map((line) => { const match = line.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/); if (!match) { return null; } return { pid: Number(match[1]), ppid: Number(match[2]), pgid: Number(match[3]), command: match[4], }; }) .filter(Boolean); } function listProcessStats() { if (process.platform === "win32") { return []; } const output = runQuietCommand("ps", [ "-axo", "pid=,ppid=,pgid=,%cpu=,%mem=,etime=,stat=,command=", ]); if (!output) { return []; } return output .split("\n") .map((line) => line.trim()) .filter(Boolean) .map((line) => { const match = line.match( /^(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+(\S+)\s+(.*)$/, ); if (!match) { return null; } return { pid: Number(match[1]), ppid: Number(match[2]), pgid: Number(match[3]), cpu: Number(match[4]), mem: Number(match[5]), etime: match[6], stat: match[7], command: match[8], }; }) .filter(Boolean); } function isGuiSmokeVerifierCommand(command) { return ( command.includes("scripts/verify-gui-smoke.mjs") || command.includes("npm run verify:gui-smoke") ); } function isGuiSmokeTauriCommand(command) { return ( command.includes("tauri dev") && command.includes(GUI_SMOKE_TEMP_CONFIG_BASENAME_PREFIX) ); } function summarizeGuiSmokeProcessCommand(command) { if (command.includes("/bin/rustc")) { return "rustc"; } if (command.includes("cargo run --no-default-features")) { return "cargo"; } if (command.includes("tauri dev")) { return "tauri"; } if (command.includes("start-web-bridge-dev.mjs")) { return "web-bridge"; } if (command.includes("npm run dev:web-bridge")) { return "npm:dev:web-bridge"; } if (command.includes("npm exec vite") || command.includes("/bin/vite")) { return "vite"; } return command.split(/\s+/)[0]?.split("/").pop() || "process"; } function resolveGuiSmokeProcessGroupId(startedByScript) { if (startedByScript && typeof state.child?.pid === "number") { return state.child.pid; } const snapshot = inspectGuiSmokeTauriProcesses(); const firstActive = snapshot.active[0]; return firstActive ? firstActive.pgid || firstActive.pid : null; } function listGuiSmokeGroupProcesses(startedByScript) { const targetGroupId = resolveGuiSmokeProcessGroupId(startedByScript); if (!Number.isInteger(targetGroupId) || targetGroupId < 1) { return []; } return listProcessStats().filter((item) => item.pgid === targetGroupId); } function isZombieProcess(item) { return typeof item?.stat === "string" && item.stat.includes("Z"); } function listActiveGuiSmokeGroupProcesses(startedByScript) { return listGuiSmokeGroupProcesses(startedByScript).filter( (item) => !isZombieProcess(item), ); } function hasActiveGuiSmokeProcesses(startedByScript) { return listActiveGuiSmokeGroupProcesses(startedByScript).length > 0; } function hasActiveGuiSmokeCompile(startedByScript) { return listActiveGuiSmokeGroupProcesses(startedByScript).some( (item) => item.command.includes("/bin/rustc") || item.command.includes("cargo run --no-default-features"), ); } function describeGuiSmokeHeartbeat(startedByScript) { const interestingProcesses = listActiveGuiSmokeGroupProcesses(startedByScript) .filter( (item) => item.command.includes("tauri dev") || item.command.includes("cargo run --no-default-features") || item.command.includes("/bin/rustc") || item.command.includes("start-web-bridge-dev.mjs") || item.command.includes("npm run dev:web-bridge") || item.command.includes("npm exec vite") || item.command.includes("/bin/vite"), ) .sort((left, right) => right.cpu - left.cpu || left.pid - right.pid) .slice(0, 5); if (interestingProcesses.length === 0) { return ""; } return interestingProcesses .map((item) => { const cpu = Number.isFinite(item.cpu) ? item.cpu.toFixed(1) : "0.0"; return `${summarizeGuiSmokeProcessCommand(item.command)} pid=${item.pid} etime=${item.etime} cpu=${cpu}% stat=${item.stat}`; }) .join(" | "); } function inspectGuiSmokeTauriProcesses() { const processTable = listProcessTable(); const processByPid = new Map(processTable.map((item) => [item.pid, item])); const active = []; const stale = []; for (const item of processTable) { if (!isGuiSmokeTauriCommand(item.command)) { continue; } if (item.pid === process.pid) { continue; } const parent = processByPid.get(item.ppid); if (!parent || !isGuiSmokeVerifierCommand(parent.command)) { stale.push(item); continue; } active.push(item); } return { active, stale }; } function isProcessAlive(pid) { if (!Number.isInteger(pid) || pid < 1) { return false; } try { process.kill(pid, 0); return true; } catch { return false; } } function signalProcessTree(pid, signal) { if (!Number.isInteger(pid) || pid < 1) { return; } if (process.platform === "win32") { if (signal === "SIGKILL") { spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", }); return; } spawnSync("taskkill", ["/pid", String(pid), "/T"], { stdio: "ignore", }); return; } const target = `-${pid}`; spawnSync("kill", [`-${signal}`, target], { stdio: "ignore", }); } async function stopProcessTree(pid, label) { if (!Number.isInteger(pid) || pid < 1) { return; } console.log(`[verify:gui-smoke] 清理残留 ${label} 进程组: ${pid}`); signalProcessTree(pid, "SIGTERM"); for (let attempt = 0; attempt < 25; attempt += 1) { if (!isProcessAlive(pid)) { return; } await sleep(200); } signalProcessTree(pid, "SIGKILL"); } async function cleanupStaleGuiSmokeProcesses() { const snapshot = inspectGuiSmokeTauriProcesses(); if (snapshot.stale.length === 0) { return 0; } for (const item of snapshot.stale) { const groupPid = item.pgid || item.pid; await stopProcessTree(groupPid, `GUI smoke Tauri(PID=${item.pid})`); } return snapshot.stale.length; } async function cleanupStaleGuiSmokeChromeProfiles( options, { label, required, skipIfBridgeUnavailable = false }, ) { if ( skipIfBridgeUnavailable && !(await isUrlReady(options.healthUrl, Math.min(options.intervalMs, 1_500))) ) { console.warn(`[verify:gui-smoke] ${label}: DevBridge 未就绪,跳过。`); return null; } try { const result = await invokeBridgeCommand( options, "cleanup_gui_smoke_chrome_profiles", ); const matchedProfiles = Array.isArray(result?.matched_profiles) ? result.matched_profiles : []; const removedProfiles = Array.isArray(result?.removed_profiles) ? result.removed_profiles : []; const skippedProfiles = Array.isArray(result?.skipped_profiles) ? result.skipped_profiles : []; const terminatedProcessCount = Number.isFinite( result?.terminated_process_count, ) ? Number(result.terminated_process_count) : 0; if (matchedProfiles.length === 0) { return result; } console.log( `[verify:gui-smoke] ${label}: 匹配 ${matchedProfiles.length} 个 smoke Chrome profiles,删除 ${removedProfiles.length} 个目录,结束 ${terminatedProcessCount} 个残留进程。`, ); if (skippedProfiles.length > 0) { console.warn( `[verify:gui-smoke] ${label}: 仍有 ${skippedProfiles.length} 个 profile 未删掉:${skippedProfiles.join(", ")}`, ); } return result; } catch (error) { const detail = error instanceof Error ? error.message : String(error); if (required) { throw error; } console.warn(`[verify:gui-smoke] ${label}: ${detail}`); return null; } } function listListeningCommandsForPort(port) { if (!port || process.platform === "win32") { return []; } const pidOutput = runQuietCommand("lsof", [ "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t", ]); if (!pidOutput) { return []; } const pids = [ ...new Set( pidOutput .split("\n") .map((item) => item.trim()) .filter(Boolean), ), ]; return pids .map((pid) => runQuietCommand("ps", ["-p", pid, "-o", "command="])) .filter(Boolean); } function isLikelyLimeFrontendListener(command) { return ( command.includes(rootDir) && (command.includes("vite") || command.includes("npm run dev") || command.includes("tauri dev")) ); } function startHeadlessTauri(options, startupMode) { console.log("[verify:gui-smoke] 启动 headless Tauri 环境..."); runCommand( npmCommand, ["run", "generate:agent-runtime-clients"], "generate:agent-runtime-clients", options.timeoutMs, ); runCommand( npmCommand, ["run", "generate:extension-site-adapters"], "generate:extension-site-adapters", options.timeoutMs, ); const tauriConfigPath = createHeadlessTauriConfig(options, startupMode); state.child = spawn( tauriCommand, ["dev", "--no-watch", "--config", tauriConfigPath], { cwd: rootDir, stdio: "inherit", env: { ...process.env, CARGO_TARGET_DIR: options.cargoTargetDir, [LIME_SKIP_STARTUP_WINDOW_REVEAL]: "1", [LIME_DISABLE_SINGLE_INSTANCE]: "1", [LIME_WEB_BRIDGE_URL]: options.appUrl, ...(startupMode.reuseExistingAppShell ? { [LIME_WEB_BRIDGE_REUSE_EXISTING_ONLY]: "1", } : {}), }, detached: process.platform !== "win32", }, ); } async function stopHeadlessTauri() { const child = state.child; if (state.cleanedUp) { return; } state.cleanedUp = true; if (child) { console.log("[verify:gui-smoke] 停止 headless Tauri 环境..."); } if (typeof child?.pid === "number") { if (process.platform === "win32") { spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", }); } else { try { process.kill(-child.pid, "SIGTERM"); } catch { try { child.kill("SIGTERM"); } catch { // ignore } } for (let attempt = 0; attempt < 25; attempt += 1) { if (child.exitCode !== null || child.signalCode) { break; } await sleep(200); } try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { // ignore } } } } try { if (state.tempConfigPath) { fs.unlinkSync(state.tempConfigPath); } } catch { // ignore } state.tempConfigPath = null; } async function waitForAppShell(options) { const startedAt = Date.now(); let lastError = null; while (Date.now() - startedAt < options.timeoutMs) { try { const response = await fetch(options.appUrl, { method: "GET" }); const html = await response.text(); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } assert( isLimeDevShell(html) || html.includes('
0 ? parts.join(" ") : "unknown"; } async function waitForBridgeHealth(options, startedByScript) { const startedAt = Date.now(); let deadlineAt = startedAt + options.timeoutMs; let compileGraceCount = 0; let bootGraceUsed = false; let childExitObservedAt = null; let lastExitedChildLogAt = 0; let lastError = null; let lastHeartbeatAt = startedAt; console.log(`[bridge:health] 开始检查: ${options.healthUrl}`); while (true) { if ( startedByScript && state.child && (typeof state.child.exitCode === "number" || state.child.signalCode) ) { const now = Date.now(); if (childExitObservedAt === null) { childExitObservedAt = now; } const exitDetail = describeChildExit(state.child); const activeProcessCount = listActiveGuiSmokeGroupProcesses( startedByScript, ).length; const heartbeat = describeGuiSmokeHeartbeat(startedByScript); if (activeProcessCount > 0) { if (now - lastExitedChildLogAt >= GUI_SMOKE_BRIDGE_HEARTBEAT_MS) { lastExitedChildLogAt = now; console.log( `[bridge:health] headless Tauri 父进程已退出(${exitDetail}),但进程组仍有 ${activeProcessCount} 个活跃进程,继续等待 DevBridge${heartbeat ? `;进程组: ${heartbeat}` : ""}`, ); } } else if ( now - childExitObservedAt >= GUI_SMOKE_CHILD_EXIT_GRACE_MS ) { const lastDetail = lastError instanceof Error ? `;最近一次健康检查错误: ${lastError.message}` : ""; throw new Error( `[verify:gui-smoke] headless Tauri 在 DevBridge 就绪前提前退出(${exitDetail}),且 ${GUI_SMOKE_CHILD_EXIT_GRACE_MS}ms 内未检测到仍在运行的 GUI smoke 进程组${lastDetail}`, ); } else if (now - lastExitedChildLogAt >= GUI_SMOKE_BRIDGE_HEARTBEAT_MS) { lastExitedChildLogAt = now; console.log( `[bridge:health] headless Tauri 父进程已退出(${exitDetail}),等待最多 ${GUI_SMOKE_CHILD_EXIT_GRACE_MS}ms 确认是否还有后续启动链。`, ); } } try { const response = await fetch(options.healthUrl, { method: "GET", signal: AbortSignal.timeout(Math.min(options.intervalMs, 1_500)), }); const text = await response.text(); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } let payload = null; try { payload = text ? JSON.parse(text) : null; } catch { payload = null; } const elapsed = Date.now() - startedAt; const status = payload && typeof payload === "object" ? payload.status : undefined; console.log( `[bridge:health] 就绪: ${options.healthUrl} (${elapsed}ms)${status ? ` status=${status}` : ""}`, ); return; } catch (error) { lastError = error; const heartbeatAt = Date.now(); if (heartbeatAt >= deadlineAt) { const heartbeat = describeGuiSmokeHeartbeat(startedByScript); if ( compileGraceCount < GUI_SMOKE_MAX_COMPILE_GRACE_EXTENSIONS && hasActiveGuiSmokeCompile(startedByScript) ) { compileGraceCount += 1; deadlineAt = heartbeatAt + GUI_SMOKE_COMPILE_GRACE_MS; console.log( `[bridge:health] 检测到 GUI smoke 仍在编译,第 ${compileGraceCount} 次额外延长 ${GUI_SMOKE_COMPILE_GRACE_MS}ms 等待 DevBridge${heartbeat ? `;进程组: ${heartbeat}` : ""}`, ); continue; } if ( !bootGraceUsed && startedByScript && ((state.child && state.child.exitCode === null && !state.child.signalCode) || hasActiveGuiSmokeProcesses(startedByScript)) ) { bootGraceUsed = true; deadlineAt = heartbeatAt + GUI_SMOKE_BOOT_GRACE_MS; console.log( `[bridge:health] 编译链已结束,额外延长 ${GUI_SMOKE_BOOT_GRACE_MS}ms 等待 headless Tauri 拉起 DevBridge${heartbeat ? `;进程组: ${heartbeat}` : ""}`, ); continue; } break; } if (heartbeatAt - lastHeartbeatAt >= GUI_SMOKE_BRIDGE_HEARTBEAT_MS) { lastHeartbeatAt = heartbeatAt; const detail = error instanceof Error ? error.message : String(error || "unknown error"); const heartbeat = describeGuiSmokeHeartbeat(startedByScript); console.log( `[bridge:health] 等待中: ${options.healthUrl} (${heartbeatAt - startedAt}ms);最近错误: ${detail}${heartbeat ? `;进程组: ${heartbeat}` : ""}`, ); } await sleep(options.intervalMs); } } const detail = lastError instanceof Error ? lastError.message : String(lastError || "unknown error"); throw new Error( `[bridge:health] 超时未就绪: ${options.healthUrl}。最后错误: ${detail}`, ); } async function probeAppShell(url, timeoutMs) { try { const response = await fetch(url, { method: "GET", signal: AbortSignal.timeout(timeoutMs), }); const html = await response.text(); return { reachable: response.ok, status: response.status, statusText: response.statusText, isLimeDevShell: response.ok && isLimeDevShell(html), }; } catch { return { reachable: false, status: null, statusText: "", isLimeDevShell: false, }; } } async function resolveStartupMode(options) { if (options.reuseRunning) { return { shouldStart: false, reusedExisting: true, reuseExistingAppShell: false, }; } const staleProcessCount = await cleanupStaleGuiSmokeProcesses(); if (staleProcessCount > 0) { console.log( `[verify:gui-smoke] 已清理 ${staleProcessCount} 条残留 GUI smoke headless 链路。`, ); } const guiSmokeProcesses = inspectGuiSmokeTauriProcesses(); const existingAppShell = await probeAppShell(options.appUrl, 1_500); if (existingAppShell.reachable && !existingAppShell.isLimeDevShell) { const statusLabel = `${existingAppShell.status || "unknown"} ${existingAppShell.statusText || ""}`.trim(); throw new Error( `[verify:gui-smoke] ${options.appUrl} 已被其他服务占用,且返回内容不是 Lime 前端壳(${statusLabel})。请先关闭占用进程后重试。`, ); } if (!existingAppShell.isLimeDevShell) { const existingListeners = listListeningCommandsForPort( resolveUrlPort(options.appUrl), ); const hasLimeFrontendListener = existingListeners.some( isLikelyLimeFrontendListener, ); if (guiSmokeProcesses.active.length > 0) { const pidList = guiSmokeProcesses.active .map((item) => item.pid) .join(", "); console.log( `[verify:gui-smoke] 检测到已有 GUI smoke headless 进程正在启动(PID: ${pidList});本次将直接复用现有链路并等待 DevBridge。`, ); return { shouldStart: false, reusedExisting: true, reuseExistingAppShell: hasLimeFrontendListener, }; } if (hasLimeFrontendListener) { console.log( `[verify:gui-smoke] 检测到 ${options.appUrl} 已由当前仓库的前端启动链监听,但页面尚未完全就绪;将复用现有前端启动链,只拉起 headless Tauri 与 DevBridge。`, ); return { shouldStart: true, reusedExisting: false, reuseExistingAppShell: true, }; } if (existingListeners.length > 0) { throw new Error( `[verify:gui-smoke] ${options.appUrl} 已被其他进程占用,且当前无法确认为 Lime 前端壳。请先关闭占用进程后重试。`, ); } return { shouldStart: true, reusedExisting: false, reuseExistingAppShell: false, }; } const existingBridge = await isUrlReady(options.healthUrl, 1_500); if (existingBridge) { console.log( "[verify:gui-smoke] 检测到已有 headless 环境,自动复用现有前端与 DevBridge。", ); return { shouldStart: false, reusedExisting: true, reuseExistingAppShell: true, }; } if (guiSmokeProcesses.active.length > 0) { const pidList = guiSmokeProcesses.active.map((item) => item.pid).join(", "); console.log( `[verify:gui-smoke] 检测到已有 GUI smoke headless 进程正在启动(PID: ${pidList});本次将直接复用现有前端与 headless 链路。`, ); return { shouldStart: false, reusedExisting: true, reuseExistingAppShell: true, }; } console.log( `[verify:gui-smoke] 检测到已有前端 dev server 正在占用 ${options.appUrl},但 DevBridge 尚未就绪;将继续拉起 headless Tauri,并复用现有前端壳。`, ); return { shouldStart: true, reusedExisting: false, reuseExistingAppShell: true, }; } async function main() { if (typeof fetch !== "function") { throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+"); } const parsedOptions = parseArgs(process.argv.slice(2)); const options = normalizeCargoTargetDir(parsedOptions); const startupMode = await resolveStartupMode(options); const startedByScript = startupMode.shouldStart; if (options.cargoTargetDirFallbackReason) { const corruptedDirList = options.corruptedSqliteBindingOutputs .map((item) => item.relativeOutDir) .join(", "); console.warn( `[verify:gui-smoke] 检测到损坏的 sqlite 构建缓存(缺少 bindgen.rs):${corruptedDirList}`, ); console.warn( `[verify:gui-smoke] 为避免复用旧半成品 target,Cargo target 已切换到新的独立目录:${options.cargoTargetDir}`, ); } console.log(`[verify:gui-smoke] Cargo target: ${options.cargoTargetDir}`); const handleSignal = async (signal) => { try { await stopHeadlessTauri(); } finally { process.kill(process.pid, signal); } }; process.once("SIGINT", () => { void handleSignal("SIGINT"); }); process.once("SIGTERM", () => { void handleSignal("SIGTERM"); }); try { if (startedByScript) { startHeadlessTauri(options, startupMode); await sleep(1_500); } else if (startupMode.reusedExisting) { console.log("[verify:gui-smoke] 复用已运行的 headless Tauri 环境。"); } await waitForBridgeHealth(options, startedByScript); await waitForAppShell(options); await cleanupStaleGuiSmokeChromeProfiles(options, { label: "预清理历史残留 smoke Chrome profiles", required: true, }); runCommand( npmCommand, [ "run", "smoke:workspace-ready", "--", "--timeout-ms", String(options.timeoutMs), "--interval-ms", String(options.intervalMs), "--sample-project-name", options.sampleProjectName, ], "smoke:workspace-ready", options.timeoutMs + 5_000, ); runCommand( npmCommand, [ "run", "smoke:browser-runtime", "--", "--timeout-ms", String(options.timeoutMs), "--interval-ms", String(options.intervalMs), "--headless", ], "smoke:browser-runtime", options.timeoutMs + 5_000, ); runCommand( npmCommand, [ "run", "smoke:site-adapters", "--", "--timeout-ms", String(options.timeoutMs), "--interval-ms", String(options.intervalMs), ], "smoke:site-adapters", options.timeoutMs + 5_000, ); runCommand( npmCommand, ["run", "smoke:agent-service-skill-entry"], "smoke:agent-service-skill-entry", options.timeoutMs + 30_000, ); runCommand( npmCommand, ["run", "smoke:agent-runtime-tool-surface"], "smoke:agent-runtime-tool-surface", options.timeoutMs + 30_000, ); runCommand( npmCommand, ["run", "smoke:agent-runtime-tool-surface-page"], "smoke:agent-runtime-tool-surface-page", options.timeoutMs + 30_000, ); await cleanupStaleGuiSmokeChromeProfiles(options, { label: "收尾清理本轮 smoke Chrome profiles", required: true, }); console.log("\n[verify:gui-smoke] 通过"); } finally { await cleanupStaleGuiSmokeChromeProfiles(options, { label: "兜底清理 smoke Chrome profiles", required: false, skipIfBridgeUnavailable: true, }); if (startedByScript) { await stopHeadlessTauri(); } } } main().catch((error) => { const exitCode = typeof error?.exitCode === "number" && error.exitCode > 0 ? error.exitCode : 1; console.error(error instanceof Error ? error.message : String(error)); process.exit(exitCode); });