Files
2026-07-13 13:39:12 +08:00

523 lines
15 KiB
JavaScript

#!/usr/bin/env node
import { spawn } from "node:child_process";
import { existsSync, readdirSync, statSync } from "node:fs";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { arch, platform, tmpdir } from "node:os";
import { dirname, join, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..", "..");
const DEFAULT_TIMEOUT_MS = 45_000;
const DEFAULT_SETTLE_MS = 2_000;
const DEFAULT_URL = "http://127.0.0.1:20128/login";
export const LINUX_EXECUTABLE_NAMES = ["omniroute-desktop", "omniroute", "OmniRoute"];
export const FATAL_LOG_PATTERNS = [
/Cannot find module/i,
/MODULE_NOT_FOUND/,
/ERR_DLOPEN_FAILED/,
/Server exited with code:\s*[1-9]/,
/Failed to start server/i,
/Unhandled Rejection/i,
/Uncaught Exception/i,
];
function parsePositiveInteger(value, fallback) {
const parsed = Number.parseInt(value || "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function sleep(ms) {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
}
function discoverMacExecutable() {
const distDir = join(ROOT, "electron", "dist-electron");
if (process.env.ELECTRON_SMOKE_APP_EXECUTABLE) {
return process.env.ELECTRON_SMOKE_APP_EXECUTABLE;
}
const candidates = [
join(
distDir,
arch() === "arm64" ? "mac-arm64" : "mac",
"OmniRoute.app",
"Contents",
"MacOS",
"OmniRoute"
),
join(distDir, "mac", "OmniRoute.app", "Contents", "MacOS", "OmniRoute"),
join(distDir, "mac-arm64", "OmniRoute.app", "Contents", "MacOS", "OmniRoute"),
];
return candidates.find((candidate) => existsSync(candidate)) || candidates[0];
}
function findExecutableByName(rootDir, names) {
const pending = [rootDir];
const wanted = new Set(names.map((name) => name.toLowerCase()));
while (pending.length > 0) {
const dir = pending.shift();
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir)) {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
pending.push(fullPath);
continue;
}
if (!wanted.has(entry.toLowerCase())) continue;
if (platform() === "linux" && (stat.mode & 0o111) === 0) continue;
return fullPath;
}
}
return null;
}
function discoverWindowsExecutable() {
const distDir = join(ROOT, "electron", "dist-electron");
const candidates = [
join(distDir, "win-unpacked", "OmniRoute.exe"),
join(distDir, "win-x64-unpacked", "OmniRoute.exe"),
join(distDir, "win-arm64-unpacked", "OmniRoute.exe"),
];
return (
candidates.find((candidate) => existsSync(candidate)) ||
findExecutableByName(distDir, ["OmniRoute.exe"]) ||
candidates[0]
);
}
function discoverLinuxExecutable() {
const distDir = join(ROOT, "electron", "dist-electron");
const unpackedDirs = ["linux-unpacked", "linux-arm64-unpacked"];
const candidates = unpackedDirs.flatMap((dir) =>
LINUX_EXECUTABLE_NAMES.map((name) => join(distDir, dir, name))
);
return (
candidates.find((candidate) => existsSync(candidate)) ||
findExecutableByName(distDir, LINUX_EXECUTABLE_NAMES) ||
candidates[0]
);
}
function discoverPackagedExecutable() {
if (process.env.ELECTRON_SMOKE_APP_EXECUTABLE) {
return process.env.ELECTRON_SMOKE_APP_EXECUTABLE;
}
if (platform() === "darwin") return discoverMacExecutable();
if (platform() === "win32") return discoverWindowsExecutable();
if (platform() === "linux") return discoverLinuxExecutable();
throw new Error(`Packaged Electron smoke check does not support ${platform()}.`);
}
async function fetchWithTimeout(url, timeoutMs) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
cache: "no-store",
redirect: "manual",
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
async function assertPortIsFree(url) {
try {
const response = await fetchWithTimeout(url, 1_000);
throw new Error(
`Smoke URL already responded with HTTP ${response.status}: ${url}. Stop the existing OmniRoute process before running this check.`
);
} catch (error) {
if (error instanceof Error && error.message.startsWith("Smoke URL already responded")) {
throw error;
}
}
}
async function waitForPortClosed(url, timeoutMs = 5_000) {
const deadline = Date.now() + timeoutMs;
let lastStatus = null;
while (Date.now() < deadline) {
try {
const response = await fetchWithTimeout(url, 1_000);
lastStatus = response.status;
} catch {
return;
}
await sleep(250);
}
throw new Error(
`Smoke URL still responded after app shutdown${
lastStatus === null ? "" : ` with HTTP ${lastStatus}`
}: ${url}`
);
}
function appendLog(buffer, chunk, prefix, streamLogs) {
const text = chunk.toString();
if (streamLogs) {
process.stdout.write(`${prefix}${text}`);
}
const next = buffer.value + text;
buffer.value = next.length > 40_000 ? next.slice(-40_000) : next;
}
function printLogTail(logs) {
if (!logs.trim()) return;
console.error("[electron-smoke] captured app log tail:");
console.error(logs.trimEnd());
}
function assertNoFatalLogs(logs) {
const fatalPattern = FATAL_LOG_PATTERNS.find((pattern) => pattern.test(logs));
if (fatalPattern) {
throw new Error(`Packaged Electron app emitted fatal startup logs matching ${fatalPattern}.`);
}
}
async function waitForExit(child, timeoutMs) {
if (child.exitCode !== null || child.signalCode !== null) return;
await Promise.race([new Promise((resolve) => child.once("exit", resolve)), sleep(timeoutMs)]);
}
function isProcessGroupAlive(pid) {
if (!pid || platform() === "win32") return false;
try {
process.kill(-pid, 0);
return true;
} catch (error) {
if (error?.code === "ESRCH") return false;
if (error?.code === "EPERM") return true;
throw error;
}
}
async function waitForProcessTreeExit(child, timeoutMs) {
if (!child.pid || platform() === "win32") {
await waitForExit(child, timeoutMs);
return;
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isProcessGroupAlive(child.pid)) return;
await sleep(100);
}
}
async function runQuietly(command, args) {
await new Promise((resolveRun) => {
const proc = spawn(command, args, { stdio: "ignore" });
proc.once("error", resolveRun);
proc.once("exit", resolveRun);
});
}
async function signalProcessTree(child, signal) {
if (!child.pid) return;
if (platform() === "win32") {
if (signal === "SIGKILL") {
await runQuietly("taskkill", ["/pid", String(child.pid), "/t", "/f"]);
} else {
child.kill(signal);
}
return;
}
try {
process.kill(-child.pid, signal);
} catch (error) {
if (error?.code !== "ESRCH") throw error;
}
}
async function stopApp(child) {
if (!child.pid) return;
await signalProcessTree(child, "SIGTERM");
await waitForProcessTreeExit(child, 5_000);
const isStillRunning =
platform() === "win32"
? child.exitCode === null && child.signalCode === null
: isProcessGroupAlive(child.pid);
if (isStillRunning) {
await signalProcessTree(child, "SIGKILL");
await waitForProcessTreeExit(child, 2_000);
}
}
export function buildSmokeEnv({
dataDir,
parentEnv = process.env,
currentPlatform = platform(),
} = {}) {
if (!dataDir) {
throw new Error("buildSmokeEnv requires dataDir.");
}
const inheritedNames = [
"PATH",
"Path",
"SystemRoot",
"WINDIR",
"COMSPEC",
"PATHEXT",
"TMPDIR",
"TMP",
"TEMP",
"LANG",
"LC_ALL",
"LC_CTYPE",
"DISPLAY",
"WAYLAND_DISPLAY",
"XAUTHORITY",
"XDG_RUNTIME_DIR",
"DBUS_SESSION_BUS_ADDRESS",
"ELECTRON_OZONE_PLATFORM_HINT",
];
const smokeEnv = {};
for (const name of inheritedNames) {
if (parentEnv[name]) {
smokeEnv[name] = parentEnv[name];
}
}
if (currentPlatform === "win32") {
smokeEnv.USERPROFILE = join(dataDir, "userprofile");
smokeEnv.APPDATA = join(dataDir, "AppData", "Roaming");
smokeEnv.LOCALAPPDATA = join(dataDir, "AppData", "Local");
smokeEnv.TEMP ||= join(dataDir, "tmp");
smokeEnv.TMP ||= smokeEnv.TEMP;
} else {
smokeEnv.HOME = join(dataDir, "home");
smokeEnv.XDG_CONFIG_HOME = join(dataDir, "config");
smokeEnv.XDG_CACHE_HOME = join(dataDir, "cache");
smokeEnv.XDG_DATA_HOME = join(dataDir, "data");
smokeEnv.TMPDIR ||= join(dataDir, "tmp");
}
const baseEnv = {
...smokeEnv,
DATA_DIR: dataDir,
ELECTRON_ENABLE_LOGGING: "1",
ELECTRON_ENABLE_STACK_DUMPING: "1",
};
// CI environments need sandbox disabled (GitHub Actions runners
// cannot configure SUID chrome-sandbox on Linux, and Windows
// runners may exit silently without it).
if (parentEnv.CI) {
baseEnv.CI = parentEnv.CI;
baseEnv.ELECTRON_DISABLE_SANDBOX = "1";
}
return baseEnv;
}
function isInsideDir(parentDir, candidateDir) {
const parent = resolve(parentDir);
const candidate = resolve(candidateDir);
return candidate === parent || candidate.startsWith(parent + sep);
}
async function ensureSmokeEnvDirs(smokeEnv, dataDir) {
const dirNames = [
"DATA_DIR",
"HOME",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
"TMPDIR",
"TMP",
"TEMP",
];
const dirs = [
...new Set(
dirNames.map((name) => smokeEnv[name]).filter((dir) => dir && isInsideDir(dataDir, dir))
),
];
// On Windows, Electron derives its userData from APPDATA/<productName>.
// requestSingleInstanceLock() runs synchronously at module load and
// fails silently if the directory doesn't exist yet — causing exit(0).
if (platform() === "win32" && smokeEnv.APPDATA) {
for (const subdir of ["omniroute-desktop", "OmniRoute", "omniroute"]) {
dirs.push(join(smokeEnv.APPDATA, subdir));
}
}
await Promise.all(dirs.map((dir) => mkdir(dir, { recursive: true })));
}
async function settleAfterReady({ getExitState, logs, settleMs }) {
const deadline = Date.now() + settleMs;
while (Date.now() < deadline) {
assertNoFatalLogs(logs.value);
const { exitCode, signalCode } = getExitState();
if (exitCode !== null || signalCode !== null) {
throw new Error(
`Packaged Electron app exited during readiness settle: code=${exitCode} signal=${signalCode}`
);
}
await sleep(Math.min(250, Math.max(0, deadline - Date.now())));
}
}
async function main() {
const appExecutable = discoverPackagedExecutable();
if (!existsSync(appExecutable)) {
throw new Error(
`Packaged OmniRoute executable not found at ${appExecutable}. Build it first with \`npm run build:<target> --prefix electron\` or set ELECTRON_SMOKE_APP_EXECUTABLE.`
);
}
const smokeUrl = process.env.ELECTRON_SMOKE_URL || DEFAULT_URL;
const timeoutMs = parsePositiveInteger(process.env.ELECTRON_SMOKE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
const settleMs = parsePositiveInteger(process.env.ELECTRON_SMOKE_SETTLE_MS, DEFAULT_SETTLE_MS);
const dataDir =
process.env.ELECTRON_SMOKE_DATA_DIR ||
(await mkdtemp(join(tmpdir(), "omniroute-electron-smoke-")));
const removeDataDir =
!process.env.ELECTRON_SMOKE_DATA_DIR && process.env.ELECTRON_SMOKE_KEEP_DATA !== "1";
const smokeEnv = buildSmokeEnv({ dataDir });
await assertPortIsFree(smokeUrl);
await ensureSmokeEnvDirs(smokeEnv, dataDir);
// ── CI sandbox workaround ──────────────────────────────────
// GitHub Actions runners cannot set SUID on chrome-sandbox (Linux)
// and Windows runners may fail silently without --no-sandbox.
const spawnArgs = [];
if (process.env.CI) {
spawnArgs.push("--no-sandbox", "--disable-gpu");
if (platform() === "linux") {
spawnArgs.push("--disable-dev-shm-usage");
}
}
console.log(`[electron-smoke] launching ${appExecutable}`);
if (spawnArgs.length) console.log(`[electron-smoke] CI args: ${spawnArgs.join(" ")}`);
console.log(`[electron-smoke] DATA_DIR=${dataDir}`);
console.log(`[electron-smoke] waiting for ${smokeUrl}`);
const logs = { value: "" };
const streamLogs = process.env.ELECTRON_SMOKE_STREAM_LOGS === "1";
const child = spawn(appExecutable, spawnArgs, {
detached: platform() !== "win32",
env: smokeEnv,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout?.on("data", (chunk) => appendLog(logs, chunk, "[electron] ", streamLogs));
child.stderr?.on("data", (chunk) => appendLog(logs, chunk, "[electron:err] ", streamLogs));
let exitCode = null;
let signalCode = null;
let spawnError = null;
child.once("exit", (code, signal) => {
exitCode = code;
signalCode = signal;
});
child.once("error", (error) => {
spawnError = error;
});
try {
const startedAt = Date.now();
let lastError = null;
while (Date.now() - startedAt < timeoutMs) {
assertNoFatalLogs(logs.value);
if (spawnError !== null) {
throw new Error(`Packaged Electron app failed to launch: ${spawnError.message}`);
}
if (exitCode !== null || signalCode !== null) {
throw new Error(
`Packaged Electron app exited before readiness: code=${exitCode} signal=${signalCode}`
);
}
try {
const response = await fetchWithTimeout(smokeUrl, 1_000);
if (response.status === 200) {
assertNoFatalLogs(logs.value);
console.log(`[electron-smoke] ready: ${smokeUrl} returned HTTP 200`);
await settleAfterReady({
getExitState: () => ({ exitCode, signalCode }),
logs,
settleMs,
});
console.log(`[electron-smoke] stable for ${settleMs}ms after readiness`);
return;
}
lastError = new Error(`HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(
`Packaged Electron app did not serve ${smokeUrl} within ${timeoutMs}ms. Last error: ${
lastError instanceof Error ? lastError.message : String(lastError)
}`
);
} catch (error) {
if (!streamLogs) {
printLogTail(logs.value);
}
throw error;
} finally {
await stopApp(child);
await waitForPortClosed(smokeUrl);
if (removeDataDir) {
await rm(dataDir, { recursive: true, force: true });
}
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(
`[electron-smoke] failed: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
});
}