509 lines
17 KiB
JavaScript
509 lines
17 KiB
JavaScript
import { existsSync, mkdirSync, mkdtempSync, 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();
|
|
const targetUrl = process.argv[2] ?? pathToFileURL(appHtml).href;
|
|
const isRemote = /^https?:\/\//i.test(targetUrl);
|
|
|
|
mkdirSync(outputDir, { recursive: true });
|
|
|
|
const evidence = await verifyButtonFlow(targetUrl);
|
|
const evidencePath = join(outputDir, isRemote ? "button-evidence-public.json" : "button-evidence.json");
|
|
writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`);
|
|
console.log(JSON.stringify(evidence, null, 2));
|
|
|
|
async function verifyButtonFlow(url) {
|
|
const browser = await launchChrome("buttons");
|
|
try {
|
|
const client = await connect(browser.websocketUrl);
|
|
await client.send("Page.enable");
|
|
await client.send("Runtime.enable");
|
|
await client.send("Network.enable");
|
|
await client.send("Emulation.setDeviceMetricsOverride", {
|
|
width: 1440,
|
|
height: 960,
|
|
deviceScaleFactor: 1,
|
|
mobile: false
|
|
});
|
|
await client.send("Page.navigate", { url });
|
|
await waitForLoad(client);
|
|
|
|
const initial = await waitForProbe(
|
|
client,
|
|
(probe) => probe.title === "ABB120 Virtual Controller"
|
|
&& probe.frameTotal > 0
|
|
&& probe.samplePeriodSec === 0.05
|
|
&& probe.stepRows > 0
|
|
&& probe.activeStepRows === 1
|
|
&& (!isRemote || probe.programOptions.length >= 10),
|
|
"initial controller render",
|
|
12_000
|
|
);
|
|
if (isRemote && initial.programOptions.length < 10) {
|
|
throw new Error(`remote manifest did not populate enough programs: ${initial.programOptions.length}`);
|
|
}
|
|
|
|
await clickCommand(client, "load");
|
|
const afterLoadClick = await sleepAndProbe(client, 120);
|
|
if (!["load", "loading", "parsing", "ready"].includes(afterLoadClick.state)) {
|
|
throw new Error(`Load did not show an active state, got ${afterLoadClick.state}`);
|
|
}
|
|
if (!afterLoadClick.log.includes(afterLoadClick.program)) {
|
|
throw new Error(`Load did not write the active program to the log: ${afterLoadClick.log}`);
|
|
}
|
|
const afterParse = await waitForProbe(
|
|
client,
|
|
(probe) => probe.state === "ready"
|
|
&& probe.parser.includes("parsed")
|
|
&& probe.parserProgress === 1
|
|
&& probe.stepRows > 1
|
|
&& probe.frameRows === probe.sourceFrameTotal,
|
|
"Load parse stream",
|
|
4000
|
|
);
|
|
if (afterLoadClick.state === "parsing") {
|
|
if (!afterLoadClick.parser.includes("parsing")) {
|
|
throw new Error(`Load did not expose parser progress: ${afterLoadClick.parser}`);
|
|
}
|
|
if (afterLoadClick.frameRows >= afterParse.frameRows && afterParse.frameRows > 1) {
|
|
throw new Error(`Trajectory did not grow during parsing: ${afterLoadClick.frameRows} -> ${afterParse.frameRows}`);
|
|
}
|
|
}
|
|
|
|
await clickCommand(client, "run");
|
|
const afterRun = await waitForProbe(
|
|
client,
|
|
(probe) => probe.state === "running" && probe.running,
|
|
"Run command",
|
|
1200
|
|
);
|
|
if (!afterRun.wait.includes("waiting") && !afterRun.wait.includes("satisfied")) {
|
|
throw new Error(`Run did not update wait state: ${afterRun.wait}`);
|
|
}
|
|
assertProgramSteps(afterRun, "Run command");
|
|
|
|
await sleep(700);
|
|
await clickCommand(client, "pause");
|
|
const afterPause = await waitForProbe(client, (probe) => probe.state === "paused", "Pause command", 1000);
|
|
assertProgramSteps(afterPause, "Pause command");
|
|
|
|
await clickCommand(client, "step");
|
|
const afterStep = await waitForProbe(client, (probe) => probe.state === "paused", "Step command", 1000);
|
|
const expectedStepFrame = afterPause.frameCurrent < afterPause.frameTotal
|
|
? afterPause.frameCurrent + 1
|
|
: afterPause.frameCurrent;
|
|
if (afterStep.frameCurrent !== expectedStepFrame) {
|
|
throw new Error(`Step sample mismatch: expected ${expectedStepFrame}, got ${afterStep.frameCurrent}`);
|
|
}
|
|
assertProgramSteps(afterStep, "Step command");
|
|
|
|
await clickCommand(client, "stop");
|
|
const afterStop = await waitForProbe(client, (probe) => probe.state === "stopped", "Stop command", 1000);
|
|
|
|
await clickCommand(client, "reset");
|
|
const afterReset = await waitForProbe(
|
|
client,
|
|
(probe) => probe.state === "ready" && probe.frameCurrent === 1,
|
|
"Reset command",
|
|
1000
|
|
);
|
|
|
|
await clickCommand(client, "export");
|
|
const afterExport = await sleepAndProbe(client, 150);
|
|
if (!afterExport.log.includes("exported") && !afterExport.reports.includes("exported")) {
|
|
throw new Error(`Export did not update log/report fields: ${afterExport.log} / ${afterExport.reports}`);
|
|
}
|
|
|
|
const fullSpecId = afterExport.programOptions.find((id) => id.startsWith("W2_99_"));
|
|
let afterProgramSwitch = afterExport;
|
|
if (fullSpecId) {
|
|
await selectProgram(client, fullSpecId);
|
|
afterProgramSwitch = await waitForProbe(
|
|
client,
|
|
(probe) => probe.program === fullSpecId
|
|
&& probe.state === "ready"
|
|
&& probe.sourceFrameTotal >= 20
|
|
&& probe.frameTotal >= 39
|
|
&& probe.stepRows >= 8
|
|
&& probe.activeStepRows === 1,
|
|
`${fullSpecId} program switch`,
|
|
isRemote ? 12_000 : 4_000
|
|
);
|
|
assertTcpAndJoints(afterProgramSwitch, fullSpecId);
|
|
}
|
|
|
|
const afterCacheWarm = await waitForProbe(
|
|
client,
|
|
(probe) => !isRemote || probe.storageState.writes >= 3,
|
|
"OPFS cache warmup",
|
|
isRemote ? 12_000 : 1500
|
|
);
|
|
const offlineCache = await verifyOfflineCache(client, afterCacheWarm);
|
|
|
|
await client.close();
|
|
return {
|
|
targetUrl: url,
|
|
chrome: chromeBin,
|
|
initial,
|
|
afterLoadClick,
|
|
afterRun,
|
|
afterPause,
|
|
afterStep,
|
|
afterStop,
|
|
afterReset,
|
|
afterExport,
|
|
afterProgramSwitch,
|
|
offlineCache
|
|
};
|
|
} finally {
|
|
browser.close();
|
|
}
|
|
}
|
|
|
|
async function verifyOfflineCache(client, beforeOffline) {
|
|
if (!isRemote) {
|
|
return { skipped: true, reason: "local file target does not require network-offline cache proof" };
|
|
}
|
|
|
|
const cacheProgram = beforeOffline.program
|
|
?? beforeOffline.programOptions.find((id) => id === "W2_60_IOWaitPulse")
|
|
?? beforeOffline.programOptions[0];
|
|
if (!cacheProgram) throw new Error("No program available for offline cache check");
|
|
|
|
await evaluate(client, `() => {
|
|
const app = window.__virtualController;
|
|
app.programCache.clear();
|
|
app.programLoads.clear();
|
|
app.assetTextCache.clear();
|
|
return true;
|
|
}`);
|
|
await client.send("Network.emulateNetworkConditions", {
|
|
offline: true,
|
|
latency: 0,
|
|
downloadThroughput: 0,
|
|
uploadThroughput: 0
|
|
});
|
|
await selectProgram(client, cacheProgram);
|
|
const offline = await waitForProbe(
|
|
client,
|
|
(probe) => probe.program === cacheProgram
|
|
&& probe.state === "ready"
|
|
&& probe.sourceFrameTotal >= 4
|
|
&& probe.storageState.hits > beforeOffline.storageState.hits,
|
|
"offline OPFS program load",
|
|
5000
|
|
);
|
|
if (offline.storageState.mode !== "OPFS") {
|
|
throw new Error(`Expected OPFS mode during offline load, got ${offline.storageState.mode}`);
|
|
}
|
|
assertTcpAndJoints(offline, cacheProgram);
|
|
return {
|
|
program: cacheProgram,
|
|
mode: offline.storageState.mode,
|
|
hitsBefore: beforeOffline.storageState.hits,
|
|
hitsAfter: offline.storageState.hits,
|
|
frame: offline.frame,
|
|
storage: offline.storage
|
|
};
|
|
}
|
|
|
|
async function clickCommand(client, command) {
|
|
return evaluate(client, `() => {
|
|
const button = document.querySelector("[data-command='${command}']");
|
|
if (!button) throw new Error("Missing command ${command}");
|
|
button.click();
|
|
return true;
|
|
}`);
|
|
}
|
|
|
|
async function selectProgram(client, programId) {
|
|
return evaluate(client, `() => {
|
|
const select = document.querySelector("[data-program-select]");
|
|
if (!select) throw new Error("Missing program select");
|
|
select.value = ${JSON.stringify(programId)};
|
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
return select.value;
|
|
}`);
|
|
}
|
|
|
|
function assertTcpAndJoints(probe, label) {
|
|
if (!/\[[^\]]+\]/.test(probe.joints) || probe.joints.split(",").length < 6) {
|
|
throw new Error(`${label}: joints field is not a 6-axis vector: ${probe.joints}`);
|
|
}
|
|
if (!probe.tcp.includes("pos [") || !probe.tcp.includes(" q [")) {
|
|
throw new Error(`${label}: TCP field is incomplete: ${probe.tcp}`);
|
|
}
|
|
if (probe.frameRows !== probe.sourceFrameTotal) {
|
|
throw new Error(`${label}: frame table rows ${probe.frameRows} != source frame total ${probe.sourceFrameTotal}`);
|
|
}
|
|
if (probe.samplePeriodSec !== 0.05) {
|
|
throw new Error(`${label}: expected 50ms sample period, got ${probe.samplePeriodSec}`);
|
|
}
|
|
assertProgramSteps(probe, label);
|
|
}
|
|
|
|
function assertProgramSteps(probe, label) {
|
|
if (probe.stepRows < 1) {
|
|
throw new Error(`${label}: program step table did not render rows`);
|
|
}
|
|
if (probe.activeStepRows !== 1) {
|
|
throw new Error(`${label}: expected one active program step, got ${probe.activeStepRows}`);
|
|
}
|
|
if (!probe.stepStatus.includes("/") || !probe.stepStatus.includes("line")) {
|
|
throw new Error(`${label}: step status is incomplete: ${probe.stepStatus}`);
|
|
}
|
|
if (!probe.activeStepText.includes("@50ms") && !probe.activeStepText.includes("line")) {
|
|
throw new Error(`${label}: active step does not expose execution detail: ${probe.activeStepText}`);
|
|
}
|
|
}
|
|
|
|
async function sleepAndProbe(client, ms) {
|
|
await sleep(ms);
|
|
return evaluate(client, pageProbeSource());
|
|
}
|
|
|
|
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 sleep(100);
|
|
}
|
|
throw new Error(`${label} timed out. Last probe: ${JSON.stringify(lastProbe)}`);
|
|
}
|
|
|
|
function pageProbeSource() {
|
|
return `() => {
|
|
const field = (name) => document.querySelector("[data-field='" + name + "']")?.textContent?.trim() ?? "";
|
|
const frame = field("frame");
|
|
const frameMatch = frame.match(/(\\d+)\\s*\\/\\s*(\\d+)/);
|
|
const app = window.__virtualController;
|
|
return {
|
|
title: document.title,
|
|
state: field("state"),
|
|
program: field("program"),
|
|
wait: field("wait"),
|
|
log: field("log"),
|
|
reports: field("reports"),
|
|
storage: field("storage"),
|
|
joints: field("joints"),
|
|
tcp: field("tcp"),
|
|
frame,
|
|
frameCurrent: frameMatch ? Number(frameMatch[1]) : 0,
|
|
frameTotal: frameMatch ? Number(frameMatch[2]) : 0,
|
|
frameRows: document.querySelectorAll("[data-frame-table] tr").length,
|
|
frameTime: field("frame-time"),
|
|
parser: field("parser"),
|
|
parserProgress: Number(document.querySelector("[data-parser-progress]")?.value ?? 0),
|
|
stepStatus: field("step-status"),
|
|
stepRows: document.querySelectorAll("[data-step-table] tr").length,
|
|
activeStepRows: document.querySelectorAll("[data-step-table] tr[data-step-state='active']").length,
|
|
activeStepText: document.querySelector("[data-step-table] tr[data-step-state='active']")?.textContent?.trim() ?? "",
|
|
programOptions: Array.from(document.querySelectorAll("[data-program-select] option")).map((item) => item.value),
|
|
running: document.querySelector(".workbench")?.dataset.running === "true",
|
|
playbackTime: Number(app?.playbackTime ?? 0),
|
|
samplePeriodSec: Number(app?.samplePeriodSec ?? 0),
|
|
sampleIndex: Number(app?.currentSampleIndex ?? 0),
|
|
sourceFrameIndex: Number(app?.currentFrameIndex ?? 0),
|
|
sourceFrameTotal: Array.isArray(app?.programCache?.get?.(app?.selectedProgramId)?.trajectory?.frames)
|
|
? app.programCache.get(app.selectedProgramId).trajectory.frames.length
|
|
: document.querySelectorAll("[data-frame-table] tr").length,
|
|
storageState: app?.storage ? { ...app.storage } : {},
|
|
parseStream: app?.parseStream ? { ...app.parseStream, timer: Boolean(app.parseStream.timer) } : {},
|
|
errors: Array.isArray(window.__vcErrors) ? [...window.__vcErrors] : []
|
|
};
|
|
}`;
|
|
}
|
|
|
|
function launchChrome(name) {
|
|
const userDataDir = mkdtempSync(join(tmpdir(), `kdl-vc-${name}-`));
|
|
const child = spawn(chromeBin, [
|
|
"--headless=new",
|
|
"--disable-gpu",
|
|
"--no-sandbox",
|
|
"--disable-dev-shm-usage",
|
|
"--ignore-certificate-errors",
|
|
"--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 sleep(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 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 sleep(ms) {
|
|
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
}
|
|
|
|
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";
|
|
}
|