375 lines
12 KiB
JavaScript
375 lines
12 KiB
JavaScript
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, join, resolve } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { spawn } from "node:child_process";
|
|
|
|
const root = resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
|
const appHtml = resolve(root, "kdl-wasm/web/app/virtual-controller.html");
|
|
const outputDir = resolve(root, "kdl-wasm/web/test-results/virtual-controller");
|
|
const chromeBin = process.env.CHROME_BIN ?? defaultChromeBin();
|
|
|
|
mkdirSync(outputDir, { recursive: true });
|
|
|
|
const desktop = await verifyViewport({
|
|
name: "desktop",
|
|
width: 1440,
|
|
height: 960,
|
|
deviceScaleFactor: 1,
|
|
mobile: false,
|
|
screenshotPath: join(outputDir, "virtual-controller-desktop.png")
|
|
});
|
|
const mobile = await verifyViewport({
|
|
name: "mobile",
|
|
width: 390,
|
|
height: 844,
|
|
deviceScaleFactor: 2,
|
|
mobile: true,
|
|
screenshotPath: join(outputDir, "virtual-controller-mobile.png")
|
|
});
|
|
|
|
const evidence = {
|
|
app: appHtml,
|
|
chrome: chromeBin,
|
|
desktop,
|
|
mobile
|
|
};
|
|
writeFileSync(join(outputDir, "evidence.json"), `${JSON.stringify(evidence, null, 2)}\n`);
|
|
console.log(JSON.stringify(evidence, null, 2));
|
|
|
|
async function verifyViewport(viewport) {
|
|
const browser = await launchChrome(viewport);
|
|
try {
|
|
const client = await connect(browser.websocketUrl);
|
|
await client.send("Page.enable");
|
|
await client.send("Runtime.enable");
|
|
await client.send("Emulation.setDeviceMetricsOverride", {
|
|
width: viewport.width,
|
|
height: viewport.height,
|
|
deviceScaleFactor: viewport.deviceScaleFactor,
|
|
mobile: viewport.mobile
|
|
});
|
|
await client.send("Page.navigate", { url: pathToFileURL(appHtml).href });
|
|
await waitForLoad(client);
|
|
|
|
const initial = await waitForProbe(
|
|
client,
|
|
(probe) => probe.parserProgress === 1 && !probe.parser.includes("parsing"),
|
|
"initial parse",
|
|
4000
|
|
);
|
|
assertProbe(initial, viewport.name, "initial");
|
|
|
|
await evaluate(client, clickCommandSource("load"));
|
|
await evaluate(client, clickCommandSource("run"));
|
|
await evaluate(client, clickIoSource());
|
|
const afterRun = await waitForProbe(client, (probe) => probe.state === "running", "run", 1200);
|
|
assertProbe(afterRun, viewport.name, "after run");
|
|
if (afterRun.state !== "running") {
|
|
throw new Error(`${viewport.name}: expected state running, got ${afterRun.state}`);
|
|
}
|
|
if (!afterRun.running) {
|
|
throw new Error(`${viewport.name}: data-running was not set after Run`);
|
|
}
|
|
if (!afterRun.wait.includes("satisfied")) {
|
|
throw new Error(`${viewport.name}: expected DI wait to be satisfied, got ${afterRun.wait}`);
|
|
}
|
|
|
|
const screenshot = await client.send("Page.captureScreenshot", {
|
|
format: "png",
|
|
captureBeyondViewport: false
|
|
});
|
|
writeFileSync(viewport.screenshotPath, Buffer.from(screenshot.data, "base64"));
|
|
const pngBytes = Buffer.byteLength(screenshot.data, "base64");
|
|
if (pngBytes < 10_000) {
|
|
throw new Error(`${viewport.name}: screenshot is unexpectedly small (${pngBytes} bytes)`);
|
|
}
|
|
|
|
await client.close();
|
|
return {
|
|
viewport: {
|
|
width: viewport.width,
|
|
height: viewport.height,
|
|
deviceScaleFactor: viewport.deviceScaleFactor,
|
|
mobile: viewport.mobile
|
|
},
|
|
screenshot: viewport.screenshotPath,
|
|
screenshotBytes: pngBytes,
|
|
state: afterRun.state,
|
|
parser: afterRun.parser,
|
|
wait: afterRun.wait,
|
|
trace: afterRun.trace,
|
|
visiblePanels: afterRun.visiblePanels
|
|
};
|
|
} finally {
|
|
browser.close();
|
|
}
|
|
}
|
|
|
|
function launchChrome(viewport) {
|
|
const userDataDir = mkdtempSync(join(tmpdir(), `kdl-vc-${viewport.name}-`));
|
|
const child = spawn(chromeBin, [
|
|
"--headless=new",
|
|
"--disable-gpu",
|
|
"--no-sandbox",
|
|
"--disable-dev-shm-usage",
|
|
"--remote-debugging-port=0",
|
|
`--user-data-dir=${userDataDir}`,
|
|
"about:blank"
|
|
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
|
|
let log = "";
|
|
let settled = false;
|
|
|
|
const cleanup = () => {
|
|
child.kill("SIGTERM");
|
|
setTimeout(() => {
|
|
rmSync(userDataDir, {
|
|
recursive: true,
|
|
force: true,
|
|
maxRetries: 5,
|
|
retryDelay: 100
|
|
});
|
|
}, 250).unref();
|
|
};
|
|
|
|
return new Promise((resolveLaunch, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
if (!settled) {
|
|
settled = true;
|
|
cleanup();
|
|
reject(new Error(`Chrome did not expose DevTools endpoint. ${log}`));
|
|
}
|
|
}, 15_000);
|
|
|
|
const onData = (chunk) => {
|
|
log += chunk.toString();
|
|
const match = log.match(/DevTools listening on (ws:\/\/[^\s]+)/);
|
|
if (!match || settled) return;
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
findPageWebsocket(match[1]).then((pageWebsocketUrl) => {
|
|
resolveLaunch({
|
|
websocketUrl: pageWebsocketUrl,
|
|
close: cleanup
|
|
});
|
|
}, (error) => {
|
|
cleanup();
|
|
reject(error);
|
|
});
|
|
};
|
|
|
|
child.stdout.on("data", onData);
|
|
child.stderr.on("data", onData);
|
|
child.on("error", (error) => {
|
|
if (!settled) {
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
cleanup();
|
|
reject(error);
|
|
}
|
|
});
|
|
child.on("exit", (code) => {
|
|
if (!settled) {
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
cleanup();
|
|
reject(new Error(`${basename(chromeBin)} exited early with ${code}. ${log}`));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function findPageWebsocket(browserWebsocketUrl) {
|
|
const endpoint = new URL(browserWebsocketUrl);
|
|
const baseUrl = `http://${endpoint.host}`;
|
|
const started = Date.now();
|
|
let lastError;
|
|
|
|
while (Date.now() - started < 5000) {
|
|
try {
|
|
const response = await fetch(`${baseUrl}/json/list`);
|
|
const targets = await response.json();
|
|
const page = targets.find((target) => target.type === "page" && target.webSocketDebuggerUrl);
|
|
if (page?.webSocketDebuggerUrl) {
|
|
return page.webSocketDebuggerUrl;
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
await new Promise((resolveRetry) => setTimeout(resolveRetry, 100));
|
|
}
|
|
|
|
throw new Error(`Could not find Chrome page target. ${lastError instanceof Error ? lastError.message : ""}`);
|
|
}
|
|
|
|
function connect(websocketUrl) {
|
|
const socket = new WebSocket(websocketUrl);
|
|
let nextId = 1;
|
|
const pending = new Map();
|
|
|
|
socket.addEventListener("message", (event) => {
|
|
const message = JSON.parse(event.data.toString());
|
|
if (!message.id) return;
|
|
const request = pending.get(message.id);
|
|
if (!request) return;
|
|
pending.delete(message.id);
|
|
if (message.error) {
|
|
request.reject(new Error(`${message.error.code}: ${message.error.message}`));
|
|
} else {
|
|
request.resolve(message.result ?? {});
|
|
}
|
|
});
|
|
|
|
return new Promise((resolveClient, reject) => {
|
|
socket.addEventListener("open", () => {
|
|
resolveClient({
|
|
send(method, params = {}) {
|
|
const id = nextId++;
|
|
socket.send(JSON.stringify({ id, method, params }));
|
|
return new Promise((resolveSend, rejectSend) => {
|
|
pending.set(id, { resolve: resolveSend, reject: rejectSend });
|
|
});
|
|
},
|
|
close() {
|
|
socket.close();
|
|
}
|
|
});
|
|
}, { once: true });
|
|
socket.addEventListener("error", reject, { once: true });
|
|
});
|
|
}
|
|
|
|
function waitForLoad(client) {
|
|
return new Promise((resolveLoad, reject) => {
|
|
const started = Date.now();
|
|
const poll = async () => {
|
|
try {
|
|
const ready = await evaluate(client, "() => document.readyState");
|
|
if (ready === "complete") {
|
|
resolveLoad();
|
|
return;
|
|
}
|
|
if (Date.now() - started > 10_000) {
|
|
reject(new Error("Timed out waiting for document.readyState complete"));
|
|
return;
|
|
}
|
|
setTimeout(poll, 100);
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
};
|
|
poll();
|
|
});
|
|
}
|
|
|
|
async function waitForProbe(client, predicate, label, timeoutMs) {
|
|
const started = Date.now();
|
|
let lastProbe;
|
|
while (Date.now() - started < timeoutMs) {
|
|
lastProbe = await evaluate(client, pageProbeSource());
|
|
if (predicate(lastProbe)) return lastProbe;
|
|
await new Promise((resolveRetry) => setTimeout(resolveRetry, 100));
|
|
}
|
|
throw new Error(`${label} timed out. Last probe: ${JSON.stringify(lastProbe)}`);
|
|
}
|
|
|
|
async function evaluate(client, expression) {
|
|
const result = await client.send("Runtime.evaluate", {
|
|
expression: `(${expression})()`,
|
|
awaitPromise: true,
|
|
returnByValue: true
|
|
});
|
|
if (result.exceptionDetails) {
|
|
throw new Error(result.exceptionDetails.text ?? "Runtime.evaluate failed");
|
|
}
|
|
return result.result.value;
|
|
}
|
|
|
|
function pageProbeSource() {
|
|
return `() => {
|
|
const field = (name) => document.querySelector("[data-field='" + name + "']")?.textContent?.trim() ?? "";
|
|
const box = (selector) => {
|
|
const item = document.querySelector(selector);
|
|
if (!item) return null;
|
|
const rect = item.getBoundingClientRect();
|
|
return { width: rect.width, height: rect.height, top: rect.top, left: rect.left };
|
|
};
|
|
const visiblePanels = [".command-bar", ".object-tree", ".viewport", ".pendant", ".editor", ".bottom-panel"]
|
|
.map((selector) => ({ selector, box: box(selector) }))
|
|
.filter((item) => item.box && item.box.width > 0 && item.box.height > 0)
|
|
.map((item) => item.selector);
|
|
return {
|
|
title: document.title,
|
|
state: field("state"),
|
|
wait: field("wait"),
|
|
queue: field("queue"),
|
|
trace: field("trace"),
|
|
parser: field("parser"),
|
|
parserProgress: Number(document.querySelector("[data-parser-progress]")?.value ?? 0),
|
|
editor: field("editor"),
|
|
stepRows: document.querySelectorAll("[data-step-table] tr").length,
|
|
activeStepRows: document.querySelectorAll("[data-step-table] tr[data-step-state='active']").length,
|
|
stepStatus: field("step-status"),
|
|
running: document.querySelector(".workbench")?.dataset.running === "true",
|
|
visiblePanels,
|
|
viewport: box(".viewport"),
|
|
bodyScrollWidth: document.documentElement.scrollWidth,
|
|
clientWidth: document.documentElement.clientWidth
|
|
};
|
|
}`;
|
|
}
|
|
|
|
function clickCommandSource(command) {
|
|
return `() => {
|
|
document.querySelector("[data-command='${command}']").click();
|
|
return document.querySelector(".workbench")?.dataset.lastCommand;
|
|
}`;
|
|
}
|
|
|
|
function clickIoSource() {
|
|
return `() => {
|
|
document.querySelector("[data-io='di1']").click();
|
|
return document.querySelector("[data-field='wait']")?.textContent;
|
|
}`;
|
|
}
|
|
|
|
function assertProbe(probe, viewportName, phase) {
|
|
if (probe.title !== "ABB120 Virtual Controller") {
|
|
throw new Error(`${viewportName}/${phase}: unexpected title ${probe.title}`);
|
|
}
|
|
const required = [".command-bar", ".object-tree", ".viewport", ".pendant", ".editor", ".bottom-panel"];
|
|
for (const selector of required) {
|
|
if (!probe.visiblePanels.includes(selector)) {
|
|
throw new Error(`${viewportName}/${phase}: ${selector} is not visible`);
|
|
}
|
|
}
|
|
if (!probe.editor.includes("proc main") && !probe.editor.includes("MoveJ") && !probe.editor.includes("PTP")) {
|
|
throw new Error(`${viewportName}/${phase}: editor source did not render`);
|
|
}
|
|
if (probe.stepRows < 1 || probe.activeStepRows !== 1 || !probe.stepStatus.includes("line")) {
|
|
throw new Error(`${viewportName}/${phase}: program steps did not render correctly`);
|
|
}
|
|
if (!probe.viewport || probe.viewport.width < 100 || probe.viewport.height < 100) {
|
|
throw new Error(`${viewportName}/${phase}: viewport is too small`);
|
|
}
|
|
if (probe.bodyScrollWidth > probe.clientWidth + 2) {
|
|
throw new Error(`${viewportName}/${phase}: horizontal overflow detected`);
|
|
}
|
|
}
|
|
|
|
function defaultChromeBin() {
|
|
if (process.platform !== "win32") {
|
|
return "/usr/bin/google-chrome";
|
|
}
|
|
const candidates = [
|
|
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
process.env.LOCALAPPDATA
|
|
? join(process.env.LOCALAPPDATA, "Google", "Chrome", "Application", "chrome.exe")
|
|
: "",
|
|
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"
|
|
];
|
|
return candidates.find((candidate) => candidate && existsSync(candidate)) ?? "chrome.exe";
|
|
}
|