aps-agent/scripts/smoke-packaged-desktop.mjs

188 lines
5.8 KiB
JavaScript

import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const executable = path.join(
repoRoot,
'apps',
'desktop',
'release',
'win-unpacked',
'工业智核 APS.exe',
);
function reservePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen({ host: '127.0.0.1', port: 0, exclusive: true }, () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close((error) => error ? reject(error) : resolve(port));
});
});
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function waitForPage(debugPort, child) {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`packaged Electron exited before smoke validation: ${child.exitCode}`);
}
try {
const response = await fetch(`http://127.0.0.1:${debugPort}/json/list`);
const targets = await response.json();
const page = targets.find((target) => (
target.type === 'page' && target.url.startsWith('http://127.0.0.1:')
));
if (page) return page;
} catch {
// The DevTools endpoint starts after the Electron main process.
}
await delay(250);
}
throw new Error('packaged Electron page did not become ready within 60 seconds');
}
function evaluate(webSocketDebuggerUrl, expression) {
return new Promise((resolve, reject) => {
const socket = new WebSocket(webSocketDebuggerUrl);
const timer = setTimeout(() => {
socket.close();
reject(new Error('DevTools evaluation timed out'));
}, 10_000);
socket.addEventListener('open', () => {
socket.send(JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: { expression, returnByValue: true },
}));
});
socket.addEventListener('message', (event) => {
const message = JSON.parse(String(event.data));
if (message.id !== 1) return;
clearTimeout(timer);
socket.close();
if (message.error || message.result?.exceptionDetails) {
reject(new Error(JSON.stringify(message.error ?? message.result.exceptionDetails)));
return;
}
resolve(message.result.result.value);
});
socket.addEventListener('error', () => {
clearTimeout(timer);
reject(new Error('DevTools WebSocket failed'));
});
});
}
async function waitForRenderer(page, child) {
const expression = `JSON.stringify({
bridge: Boolean(window.apsDesktop),
mode: window.apsDesktop?.mode,
ready: document.readyState,
origin: location.origin,
skillsDir: window.apsDesktop?.skillsDir,
title: document.title,
rootChildren: document.querySelector('#root')?.childElementCount ?? 0
})`;
const deadline = Date.now() + 30_000;
let lastResult = null;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`packaged Electron exited during renderer validation: ${child.exitCode}`);
}
try {
lastResult = await evaluate(page.webSocketDebuggerUrl, expression);
const renderer = JSON.parse(lastResult);
if (
renderer.origin.startsWith('http://127.0.0.1:')
&& renderer.ready === 'complete'
&& renderer.bridge
&& renderer.rootChildren > 0
) return renderer;
} catch {
// Navigation replaces the initial about:blank execution context.
}
await delay(250);
}
throw new Error(`sandboxed preload validation failed: ${lastResult}`);
}
function stopProcessTree(child) {
if (!Number.isInteger(child.pid) || child.pid <= 0) return;
if (process.platform === 'win32') {
spawnSync('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], {
stdio: 'ignore',
windowsHide: true,
});
return;
}
child.kill('SIGKILL');
}
async function removeUserDataDir(userDataDir) {
let lastError = null;
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
fs.rmSync(userDataDir, { recursive: true, force: true });
return;
} catch (error) {
lastError = error;
await delay(500 * (attempt + 1));
}
}
console.warn(`could not remove smoke user data: ${lastError}`);
}
async function main() {
if (!fs.existsSync(executable)) {
throw new Error(`packaged Electron executable is missing: ${executable}`);
}
const debugPort = await reservePort();
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aps-electron-smoke-'));
const child = spawn(executable, [
`--remote-debugging-port=${debugPort}`,
`--user-data-dir=${userDataDir}`,
], {
cwd: path.dirname(executable),
stdio: 'ignore',
windowsHide: true,
});
try {
const page = await waitForPage(debugPort, child);
const renderer = await waitForRenderer(page, child);
if (renderer.mode !== 'desktop') throw new Error(`unexpected desktop mode: ${renderer.mode}`);
const directHealth = await fetch(`${renderer.origin}/api/health`);
if (directHealth.status !== 403) {
throw new Error(`direct Sidecar request returned ${directHealth.status}, expected 403`);
}
console.log(JSON.stringify({
electronPid: child.pid,
origin: renderer.origin,
bridge: renderer.bridge,
mode: renderer.mode,
ready: renderer.ready,
directHealthStatus: directHealth.status,
skillsDir: renderer.skillsDir,
title: renderer.title,
rootChildren: renderer.rootChildren,
}));
} finally {
stopProcessTree(child);
await delay(750);
await removeUserDataDir(userDataDir);
}
}
await main();