agent-ecosystem/scripts/electron-builder/smokePackagedApp.cjs

277 lines
8.2 KiB
JavaScript

const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const STARTUP_TIMEOUT_MS = Number(process.env.PACKAGED_SMOKE_TIMEOUT_MS ?? 30_000);
const POST_STARTUP_STABLE_MS = Number(process.env.PACKAGED_SMOKE_STABLE_MS ?? 8_000);
const SHUTDOWN_TIMEOUT_MS = Number(process.env.PACKAGED_SMOKE_SHUTDOWN_TIMEOUT_MS ?? 5_000);
const REQUIRED_LOG_MARKERS = ['renderer did-finish-load'];
const FAILURE_PATTERNS = [
/Cannot find module/i,
/MODULE_NOT_FOUND/i,
/Failed to start HTTP server/i,
/Unable to set login item/i,
/\[DEP0180\]/i,
/DeprecationWarning: fs\.Stats constructor is deprecated/i,
];
function isMacBundle(candidatePath) {
return (
candidatePath.endsWith('.app') &&
fs.existsSync(path.join(candidatePath, 'Contents', 'MacOS')) &&
fs.statSync(path.join(candidatePath, 'Contents', 'MacOS')).isDirectory()
);
}
function findMacBundles(searchRoot, maxDepth = 3) {
if (!fs.existsSync(searchRoot) || maxDepth < 0) {
return [];
}
const stat = fs.statSync(searchRoot);
if (stat.isDirectory() && isMacBundle(searchRoot)) {
return [searchRoot];
}
if (!stat.isDirectory()) {
return [];
}
const bundles = [];
for (const entry of fs.readdirSync(searchRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const fullPath = path.join(searchRoot, entry.name);
if (isMacBundle(fullPath)) {
bundles.push(fullPath);
continue;
}
bundles.push(...findMacBundles(fullPath, maxDepth - 1));
}
return bundles;
}
function resolveBundlePath(bundlePath, platform) {
if (platform !== 'darwin' || isMacBundle(bundlePath)) {
return bundlePath;
}
const searchRoots = [
path.dirname(bundlePath),
path.dirname(path.dirname(bundlePath)),
path.resolve(process.cwd(), 'release'),
];
const bundles = [...new Set(searchRoots.flatMap((searchRoot) => findMacBundles(searchRoot)))];
if (bundles.length === 1) {
return bundles[0];
}
if (bundles.length > 1) {
const expectedName = path.basename(bundlePath);
const nameMatch = bundles.find((candidate) => path.basename(candidate) === expectedName);
if (nameMatch) {
return nameMatch;
}
}
return bundlePath;
}
function fail(message, log = '') {
console.error(`[smokePackagedApp] ${message}`);
if (log.trim()) {
console.error('--- packaged app log ---');
console.error(log.trim());
}
process.exit(1);
}
function findExecutable(bundlePath, platform) {
if (platform === 'darwin') {
const macOsDir = path.join(bundlePath, 'Contents', 'MacOS');
const entries = fs.readdirSync(macOsDir);
const executable = entries.find((entry) => {
const fullPath = path.join(macOsDir, entry);
return fs.statSync(fullPath).isFile() && (fs.statSync(fullPath).mode & 0o111) !== 0;
});
if (!executable) fail(`No executable found in ${macOsDir}`);
return path.join(macOsDir, executable);
}
if (platform === 'win32') {
const executable = fs
.readdirSync(bundlePath)
.find(
(entry) =>
entry.toLowerCase().endsWith('.exe') && !entry.toLowerCase().includes('uninstall')
);
if (!executable) fail(`No .exe found in ${bundlePath}`);
return path.join(bundlePath, executable);
}
if (platform === 'linux') {
const packageJsonPath = path.join(bundlePath, 'resources', 'app.asar.unpacked', 'package.json');
const packageJson = fs.existsSync(packageJsonPath)
? JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
: {};
const preferredNames = [
packageJson.name,
'agent-teams-ai',
'Agent Teams AI',
'Agent Teams UI',
].filter(Boolean);
for (const name of preferredNames) {
const candidate = path.join(bundlePath, name);
if (fs.existsSync(candidate)) return candidate;
}
const executable = fs.readdirSync(bundlePath).find((entry) => {
const fullPath = path.join(bundlePath, entry);
return fs.statSync(fullPath).isFile() && (fs.statSync(fullPath).mode & 0o111) !== 0;
});
if (!executable) fail(`No executable found in ${bundlePath}`);
return path.join(bundlePath, executable);
}
fail(`Unsupported platform: ${platform}`);
}
function waitForProcessClose(child, exitPromise, timeoutMs) {
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve(true);
}
let timeoutId;
const timeoutPromise = new Promise((resolve) => {
timeoutId = setTimeout(() => resolve(false), timeoutMs);
});
return Promise.race([exitPromise.then(() => true), timeoutPromise]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
}
async function terminateChild(child, exitPromise, platform) {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
if (platform === 'win32' && child.pid) {
await new Promise((resolve) => {
const killer = spawn('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], {
stdio: 'ignore',
});
killer.once('error', resolve);
killer.once('close', resolve);
});
} else {
const pid = child.pid;
if (pid) {
try {
process.kill(-pid, 'SIGTERM');
} catch {
child.kill();
}
} else {
child.kill();
}
}
const closed = await waitForProcessClose(child, exitPromise, SHUTDOWN_TIMEOUT_MS);
if (!closed && child.exitCode === null && child.signalCode === null) {
if (child.pid && platform !== 'win32') {
try {
process.kill(-child.pid, 'SIGKILL');
} catch {
child.kill('SIGKILL');
}
} else {
child.kill('SIGKILL');
}
const killed = await waitForProcessClose(child, exitPromise, SHUTDOWN_TIMEOUT_MS);
if (!killed && child.exitCode === null && child.signalCode === null) {
throw new Error(`Timed out after ${SHUTDOWN_TIMEOUT_MS}ms waiting for packaged app to exit`);
}
}
}
async function main() {
const [bundlePathArg, platform] = process.argv.slice(2);
if (!bundlePathArg || !platform) {
fail('Usage: node ./scripts/electron-builder/smokePackagedApp.cjs <bundlePath> <platform>');
}
const bundlePath = resolveBundlePath(path.resolve(bundlePathArg), platform);
const executable = findExecutable(bundlePath, platform);
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-smoke-'));
const args = [`--user-data-dir=${userDataDir}`];
if (platform === 'linux') {
args.push('--no-sandbox');
}
const child = spawn(executable, args, {
env: {
...process.env,
AGENT_TEAMS_PACKAGED_SMOKE: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
detached: platform !== 'win32',
});
let log = '';
child.stdout.on('data', (chunk) => {
log += chunk.toString();
});
child.stderr.on('data', (chunk) => {
log += chunk.toString();
});
const exitPromise = new Promise((resolve) => {
child.on('exit', (code, signal) => resolve({ code, signal }));
});
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
let startupSeenAt = null;
while (Date.now() < deadline) {
if (FAILURE_PATTERNS.some((pattern) => pattern.test(log))) {
await terminateChild(child, exitPromise, platform);
fail('Detected startup failure pattern', log);
}
if (startupSeenAt === null && REQUIRED_LOG_MARKERS.every((marker) => log.includes(marker))) {
startupSeenAt = Date.now();
}
if (startupSeenAt !== null && Date.now() - startupSeenAt >= POST_STARTUP_STABLE_MS) {
await terminateChild(child, exitPromise, platform);
console.log(`[smokePackagedApp] OK ${platform}: ${bundlePath}`);
return;
}
const exit = await Promise.race([
exitPromise,
new Promise((resolve) => setTimeout(() => resolve(null), 250)),
]);
if (exit) {
fail(
`Packaged app exited before startup completed: code=${exit.code} signal=${exit.signal}`,
log
);
}
}
await terminateChild(child, exitPromise, platform);
fail(`Timed out after ${STARTUP_TIMEOUT_MS}ms waiting for packaged startup`, log);
}
if (require.main === module) {
main().catch((error) => fail(error?.stack || String(error)));
}
module.exports = {
_internal: {
terminateChild,
waitForProcessClose,
},
};