Document source UI gates, explicit native live-plot clearing, and remaining parity boundaries. Fix pause/resume transport continuity, repeated stepping, coordinate previews, and spindle override state. Optimize large program snapshots and rendering, and cover the control flows with native Tcl, WASM, and browser checks. Validation: 22 native tests and source/browser checks pass. Worker/App regression: 113 of 123 pass; 10 pre-existing failures remain documented with no new failures.
236 lines
16 KiB
JavaScript
236 lines
16 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const modulePath = process.env.PLAYWRIGHT_MODULE;
|
|
assert.ok(modulePath, "PLAYWRIGHT_MODULE is required");
|
|
const { chromium } = await import(pathToFileURL(modulePath).href);
|
|
const url = process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5174/";
|
|
const browser = await chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--disable-gpu"] });
|
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
|
const errors = [];
|
|
const checks = [];
|
|
let dialogAnswers = [];
|
|
page.on("pageerror", (error) => errors.push(error.message));
|
|
page.on("dialog", (dialog) => dialogAnswers.length ? dialog.accept(dialogAnswers.shift()) : dialog.dismiss());
|
|
await page.addInitScript(() => {
|
|
window.axisMessages = [];
|
|
window.axisResults = [];
|
|
const NativeWorker = window.Worker;
|
|
window.Worker = class extends NativeWorker {
|
|
constructor(...args) {
|
|
super(...args);
|
|
this.addEventListener("message", (event) => {
|
|
const value = event.data;
|
|
window.axisResults.push({ kind: value.kind, seq: value.seq, payload: value.payload });
|
|
if (window.axisResults.length > 100) window.axisResults.shift();
|
|
});
|
|
}
|
|
postMessage(value, ...args) {
|
|
window.axisMessages.push({ kind: value.kind, seq: value.seq, payload: value.payload });
|
|
super.postMessage(value, ...args);
|
|
}
|
|
};
|
|
});
|
|
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
|
|
const wait = (fn, arg) => page.waitForFunction(fn, arg, { timeout: 45000 });
|
|
const ready = () => wait(() => document.querySelector("main")?.dataset.axisProgramLoading === "false");
|
|
const queueEmpty = () => wait(() => document.querySelector("main")?.dataset.axisTpQueueDepth === "0");
|
|
const messages = (kind) => page.evaluate((kind) => window.axisMessages.filter((entry) => entry.kind === kind), kind);
|
|
const commandCount = async (kind) => (await messages(kind)).length;
|
|
const ack = (kind, after = 0) => wait(({ kind, after }) => window.axisResults.some((result) => result.seq > after && result.kind === "COMMAND_RESULT" && window.axisMessages.some((message) => message.seq === result.seq && message.kind === kind)), { kind, after });
|
|
const latestSeq = () => page.evaluate(() => window.axisMessages.filter((entry) => !entry.kind.startsWith("UI_STATE_")).at(-1)?.seq ?? 0);
|
|
const clickAck = async (id, kind) => { const seq = await latestSeq(); await control(id).click(); await ack(kind, seq); };
|
|
const pose = () => page.locator('[data-xyzac-preview="toolpath"]').evaluate((node) => JSON.parse(node.dataset.xyzacLivePose));
|
|
const check = async (name, run) => { await run(); checks.push(name); process.stderr.write(`[source-flow] ${name}: verified\n`); };
|
|
const hold = async (id, release, axis) => {
|
|
const starts = await commandCount("JOG_START"), stops = await commandCount("JOG_STOP"), seq = await latestSeq();
|
|
const before = await pose();
|
|
await control(id).dispatchEvent("pointerdown", { pointerId: 1, button: 0 });
|
|
await ack("JOG_START", seq);
|
|
await page.waitForTimeout(400);
|
|
const during = await pose();
|
|
assert.notEqual(during[axis], before[axis], `no ${axis} motion`);
|
|
assert.equal(await commandCount("JOG_START"), starts + 1, "sampling must not reissue JOG_START");
|
|
await release();
|
|
await ack("JOG_STOP", seq);
|
|
await queueEmpty();
|
|
const stopped = await pose();
|
|
await page.waitForTimeout(180);
|
|
assert.deepEqual(await pose(), stopped, "motion continued after release");
|
|
assert.equal(await commandCount("JOG_STOP"), stops + 1, "release must stop exactly once");
|
|
};
|
|
const pixels = () => page.locator('[data-xyzac-preview="toolpath"] canvas').evaluate((canvas) => {
|
|
const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
|
|
if (!gl) return { colors: 0 };
|
|
const data = new Uint8Array(canvas.width * canvas.height * 4);
|
|
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
|
const colors = new Set(); let hash = 2166136261;
|
|
for (let i = 0; i < data.length; i += 16) { const value = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2]; colors.add(value); hash = Math.imul(hash ^ value, 16777619); }
|
|
return { colors: colors.size, hash, width: canvas.width, height: canvas.height };
|
|
});
|
|
try {
|
|
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
await ready();
|
|
await check("startup-single-load", async () => assert.equal(await commandCount("PROGRAM_LOAD"), 1));
|
|
await clickAck("toolbar-power", "POWER");
|
|
await clickAck("manual-home-all", "HOME");
|
|
await check("mdi-trim-clear-deduplicate-history", async () => {
|
|
await control("tab-mdi").click();
|
|
const input = control("mdi-command");
|
|
const submit = async (text) => { const seq = await latestSeq(); await input.fill(text); await control("mdi-submit").click(); await ack("MDI_EXECUTE", seq); await queueEmpty(); assert.equal(await input.inputValue(), ""); };
|
|
await submit(" G21 "); await submit("G21");
|
|
assert.equal(await control("mdi-history-select").count(), 1);
|
|
await submit("G90");
|
|
assert.deepEqual(await control("mdi-history-select").allTextContents(), ["G21", "G90"]);
|
|
assert.equal((await messages("MDI_EXECUTE"))[0].payload.block, "G21");
|
|
await input.press("ArrowUp"); assert.equal(await input.inputValue(), "G90");
|
|
await input.press("ArrowUp"); assert.equal(await input.inputValue(), "G21");
|
|
await input.press("ArrowUp"); assert.equal(await input.inputValue(), "");
|
|
await input.press("ArrowDown"); assert.equal(await input.inputValue(), "G21");
|
|
const count = await commandCount("MDI_EXECUTE");
|
|
await control("mdi-history-select").last().click();
|
|
assert.equal(await commandCount("MDI_EXECUTE"), count);
|
|
const seq = await latestSeq(); await control("mdi-history-select").last().dblclick(); await ack("MDI_EXECUTE", seq);
|
|
assert.equal(await commandCount("MDI_EXECUTE"), count + 1);
|
|
assert.equal(await control("mdi-history-select").count(), 2);
|
|
});
|
|
await control("tab-manual").click();
|
|
await check("continuous-five-axes-both-directions", async () => {
|
|
for (const axis of ["x", "y", "z", "a", "c"]) {
|
|
await control(`manual-axis-${axis}`).check();
|
|
for (const direction of ["positive", "negative"]) {
|
|
const id = `manual-jog-${direction}`;
|
|
await hold(id, async () => {
|
|
await control(id).dispatchEvent("pointerup", { pointerId: 1, button: 0 });
|
|
await control(id).dispatchEvent("pointercancel", { pointerId: 1, button: 0 });
|
|
}, axis);
|
|
}
|
|
}
|
|
});
|
|
await control("manual-axis-x").check();
|
|
await check("jog-blur-and-hidden-tab-stop", async () => {
|
|
await hold("manual-jog-positive", () => page.evaluate(() => window.dispatchEvent(new Event("blur"))), "x");
|
|
await hold("manual-jog-positive", () => control("tab-diagnostics").click(), "x");
|
|
await control("tab-manual").click();
|
|
});
|
|
await check("jog-fast-release-and-keyboard-hold", async () => {
|
|
let starts = await commandCount("JOG_START"), stops = await commandCount("JOG_STOP");
|
|
await control("manual-jog-positive").evaluate((button) => {
|
|
button.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 9, button: 0 }));
|
|
button.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 9, button: 0 }));
|
|
});
|
|
await wait((stops) => window.axisMessages.filter((m) => m.kind === "JOG_STOP").length === stops + 1, stops);
|
|
await queueEmpty(); assert.equal(await commandCount("JOG_START"), starts + 1);
|
|
await control("manual-jog-positive").focus();
|
|
starts = await commandCount("JOG_START"); stops = await commandCount("JOG_STOP");
|
|
await page.keyboard.down("Space"); await page.keyboard.down("Space"); await page.waitForTimeout(200); await page.keyboard.up("Space");
|
|
await wait((stops) => window.axisMessages.filter((m) => m.kind === "JOG_STOP").length === stops + 1, stops);
|
|
await queueEmpty(); assert.equal(await commandCount("JOG_START"), starts + 1);
|
|
});
|
|
await check("increment-completes-after-release", async () => {
|
|
await control("manual-jog-increment").selectOption("0.1000");
|
|
const before = await pose(), seq = await latestSeq(), stops = await commandCount("JOG_STOP");
|
|
await control("manual-jog-positive").press("Space"); await ack("JOG_START", seq); await queueEmpty();
|
|
assert.ok(Math.abs((await pose()).x - before.x - 0.1) < 1e-8);
|
|
assert.equal(await commandCount("JOG_STOP"), stops);
|
|
await control("manual-jog-increment").selectOption("Continuous");
|
|
});
|
|
await check("touch-off-preview-and-relative-coordinate", async () => {
|
|
const before = await pose();
|
|
dialogAnswers = ["2", "P1 G54"];
|
|
await clickAck("manual-touch-off", "TOUCH_OFF");
|
|
assert.deepEqual(await pose(), before);
|
|
await control("tab-dro").click(); await control("dro-coordinate").selectOption("relative");
|
|
assert.equal(Number(await page.locator(".dro-row").first().locator("output").first().textContent()), 2);
|
|
await control("dro-coordinate").selectOption("machine"); await control("tab-preview").click();
|
|
dialogAnswers = [String(before.x), "P1 G54"];
|
|
await clickAck("manual-touch-off", "TOUCH_OFF");
|
|
});
|
|
await check("spindle-key-toggle-and-release", async () => {
|
|
await clickAck("manual-spindle-ccw", "SPINDLE_COMMAND");
|
|
let seq = await latestSeq(); await page.keyboard.press("F9"); await ack("SPINDLE_COMMAND", seq);
|
|
assert.equal((await messages("SPINDLE_COMMAND")).at(-1).payload.direction, 0);
|
|
await clickAck("manual-spindle-cw", "SPINDLE_COMMAND");
|
|
seq = await latestSeq(); await page.keyboard.press("F10"); await ack("SPINDLE_COMMAND", seq);
|
|
assert.equal((await messages("SPINDLE_COMMAND")).at(-1).payload.direction, 0);
|
|
await clickAck("manual-spindle-cw", "SPINDLE_COMMAND");
|
|
seq = await latestSeq(); await control("manual-spindle-increase").dispatchEvent("pointerdown", { pointerId: 2, button: 0 }); await ack("SPINDLE_COMMAND", seq);
|
|
await page.evaluate(() => window.dispatchEvent(new Event("blur"))); await ack("SPINDLE_CONSTANT", seq);
|
|
await clickAck("manual-spindle-stop", "SPINDLE_COMMAND");
|
|
});
|
|
await check("paused-open-gate-and-source-key-case", async () => {
|
|
await clickAck("toolbar-run", "PROGRAM_RUN"); await clickAck("toolbar-pause-resume", "PROGRAM_PAUSE");
|
|
assert.ok(await control("toolbar-open").isDisabled());
|
|
assert.ok(await control("toolbar-open").getAttribute("title"));
|
|
const count = await commandCount("PROGRAM_LOAD");
|
|
await page.keyboard.press("Control+r"); await page.waitForTimeout(100);
|
|
assert.equal(await commandCount("PROGRAM_LOAD"), count);
|
|
await clickAck("toolbar-stop", "PROGRAM_ABORT");
|
|
await wait(() => document.querySelector("main")?.dataset.axisTaskMode === "MANUAL");
|
|
const commands = await commandCount("PROGRAM_RUN");
|
|
await page.keyboard.press("Control+r"); await ready();
|
|
assert.equal(await commandCount("PROGRAM_RUN"), commands, "Ctrl-R must never become Run");
|
|
await page.keyboard.press("R"); assert.equal(await commandCount("PROGRAM_RUN"), commands);
|
|
});
|
|
await check("repeat-step-and-restore-tab-mode", async () => {
|
|
const input = control("program-file-input");
|
|
const seq = await latestSeq();
|
|
await input.setInputFiles({ name: "source-step.ngc", mimeType: "text/plain", buffer: Buffer.from("G21 G90\nG1 X2 F600\nG1 X3\nM2\n") });
|
|
await ack("PROGRAM_LOAD", seq); await ready();
|
|
const x = (await pose()).x;
|
|
await clickAck("toolbar-step", "PROGRAM_STEP");
|
|
assert.equal((await pose()).x, x);
|
|
await clickAck("toolbar-step", "PROGRAM_STEP");
|
|
await clickAck("toolbar-step", "PROGRAM_STEP");
|
|
await clickAck("toolbar-step", "PROGRAM_STEP"); await queueEmpty();
|
|
assert.ok(Math.abs((await pose()).x - 2) < 1e-7);
|
|
await clickAck("toolbar-step", "PROGRAM_STEP"); await clickAck("toolbar-step", "PROGRAM_STEP"); await queueEmpty();
|
|
assert.ok(Math.abs((await pose()).x - 3) < 1e-7);
|
|
await clickAck("toolbar-stop", "PROGRAM_ABORT");
|
|
await wait(() => document.querySelector("main")?.dataset.axisTaskMode === "MANUAL");
|
|
});
|
|
await check("about-modal-and-estop-from-editor", async () => {
|
|
await control("menu-help").click(); await control("menu-help-about").click();
|
|
assert.equal(await page.evaluate(() => document.activeElement?.getAttribute("data-axis-control")), "about-close");
|
|
const runs = await commandCount("PROGRAM_RUN"); await page.keyboard.press("r"); assert.equal(await commandCount("PROGRAM_RUN"), runs);
|
|
await page.keyboard.press("Escape"); assert.equal(await control("about-close").count(), 0);
|
|
await control("program-editor").focus(); const seq = await latestSeq(); await page.keyboard.press("F1"); await ack("ESTOP", seq);
|
|
await wait(() => document.querySelector("[data-axis-state-machine]")?.dataset.axisStateMachine === "ESTOP");
|
|
assert.ok(await control("toolbar-power").isDisabled());
|
|
await clickAck("toolbar-estop", "ESTOP"); await clickAck("toolbar-power", "POWER");
|
|
});
|
|
await check("desktop-mobile-canvas-and-layout", async () => {
|
|
for (const viewport of [{ width: 1440, height: 1000 }, { width: 390, height: 844 }]) {
|
|
await page.setViewportSize(viewport); await control("tab-preview").click(); await control("toolbar-view-p").click(); await page.waitForTimeout(200);
|
|
for (const preset of ["z", "z2", "x", "y", "y2", "p"]) {
|
|
await control(`toolbar-view-${preset}`).click();
|
|
await wait((preset) => document.querySelector("[data-xyzac-preview]")?.dataset.xyzacProjection === (preset === "p" ? "perspective" : "orthographic"), preset);
|
|
}
|
|
const before = await pixels(); assert.ok(before.colors > 20, JSON.stringify(before));
|
|
await control("toolbar-rotate").click(); await page.waitForTimeout(250);
|
|
const stationary = await pixels(); await page.waitForTimeout(250);
|
|
assert.equal((await pixels()).hash, stationary.hash, "rotate mode must not animate the camera");
|
|
await control("preview-layer-velocity").check();
|
|
await wait(() => document.querySelector(".axis-preview-hud")?.textContent.includes("mm/min"));
|
|
await control("preview-layer-velocity").uncheck();
|
|
await control("preview-layer-limits").check();
|
|
await wait(() => document.querySelector("[data-xyzac-preview]")?.dataset.xyzacLimitsVisible === "true");
|
|
await control("preview-layer-limits").uncheck();
|
|
await control("preview-layer-extents").check();
|
|
await wait(() => document.querySelector("[data-xyzac-preview]")?.dataset.xyzacExtentsVisible === "true");
|
|
await control("preview-layer-extents").uncheck();
|
|
await control("toolbar-view-x").click(); await page.waitForTimeout(200);
|
|
const after = await pixels(); assert.notEqual(after.hash, before.hash, "view did not change canvas");
|
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), true, "horizontal page overflow");
|
|
assert.equal(await page.locator("img").evaluateAll((images) => images.some((image) => !image.complete || image.naturalWidth === 0)), false);
|
|
await page.screenshot({ path: `/tmp/axis-source-flow-${viewport.width}.png`, fullPage: true });
|
|
}
|
|
});
|
|
assert.deepEqual(errors, []);
|
|
console.log(JSON.stringify({ schema: "axis-panel-source-flow-browser.v1", status: "verified", url, checks, browser_errors: errors }));
|
|
} catch (error) {
|
|
process.stderr.write(JSON.stringify(await page.evaluate(() => ({ messages: window.axisMessages.slice(-12), results: window.axisResults.slice(-3).map((entry) => ({ kind: entry.kind, seq: entry.seq, status: entry.payload?.status, reason: entry.payload?.gate_reason ?? entry.payload?.message_key })) }))) + "\n");
|
|
await page.screenshot({ path: "/tmp/axis-source-flow-failure.png", fullPage: true });
|
|
throw error;
|
|
} finally { await browser.close(); }
|