#!/usr/bin/env node import { spawn, spawnSync } from "node:child_process"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; const DEFAULTS = { appUrl: "http://127.0.0.1:1420/", healthUrl: "http://127.0.0.1:3030/health", timeoutMs: 600_000, intervalMs: 1_000, reuseRunning: false, sampleProjectName: "Lime Smoke Workspace", }; 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 state = { child: null, cleanedUp: false, }; 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 --timeout-ms 等待 headless / bridge / smoke 的超时,默认 600000 --interval-ms 轮询间隔,默认 1000 --sample-project-name workspace 路径校验使用的示例项目名 --reuse-running 复用已启动的 headless Tauri,不主动拉起 -h, --help 显示帮助 `); } function parseArgs(argv) { const options = { ...DEFAULTS }; 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 === "--timeout-ms" && argv[index + 1]) { options.timeoutMs = Number(argv[index + 1]); 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 === "--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 (!options.sampleProjectName) { throw new Error("--sample-project-name 不能为空"); } return options; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function assert(condition, message) { if (!condition) { throw new Error(message); } } function formatCommand(command, args) { return [command, ...args].join(" "); } 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 startHeadlessTauri() { console.log("[verify:gui-smoke] 启动 headless Tauri 环境..."); state.child = spawn(npmCommand, ["run", "tauri:dev:headless"], { cwd: rootDir, stdio: "inherit", env: { ...process.env, [LIME_SKIP_STARTUP_WINDOW_REVEAL]: "1", [LIME_DISABLE_SINGLE_INSTANCE]: "1", }, detached: process.platform !== "win32", }); } async function stopHeadlessTauri() { const child = state.child; if (!child || state.cleanedUp) { return; } state.cleanedUp = true; console.log("[verify:gui-smoke] 停止 headless Tauri 环境..."); if (typeof child.pid !== "number") { return; } if (process.platform === "win32") { spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", }); return; } try { process.kill(-child.pid, "SIGTERM"); } catch { try { child.kill("SIGTERM"); } catch { return; } } for (let attempt = 0; attempt < 25; attempt += 1) { if (child.exitCode !== null || child.signalCode) { return; } await sleep(200); } try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { // ignore } } } 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( html.includes("Lime") || html.includes('
') || html.includes('
{ 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(); await sleep(1_500); } else if (startupMode.reusedExisting) { console.log("[verify:gui-smoke] 复用已运行的 headless Tauri 环境。"); } runCommand( npmCommand, [ "run", "bridge:health", "--", "--url", options.healthUrl, "--timeout-ms", String(options.timeoutMs), "--interval-ms", String(options.intervalMs), ], "bridge:health", options.timeoutMs + 5_000, ); await waitForAppShell(options); 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, ); console.log("\n[verify:gui-smoke] 通过"); } finally { 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); });