mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix: package Electron runtime deps (#1639)
Integrated into release/v3.7.1 — fixes Electron installer shipping empty node_modules. Adds separate extraResources FileSet, CI smoke test job, and cross-platform packaged app validation script. Closes #1636.
This commit is contained in:
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
@@ -168,6 +168,34 @@ jobs:
|
||||
- run: npm run build:cli
|
||||
- run: npm run check:pack-artifact
|
||||
|
||||
electron-package-smoke:
|
||||
name: Electron Package Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: build
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run build
|
||||
- name: Install Electron dependencies
|
||||
working-directory: electron
|
||||
run: npm install --no-audit --no-fund
|
||||
- name: Pack Electron app
|
||||
working-directory: electron
|
||||
run: npm run pack
|
||||
- name: Smoke packaged Electron app
|
||||
env:
|
||||
ELECTRON_SMOKE_TIMEOUT_MS: 60000
|
||||
run: xvfb-run -a npm run electron:smoke:packaged
|
||||
|
||||
test-unit:
|
||||
name: Unit Tests (${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -446,6 +474,7 @@ jobs:
|
||||
- advanced-security
|
||||
- build
|
||||
- package-artifact
|
||||
- electron-package-smoke
|
||||
- test-unit
|
||||
- node-24-compat
|
||||
- test-coverage
|
||||
@@ -494,6 +523,7 @@ jobs:
|
||||
echo "|-----|--------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Build Matrix | $(status '${{ needs.build.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Package Artifact | $(status '${{ needs.package-artifact.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Electron Package Smoke | $(status '${{ needs.electron-package-smoke.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## 🧪 Tests" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
12
.github/workflows/electron-release.yml
vendored
12
.github/workflows/electron-release.yml
vendored
@@ -133,6 +133,18 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build:${{ matrix.target }}
|
||||
|
||||
- name: Smoke packaged Electron app
|
||||
if: matrix.platform != 'linux'
|
||||
env:
|
||||
ELECTRON_SMOKE_TIMEOUT_MS: 60000
|
||||
run: npm run electron:smoke:packaged
|
||||
|
||||
- name: Smoke packaged Electron app (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
env:
|
||||
ELECTRON_SMOKE_TIMEOUT_MS: 60000
|
||||
run: xvfb-run -a npm run electron:smoke:packaged
|
||||
|
||||
- name: Collect installers
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -61,6 +61,13 @@
|
||||
"node_modules/**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../.next/electron-standalone/node_modules",
|
||||
"to": "app/node_modules",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "assets",
|
||||
"to": "assets",
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"electron:build:win": "npm run build && cd electron && npm run build:win",
|
||||
"electron:build:mac": "npm run build && cd electron && npm run build:mac",
|
||||
"electron:build:linux": "npm run build && cd electron && npm run build:linux",
|
||||
"electron:smoke:packaged": "node scripts/smoke-electron-packaged.mjs",
|
||||
"test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test --test-concurrency=10 tests/unit/*.test.ts",
|
||||
"test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test --test-concurrency=10 tests/unit/*.test.ts",
|
||||
"test:plan3": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/plan3-p0.test.ts",
|
||||
|
||||
491
scripts/smoke-electron-packaged.mjs
Normal file
491
scripts/smoke-electron-packaged.mjs
Normal file
@@ -0,0 +1,491 @@
|
||||
#!/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");
|
||||
}
|
||||
|
||||
return {
|
||||
...smokeEnv,
|
||||
DATA_DIR: dataDir,
|
||||
ELECTRON_ENABLE_LOGGING: "1",
|
||||
ELECTRON_ENABLE_STACK_DUMPING: "1",
|
||||
};
|
||||
}
|
||||
|
||||
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))
|
||||
),
|
||||
];
|
||||
|
||||
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);
|
||||
|
||||
console.log(`[electron-smoke] launching ${appExecutable}`);
|
||||
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, [], {
|
||||
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);
|
||||
});
|
||||
}
|
||||
26
tests/unit/electron-packaging.test.ts
Normal file
26
tests/unit/electron-packaging.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const ROOT = join(import.meta.dirname, "..", "..");
|
||||
|
||||
test("electron build copies standalone runtime dependencies into resources/app/node_modules", () => {
|
||||
const electronPackage = JSON.parse(readFileSync(join(ROOT, "electron", "package.json"), "utf8"));
|
||||
|
||||
const extraResources = electronPackage.build?.extraResources;
|
||||
assert.ok(Array.isArray(extraResources), "electron build.extraResources must be an array");
|
||||
|
||||
assert.deepEqual(
|
||||
extraResources.find(
|
||||
(resource) =>
|
||||
resource?.from === "../.next/electron-standalone/node_modules" &&
|
||||
resource?.to === "app/node_modules"
|
||||
),
|
||||
{
|
||||
from: "../.next/electron-standalone/node_modules",
|
||||
to: "app/node_modules",
|
||||
filter: ["**/*"],
|
||||
}
|
||||
);
|
||||
});
|
||||
49
tests/unit/electron-smoke-script.test.ts
Normal file
49
tests/unit/electron-smoke-script.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildSmokeEnv,
|
||||
FATAL_LOG_PATTERNS,
|
||||
LINUX_EXECUTABLE_NAMES,
|
||||
} from "../../scripts/smoke-electron-packaged.mjs";
|
||||
|
||||
test("electron smoke discovers the default Linux executable name", () => {
|
||||
assert.ok(LINUX_EXECUTABLE_NAMES.includes("omniroute-desktop"));
|
||||
});
|
||||
|
||||
test("electron smoke env allowlists runtime variables and drops secrets", () => {
|
||||
const env = buildSmokeEnv({
|
||||
currentPlatform: "linux",
|
||||
dataDir: "/tmp/omniroute-electron-smoke-test",
|
||||
parentEnv: {
|
||||
DISPLAY: ":99",
|
||||
GITHUB_TOKEN: "should-not-leak",
|
||||
PATH: "/usr/bin",
|
||||
SNYK_TOKEN: "should-not-leak",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(env.DATA_DIR, "/tmp/omniroute-electron-smoke-test");
|
||||
assert.equal(env.DISPLAY, ":99");
|
||||
assert.equal(env.PATH, "/usr/bin");
|
||||
assert.equal(env.HOME, "/tmp/omniroute-electron-smoke-test/home");
|
||||
assert.equal(env.XDG_CONFIG_HOME, "/tmp/omniroute-electron-smoke-test/config");
|
||||
assert.equal(env.ELECTRON_ENABLE_LOGGING, "1");
|
||||
assert.equal(env.ELECTRON_ENABLE_STACK_DUMPING, "1");
|
||||
assert.equal(env.GITHUB_TOKEN, undefined);
|
||||
assert.equal(env.SNYK_TOKEN, undefined);
|
||||
});
|
||||
|
||||
test("electron smoke treats Electron process errors as fatal startup logs", () => {
|
||||
const logs = [
|
||||
"[Electron] Unhandled Rejection: Error: startup failed",
|
||||
"[Electron] Uncaught Exception: Error: startup failed",
|
||||
];
|
||||
|
||||
for (const log of logs) {
|
||||
assert.ok(
|
||||
FATAL_LOG_PATTERNS.some((pattern) => pattern.test(log)),
|
||||
`${log} should match a fatal log pattern`
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user