Files
Firelink/scripts/smoke-packaged-app.js
2026-07-17 02:35:36 +03:30

355 lines
9.5 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const executableArg = argValue('--executable');
if (!executableArg) {
console.error('Pass --executable <path>.');
process.exit(1);
}
const executable = path.resolve(executableArg);
const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows');
const assertPortableData = process.argv.includes('--assert-portable-data');
const READY_PORT_TIMEOUT_MS = 500;
const child = spawn(executable, [], {
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
detached: process.platform !== 'win32',
env: {
...process.env,
FIRELINK_SMOKE_TEST: '1',
WEBKIT_DISABLE_COMPOSITING_MODE: '1',
GDK_BACKEND: 'x11',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
let spawnError = null;
let readyPort = null;
let childExit = null;
child.on('error', error => {
spawnError = error;
});
child.on('exit', (code, signal) => {
childExit = { code, signal };
if (readyPort === null) {
console.error(`Child exited prematurely with code ${code} signal ${signal}`);
}
});
child.stderr.on('data', data => {
stderr += data.toString();
});
child.stdout.on('data', () => {});
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function findReadyPort() {
for (let attempt = 0; attempt < 200 && readyPort === null; attempt += 1) {
if (spawnError || childExit) {
break;
}
const ports = Array.from({ length: 11 }, (_, index) => 6412 + index);
const matches = await Promise.all(ports.map(async port => {
try {
const response = await fetch(`http://127.0.0.1:${port}/ping`, {
signal: AbortSignal.timeout(READY_PORT_TIMEOUT_MS),
});
const matchesChild = response.headers.get('x-firelink-server') === '1'
&& response.headers.get('x-firelink-smoke-process-id') === String(child.pid);
await response.body?.cancel();
return matchesChild ? port : null;
} catch {
return null;
}
}));
readyPort = matches.find(port => port !== null) ?? null;
if (readyPort === null) {
await sleep(250);
}
}
}
function assertNoVisibleWindows(rootPid) {
if (process.platform !== 'win32') {
return;
}
const script = `
$root = ${rootPid}
$all = Get-CimInstance Win32_Process
$pending = @($root)
$descendants = @()
while ($pending.Count -gt 0) {
$parent = $pending[0]
if ($pending.Count -eq 1) {
$pending = @()
} else {
$pending = $pending[1..($pending.Count - 1)]
}
$children = @($all | Where-Object { $_.ParentProcessId -eq $parent })
foreach ($child in $children) {
$descendants += $child.ProcessId
$pending += $child.ProcessId
}
}
$visible = foreach ($childPid in $descendants) {
$process = Get-Process -Id $childPid -ErrorAction SilentlyContinue
if ($process -and $process.MainWindowHandle -ne 0) {
"$($process.ProcessName)($childPid)"
}
}
if ($visible.Count -gt 0) {
Write-Error "Visible child process windows detected: $($visible -join ', ')"
exit 1
}
`;
try {
execFileSync('powershell', ['-NoProfile', '-Command', script], {
stdio: 'pipe',
windowsHide: true,
});
} catch (error) {
const detail = [error.stdout?.toString(), error.stderr?.toString()].filter(Boolean).join('\n').trim();
throw new Error(detail || 'Visible child process window check failed.');
}
}
function waitForChildExit(timeoutMs) {
if (childExit) {
return Promise.resolve(true);
}
return new Promise(resolve => {
let timer;
const onExit = () => {
clearTimeout(timer);
child.off('exit', onExit);
resolve(true);
};
timer = setTimeout(() => {
child.off('exit', onExit);
resolve(false);
}, timeoutMs);
child.once('exit', onExit);
});
}
function isProcessGroupAlive(rootPid) {
if (process.platform === 'win32') {
return false;
}
try {
process.kill(-rootPid, 0);
return true;
} catch (error) {
return error?.code !== 'ESRCH';
}
}
async function waitForProcessGroupExit(rootPid, timeoutMs) {
if (process.platform === 'win32') {
return true;
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isProcessGroupAlive(rootPid)) {
return true;
}
await sleep(100);
}
return !isProcessGroupAlive(rootPid);
}
function windowsBundleRoot() {
return path.dirname(executable).replaceAll("'", "''");
}
function windowsBundleProcessIds() {
const root = windowsBundleRoot();
const script = `
$root = '${root}'
$rootPrefix = $root.TrimEnd('\\') + '\\'
Get-CimInstance Win32_Process |
Where-Object {
$commandLine = $_.CommandLine
$commandLine -and $commandLine.TrimStart([char]34).StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)
} |
Select-Object -ExpandProperty ProcessId
`;
try {
const output = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
return output
.split(/\r?\n/)
.map(value => Number.parseInt(value.trim(), 10))
.filter(Number.isInteger);
} catch {
return null;
}
}
function terminateWindowsBundleProcesses() {
const root = windowsBundleRoot();
const script = `
$root = '${root}'
$rootPrefix = $root.TrimEnd('\\') + '\\'
Get-CimInstance Win32_Process |
Where-Object {
$commandLine = $_.CommandLine
$commandLine -and $commandLine.TrimStart([char]34).StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)
} |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
`;
try {
execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
stdio: 'ignore',
windowsHide: true,
});
} catch {}
}
async function waitForWindowsBundleExit(timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const processIds = windowsBundleProcessIds();
if (processIds?.length === 0) {
return true;
}
await sleep(100);
}
return windowsBundleProcessIds()?.length === 0;
}
async function terminateChild() {
if (!child.pid) {
return true;
}
const childWasRunning = !childExit;
if (process.platform === 'win32') {
if (childWasRunning) {
try {
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
} catch {}
}
const childExited = await waitForChildExit(10000);
const bundleExited = await waitForWindowsBundleExit(5000);
if (childExited && bundleExited) {
return true;
}
terminateWindowsBundleProcesses();
return await waitForChildExit(5000) && await waitForWindowsBundleExit(5000);
}
if (childWasRunning && !childExit) {
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
if (!childExit) {
child.kill('SIGTERM');
}
}
}
if (await waitForChildExit(5000) && await waitForProcessGroupExit(child.pid, 5000)) {
return true;
}
if (!childWasRunning || childExit) {
return false;
}
try {
process.kill(-child.pid, 'SIGKILL');
} catch {
if (!childExit) {
child.kill('SIGKILL');
}
}
return await waitForChildExit(5000) && await waitForProcessGroupExit(child.pid, 5000);
}
async function assertPortableStorage() {
const portableRoot = path.dirname(executable);
const marker = path.join(portableRoot, 'portable.flag');
const database = path.join(portableRoot, 'data', 'firelink.sqlite');
const webviewData = path.join(portableRoot, 'data', 'webview');
for (let attempt = 0; attempt < 40; attempt += 1) {
const markerReady = fs.statSync(marker, { throwIfNoEntry: false })?.isFile();
const databaseReady = fs.statSync(database, { throwIfNoEntry: false })?.isFile();
const webviewReady = fs.statSync(webviewData, { throwIfNoEntry: false })?.isDirectory();
if (markerReady && databaseReady && webviewReady) {
return;
}
await sleep(250);
}
throw new Error(
`Portable storage was not ready: marker=${marker}, database=${database}, webview=${webviewData}`,
);
}
try {
await findReadyPort();
if (readyPort === null) {
if (spawnError) {
throw new Error(`Packaged Firelink failed to start: ${spawnError.message}`);
} else if (childExit) {
throw new Error(
`Packaged Firelink exited before exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`,
);
} else {
throw new Error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`);
}
}
if (assertNoVisibleChildWindows) {
assertNoVisibleWindows(child.pid);
}
if (assertPortableData) {
await assertPortableStorage();
}
if (childExit) {
throw new Error(`Packaged Firelink exited after exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`);
}
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
} finally {
if (!await terminateChild()) {
console.error('Packaged Firelink could not be terminated cleanly; refusing to report smoke success.');
process.exitCode = 1;
}
}