188 lines
5.8 KiB
JavaScript
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();
|