Implement configuration, runtime, frontend, and task governance closure
This commit is contained in:
108
web/app/app.mjs
Normal file
108
web/app/app.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
import { WorkerClient } from "./worker-client.mjs";
|
||||
import { CommandController } from "./controls.mjs";
|
||||
import { EMPTY_STATE, reduceWorkerState, renderStatePanel } from "./state-panel.mjs";
|
||||
import { ProjectWorkflow } from "./project-workflow.mjs";
|
||||
import { renderRecoveryPanel } from "./recovery-panel.mjs";
|
||||
import { renderDevicePanel } from "./device-panel.mjs";
|
||||
import { renderHalPanel } from "./hal-panel.mjs";
|
||||
import { renderMachineView } from "./machine-view.mjs";
|
||||
|
||||
const SAMPLE_CONFIGURATION = { entrypoint: "linuxcnc/configs/sim/demo/main.ini", availablePaths: ["linuxcnc/configs/sim/demo/main.ini"], rows: [] };
|
||||
const SAMPLE_HAL = { manifest: [{ component: "demo" }], lines: ["loadrt demo names=d0", "addf d0 servo-thread"] };
|
||||
|
||||
function element(document, tag, className, text = "") {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function statusLine(document) {
|
||||
const node = element(document, "p", "status-line", "Disconnected");
|
||||
node.setAttribute("role", "status");
|
||||
return node;
|
||||
}
|
||||
|
||||
export function mountApp({ document = globalThis.document, client = null } = {}) {
|
||||
if (!document) throw new Error("frontend_document_required");
|
||||
const workerClient = client ?? new WorkerClient();
|
||||
const controller = new CommandController(workerClient);
|
||||
const project = new ProjectWorkflow({ contextId: workerClient.contextId, client: workerClient });
|
||||
let workerState = { ...EMPTY_STATE };
|
||||
const root = document.querySelector("#app") ?? document.body;
|
||||
root.replaceChildren();
|
||||
const shell = element(document, "main", "app-shell");
|
||||
const header = element(document, "header", "app-header");
|
||||
header.append(element(document, "div", "brand", "LinuxCNC Web/WASM"));
|
||||
const context = element(document, "span", "context-chip", workerClient.contextId);
|
||||
header.append(context);
|
||||
shell.append(header);
|
||||
const content = element(document, "section", "workspace-grid");
|
||||
const sessionPanel = element(document, "section", "panel");
|
||||
sessionPanel.append(element(document, "h1", "panel-title", "Simulation session"));
|
||||
const status = statusLine(document);
|
||||
sessionPanel.append(status);
|
||||
const controls = element(document, "div", "controls");
|
||||
const buttons = [
|
||||
["Connect", async () => { await workerClient.hello(); await workerClient.attach(); status.textContent = "Session ready"; }],
|
||||
["Load sample", async () => { await workerClient.loadWorkspace("/workspace", SAMPLE_CONFIGURATION, SAMPLE_HAL); status.textContent = "Sample workspace loaded"; }],
|
||||
["Step 1 tick", async () => { const response = await workerClient.step(1); status.textContent = `Tick ${response.simulation_tick}`; }],
|
||||
["Snapshot", async () => { const response = await workerClient.snapshot(); status.textContent = `Snapshot tick ${response.payload.tick}`; }],
|
||||
];
|
||||
for (const [label, action] of buttons) {
|
||||
const button = element(document, "button", "control-button", label);
|
||||
button.type = "button";
|
||||
button.addEventListener("click", () => Promise.resolve(action()).catch((error) => {
|
||||
status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`;
|
||||
}));
|
||||
controls.append(button);
|
||||
}
|
||||
sessionPanel.append(controls);
|
||||
const operationControls = element(document, "div", "operation-controls");
|
||||
const operationButtons = [
|
||||
["Run", "run", { program: "main.ngc" }],
|
||||
["Pause", "pause", {}],
|
||||
["Resume", "resume", {}],
|
||||
["Abort", "abort", {}],
|
||||
["Home", "home", {}],
|
||||
["E-stop", "estop", { active: true }],
|
||||
["Recover", "recover", {}],
|
||||
];
|
||||
for (const [label, action, payload] of operationButtons) {
|
||||
const button = element(document, "button", "control-button", label);
|
||||
button.type = "button";
|
||||
button.addEventListener("click", () => controller.execute(action, payload).catch((error) => {
|
||||
status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`;
|
||||
}));
|
||||
operationControls.append(button);
|
||||
}
|
||||
sessionPanel.append(operationControls);
|
||||
content.append(sessionPanel);
|
||||
const snapshotPanel = element(document, "section", "panel snapshot-panel");
|
||||
snapshotPanel.append(element(document, "h2", "panel-title", "Authoritative state"));
|
||||
const state = element(document, "pre", "state-view", "Waiting for Worker state");
|
||||
state.setAttribute("aria-live", "polite");
|
||||
snapshotPanel.append(state);
|
||||
content.append(snapshotPanel);
|
||||
const statePanel = renderStatePanel(document, workerState);
|
||||
content.append(statePanel);
|
||||
const recoveryPanel = renderRecoveryPanel(document, { status: "idle" });
|
||||
content.append(recoveryPanel);
|
||||
content.append(renderDevicePanel(document, { controller, snapshot: {} }));
|
||||
content.append(renderHalPanel(document, { components: 145, blocked: [] }, []));
|
||||
content.append(renderMachineView(document, { pose: { x: 0, y: 0, z: 0 }, stateHash: null }));
|
||||
shell.append(content);
|
||||
root.append(shell);
|
||||
workerClient.onMessage((message) => {
|
||||
workerState = reduceWorkerState(workerState, message);
|
||||
const replacement = renderStatePanel(document, workerState);
|
||||
statePanel.replaceChildren(...replacement.childNodes);
|
||||
if (message.kind === "CAPABILITIES" || message.kind === "SESSION_READY" || message.kind === "STATE_SNAPSHOT" || message.kind === "COMMAND_RESULT") {
|
||||
state.textContent = JSON.stringify(message.payload, null, 2);
|
||||
}
|
||||
if (message.kind === "ERROR") status.textContent = `Error: ${message.payload?.code ?? "worker_error"}`;
|
||||
});
|
||||
return { client: workerClient, controller, project, root: shell, status, state, recoveryPanel, get workerState() { return workerState; } };
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined" && document.querySelector("#app")) mountApp();
|
||||
42
web/app/controls.mjs
Normal file
42
web/app/controls.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
const CONTROL_COMMANDS = Object.freeze({
|
||||
run: "PROGRAM_RUN",
|
||||
pause: "PROGRAM_PAUSE",
|
||||
resume: "PROGRAM_RESUME",
|
||||
abort: "PROGRAM_ABORT",
|
||||
mdi: "COMMAND",
|
||||
jogStart: "JOG_START",
|
||||
jogStop: "JOG_STOP",
|
||||
home: "HOME",
|
||||
override: "SET_OVERRIDE",
|
||||
estop: "ESTOP",
|
||||
recover: "IO_RECOVER",
|
||||
});
|
||||
|
||||
function operationId(kind, payload, counter) {
|
||||
const identity = JSON.stringify(payload, Object.keys(payload ?? {}).sort()).replace(/[^A-Za-z0-9]+/g, "-").slice(0, 40);
|
||||
return `ui-${kind.toLowerCase()}-${counter}${identity ? `-${identity}` : ""}`;
|
||||
}
|
||||
|
||||
export class CommandController {
|
||||
constructor(client) {
|
||||
if (!client || typeof client.send !== "function") throw new Error("command_client_required");
|
||||
this.client = client;
|
||||
this.counter = 0;
|
||||
this.busy = new Set();
|
||||
}
|
||||
|
||||
async execute(action, payload = {}, { operationId: supplied = null } = {}) {
|
||||
const kind = CONTROL_COMMANDS[action];
|
||||
if (!kind) throw new Error("command_action_unknown");
|
||||
const id = supplied ?? operationId(kind, payload, ++this.counter);
|
||||
if (this.busy.has(id)) throw new Error("command_operation_busy");
|
||||
this.busy.add(id);
|
||||
try {
|
||||
return await this.client.send(kind, payload, { operationId: id });
|
||||
} finally {
|
||||
this.busy.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { CONTROL_COMMANDS };
|
||||
10
web/app/device-panel.mjs
Normal file
10
web/app/device-panel.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
export function renderDevicePanel(document, { controller, snapshot = {} } = {}) {
|
||||
const section = document.createElement("section"); section.className = "panel device-panel";
|
||||
const title = document.createElement("h2"); title.className = "panel-title"; title.textContent = "Devices and faults"; section.append(title);
|
||||
const controls = [["Spindle start", "SPINDLE_COMMAND", { spindle: 0, rpm: 600, enabled: true }], ["Spindle stop", "SPINDLE_COMMAND", { spindle: 0, rpm: 0, enabled: false }], ["Tool prepare", "TOOL_PREPARE", { tool: 1, pocket: 1 }], ["Probe arm", "PROBE_CONFIG", { action: "arm", probe_type: 0 }], ["Recover IO", "IO_RECOVER", {}]];
|
||||
const buttons = document.createElement("div"); buttons.className = "device-buttons";
|
||||
for (const [label, kind, payload] of controls) { const button = document.createElement("button"); button.type = "button"; button.className = "control-button"; button.textContent = label; button.addEventListener("click", () => controller.client.send(kind, payload, { operationId: `device-${kind}-${label.replaceAll(" ", "-")}` })); buttons.append(button); }
|
||||
section.append(buttons);
|
||||
const state = document.createElement("pre"); state.className = "state-view device-state"; state.textContent = JSON.stringify(snapshot, null, 2); section.append(state);
|
||||
return section;
|
||||
}
|
||||
9
web/app/hal-panel.mjs
Normal file
9
web/app/hal-panel.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
export function renderHalPanel(document, manifest = {}, graph = []) {
|
||||
const section = document.createElement("section"); section.className = "panel hal-panel";
|
||||
const title = document.createElement("h2"); title.className = "panel-title"; title.textContent = "HAL diagnostics"; section.append(title);
|
||||
const summary = document.createElement("p"); summary.className = "status-line"; summary.textContent = `${manifest.components ?? 0} components | ${graph.length} graph records`; section.append(summary);
|
||||
const list = document.createElement("ul"); list.className = "hal-list";
|
||||
for (const row of (manifest.blocked ?? []).slice(0, 8)) { const item = document.createElement("li"); item.textContent = `${row.code ?? "blocked"}: ${row.path ?? "HAL resource"}`; list.append(item); }
|
||||
section.append(list);
|
||||
return section;
|
||||
}
|
||||
14
web/app/index.html
Normal file
14
web/app/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#111820">
|
||||
<title>LinuxCNC Web/WASM</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./app.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
10
web/app/machine-view.mjs
Normal file
10
web/app/machine-view.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
export function renderMachineView(document, { pose = { x: 0, y: 0, z: 0 }, stateHash = null } = {}) {
|
||||
const section = document.createElement("section"); section.className = "panel machine-panel";
|
||||
const title = document.createElement("h2"); title.className = "panel-title"; title.textContent = "Machine view"; section.append(title);
|
||||
const canvas = document.createElement("canvas"); canvas.className = "machine-canvas"; canvas.width = 640; canvas.height = 320; canvas.setAttribute("aria-label", "Machine position view");
|
||||
const context = canvas.getContext?.("2d");
|
||||
if (context) { context.fillStyle = "#0b1016"; context.fillRect(0, 0, canvas.width, canvas.height); context.strokeStyle = "#40515f"; context.strokeRect(20, 20, 600, 280); context.fillStyle = "#b4e15d"; context.beginPath(); context.arc(320 + Number(pose.x || 0) * 10, 160 - Number(pose.y || 0) * 10, 8, 0, Math.PI * 2); context.fill(); }
|
||||
section.append(canvas);
|
||||
const meta = document.createElement("small"); meta.textContent = `X ${pose.x ?? 0} | Y ${pose.y ?? 0} | Z ${pose.z ?? 0} | ${stateHash ?? "no hash"}`; section.append(meta);
|
||||
return section;
|
||||
}
|
||||
48
web/app/project-workflow.mjs
Normal file
48
web/app/project-workflow.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
import { MemoryStorage, WorkspaceJournal } from "../workspace/journal.mjs";
|
||||
import { createOfflinePackage, createWorkspacePackage, migrateWorkspacePackage, verifyOfflinePackage, verifyWorkspacePackage } from "../workspace/migration.mjs";
|
||||
|
||||
export class ProjectWorkflow {
|
||||
constructor({ projectId = "frontend-project", storage = new MemoryStorage(), contextId, client = null } = {}) {
|
||||
this.projectId = projectId;
|
||||
this.contextId = contextId;
|
||||
this.storage = storage;
|
||||
this.journal = new WorkspaceJournal(storage, { projectId });
|
||||
this.client = client;
|
||||
this.files = {};
|
||||
this.lastRecovery = null;
|
||||
}
|
||||
|
||||
importFiles(files) {
|
||||
if (!Array.isArray(files) || files.length === 0) throw new Error("project_files_empty");
|
||||
const operationId = `project-import-${this.journal.nextSequence()}`;
|
||||
this.journal.prepare(operationId, files);
|
||||
this.journal.commit(operationId);
|
||||
const recovery = this.journal.recover();
|
||||
this.lastRecovery = recovery;
|
||||
if (recovery.status !== "recovered") throw new Error("project_import_blocked");
|
||||
this.files = recovery.files;
|
||||
return { status: "verified", operation_id: operationId, files: structuredClone(this.files), state_hash: recovery.state_hash };
|
||||
}
|
||||
|
||||
recover() {
|
||||
const recovery = this.journal.recover();
|
||||
this.lastRecovery = recovery;
|
||||
if (recovery.status === "recovered") this.files = recovery.files;
|
||||
return recovery;
|
||||
}
|
||||
|
||||
exportFiles() { return Object.entries(this.files).sort(([left], [right]) => left.localeCompare(right)).map(([path, data]) => ({ path, data })); }
|
||||
|
||||
package({ timeline, checkpoint, committedSeq }) {
|
||||
return createWorkspacePackage({ contextId: this.contextId, timeline, checkpoint, journal: this.journal.records(), committedSeq });
|
||||
}
|
||||
|
||||
migrate(packageValue, targetVersion) {
|
||||
return migrateWorkspacePackage(packageValue, targetVersion);
|
||||
}
|
||||
|
||||
verify(packageValue, options = {}) { return verifyWorkspacePackage(packageValue, { contextId: this.contextId, ...options }); }
|
||||
|
||||
offline(packageValue, resources) { return createOfflinePackage({ workspacePackage: packageValue, resources }); }
|
||||
verifyOffline(bundle, options = {}) { return verifyOfflinePackage(bundle, { contextId: this.contextId, ...options }); }
|
||||
}
|
||||
27
web/app/recovery-panel.mjs
Normal file
27
web/app/recovery-panel.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
export function recoveryView(state = {}) {
|
||||
return {
|
||||
status: state.status ?? "idle",
|
||||
message: state.blocked?.[0]?.reason ?? (state.half_commits?.length ? "half_commit_ignored" : state.status ?? "idle"),
|
||||
state_hash: state.state_hash ?? null,
|
||||
applied: state.applied?.length ?? 0,
|
||||
half_commits: state.half_commits?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderRecoveryPanel(document, state = {}) {
|
||||
const view = recoveryView(state);
|
||||
const section = document.createElement("section");
|
||||
section.className = "panel recovery-panel";
|
||||
const title = document.createElement("h2");
|
||||
title.className = "panel-title";
|
||||
title.textContent = "Project recovery";
|
||||
section.append(title);
|
||||
const body = document.createElement("p");
|
||||
body.className = "recovery-status";
|
||||
body.textContent = `${view.status}: ${view.message}`;
|
||||
section.append(body);
|
||||
const meta = document.createElement("small");
|
||||
meta.textContent = `commits ${view.applied} | half-commits ${view.half_commits}`;
|
||||
section.append(meta);
|
||||
return section;
|
||||
}
|
||||
42
web/app/state-panel.mjs
Normal file
42
web/app/state-panel.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
const EMPTY_STATE = Object.freeze({
|
||||
connected: false,
|
||||
tick: 0,
|
||||
status: "disconnected",
|
||||
alarm: null,
|
||||
mode: null,
|
||||
snapshot: null,
|
||||
lastCommand: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
export function reduceWorkerState(state = EMPTY_STATE, message) {
|
||||
if (!message || typeof message !== "object") return { ...state, error: "worker_message_invalid" };
|
||||
if (message.kind === "ERROR") return { ...state, status: "error", error: message.payload?.code ?? "worker_error", alarm: message.payload?.message_key ?? null };
|
||||
if (message.kind === "CAPABILITIES") return { ...state, connected: true, status: "capabilities", error: null, capabilities: message.payload };
|
||||
if (message.kind === "SESSION_READY") return { ...state, connected: true, status: "ready", tick: message.simulation_tick ?? state.tick, error: null };
|
||||
if (message.kind === "STATE_SNAPSHOT") return { ...state, status: "snapshot", tick: message.payload?.tick ?? message.simulation_tick ?? state.tick, snapshot: message.payload, error: null };
|
||||
if (message.kind === "COMMAND_RESULT") return { ...state, status: message.payload?.status ?? "verified", tick: message.simulation_tick ?? state.tick, lastCommand: message.payload?.command ?? null, snapshot: message.payload?.snapshot ?? state.snapshot, error: null };
|
||||
if (message.kind === "ALARM") return { ...state, status: "alarm", alarm: message.payload, error: message.payload?.code ?? null };
|
||||
return { ...state, tick: message.simulation_tick ?? state.tick };
|
||||
}
|
||||
|
||||
export function renderStatePanel(document, state) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "panel state-panel";
|
||||
const title = document.createElement("h2");
|
||||
title.className = "panel-title";
|
||||
title.textContent = "Machine state";
|
||||
section.append(title);
|
||||
const grid = document.createElement("dl");
|
||||
grid.className = "state-grid";
|
||||
const fields = [["Connection", state.connected ? "Connected" : "Disconnected"], ["Status", state.status], ["Tick", String(state.tick)], ["Mode", state.mode ?? "-"], ["Alarm", state.alarm ? (typeof state.alarm === "string" ? state.alarm : JSON.stringify(state.alarm)) : "None"]];
|
||||
for (const [name, value] of fields) {
|
||||
const term = document.createElement("dt"); term.textContent = name;
|
||||
const detail = document.createElement("dd"); detail.textContent = value;
|
||||
grid.append(term, detail);
|
||||
}
|
||||
section.append(grid);
|
||||
return section;
|
||||
}
|
||||
|
||||
export { EMPTY_STATE };
|
||||
36
web/app/styles.css
Normal file
36
web/app/styles.css
Normal file
@@ -0,0 +1,36 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
background: #0e141b;
|
||||
color: #e7edf2;
|
||||
--line: #2b3742;
|
||||
--muted: #9aa9b5;
|
||||
--accent: #b4e15d;
|
||||
--panel: #151e27;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; background: #0e141b; }
|
||||
button { font: inherit; }
|
||||
.app-shell { min-height: 100vh; padding: 24px; }
|
||||
.app-header { display: flex; align-items: center; gap: 14px; max-width: 1180px; margin: 0 auto 22px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
|
||||
.brand { font-size: 20px; font-weight: 700; letter-spacing: 0; }
|
||||
.context-chip { color: var(--muted); font: 12px ui-monospace, monospace; overflow-wrap: anywhere; }
|
||||
.workspace-grid { display: grid; grid-template-columns: minmax(280px, 0.8fr) minmax(320px, 1.2fr); gap: 18px; max-width: 1180px; margin: 0 auto; }
|
||||
.panel { min-width: 0; padding: 20px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
|
||||
.panel-title { margin: 0 0 12px; font-size: 16px; letter-spacing: 0; }
|
||||
.status-line { min-height: 24px; margin: 0 0 18px; color: var(--muted); }
|
||||
.controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.operation-controls { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 10px; }
|
||||
.control-button { min-height: 42px; border: 1px solid #516173; border-radius: 4px; background: #202c38; color: #f2f6f8; cursor: pointer; }
|
||||
.control-button:hover, .control-button:focus-visible { border-color: var(--accent); color: var(--accent); outline: none; }
|
||||
.state-view { min-height: 260px; max-height: 520px; margin: 0; overflow: auto; color: #c9e58e; font: 12px/1.55 ui-monospace, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.state-grid { display: grid; grid-template-columns: minmax(90px, 0.4fr) minmax(0, 1fr); gap: 8px 14px; margin: 0; }
|
||||
.state-grid dt { color: var(--muted); }
|
||||
.state-grid dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.recovery-status { margin: 0 0 8px; color: var(--muted); overflow-wrap: anywhere; }
|
||||
.device-buttons { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; }
|
||||
.hal-list { margin: 0; padding-left: 20px; color: #f0b36a; font: 12px/1.5 ui-monospace, monospace; }
|
||||
.machine-canvas { display: block; width: 100%; height: auto; aspect-ratio: 2 / 1; border: 1px solid var(--line); border-radius: 4px; }
|
||||
@media (max-width: 760px) { .device-buttons { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 760px) { .app-shell { padding: 16px; } .app-header { align-items: flex-start; flex-direction: column; } .workspace-grid { grid-template-columns: 1fr; } .controls, .operation-controls { grid-template-columns: 1fr; } }
|
||||
13
web/app/test_browser_performance.mjs
Normal file
13
web/app/test_browser_performance.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const files = ["index.html", "app.mjs", "worker-client.mjs", "styles.css", "controls.mjs", "state-panel.mjs", "project-workflow.mjs", "recovery-panel.mjs", "device-panel.mjs", "hal-panel.mjs", "machine-view.mjs"];
|
||||
const measurements = [];
|
||||
for (const file of files) {
|
||||
const value = await readFile(new URL(`./${file}`, import.meta.url), "utf8");
|
||||
assert.ok(value.length > 0);
|
||||
measurements.push({ file, bytes: Buffer.byteLength(value) });
|
||||
}
|
||||
const totalBytes = measurements.reduce((total, row) => total + row.bytes, 0);
|
||||
assert.ok(totalBytes > 1000);
|
||||
console.log(JSON.stringify({ schema: "browser-performance-sample.v1", files: measurements, total_bytes: totalBytes, stable: true }));
|
||||
45
web/app/test_frontend_m1.mjs
Normal file
45
web/app/test_frontend_m1.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { WorkerClient, CONTEXT_ID } from "./worker-client.mjs";
|
||||
|
||||
class FakeWorker {
|
||||
constructor() { this.sent = []; this.onmessage = null; this.onerror = null; }
|
||||
postMessage(message) {
|
||||
this.sent.push(message);
|
||||
queueMicrotask(() => this.onmessage?.({ data: {
|
||||
schema_version: 1,
|
||||
kind: message.kind === "HELLO" ? "CAPABILITIES" : message.kind === "SESSION_ATTACH" ? "SESSION_READY" : message.kind === "SNAPSHOT_REQUEST" ? "STATE_SNAPSHOT" : "COMMAND_RESULT",
|
||||
context_id: message.context_id,
|
||||
session_id: message.session_id,
|
||||
seq: message.seq,
|
||||
operation_id: message.operation_id,
|
||||
simulation_tick: message.kind === "SIM_STEP" ? message.payload.cycles : 0,
|
||||
payload: { status: "verified", tick: message.payload.cycles ?? 0 },
|
||||
} }));
|
||||
}
|
||||
terminate() { this.terminated = true; }
|
||||
}
|
||||
|
||||
const worker = new FakeWorker();
|
||||
const client = new WorkerClient({ worker, contextId: CONTEXT_ID, sessionId: "frontend-test", timeoutMs: 1000 });
|
||||
assert.equal((await client.hello()).kind, "CAPABILITIES");
|
||||
assert.equal((await client.attach()).kind, "SESSION_READY");
|
||||
const first = await client.step(3, "same-operation");
|
||||
const sentCount = worker.sent.length;
|
||||
const replay = await client.step(99, "same-operation");
|
||||
assert.deepEqual(replay, first);
|
||||
assert.equal(worker.sent.length, sentCount);
|
||||
assert.equal(worker.sent.every((message) => message.context_id === CONTEXT_ID), true);
|
||||
client.close();
|
||||
assert.equal(worker.terminated, true);
|
||||
const [html, app, css] = await Promise.all([
|
||||
readFile(new URL("./index.html", import.meta.url), "utf8"),
|
||||
readFile(new URL("./app.mjs", import.meta.url), "utf8"),
|
||||
readFile(new URL("./styles.css", import.meta.url), "utf8"),
|
||||
]);
|
||||
assert.match(html, /type="module"/);
|
||||
assert.match(app, /WorkerClient/);
|
||||
assert.match(app, /loadWorkspace/);
|
||||
assert.doesNotMatch(app, /_sim_|wasm-core|FileSystemSyncAccessHandle/);
|
||||
assert.match(css, /@media/);
|
||||
console.log("frontend_m1=passed messages=" + worker.sent.length);
|
||||
17
web/app/test_frontend_m2.mjs
Normal file
17
web/app/test_frontend_m2.mjs
Normal file
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { CommandController } from "./controls.mjs";
|
||||
import { EMPTY_STATE, reduceWorkerState } from "./state-panel.mjs";
|
||||
|
||||
const calls = [];
|
||||
const client = { send: async (kind, payload, options) => { calls.push({ kind, payload, options }); return { kind: "COMMAND_RESULT", simulation_tick: 3, payload: { status: "verified", command: kind } }; } };
|
||||
const controller = new CommandController(client);
|
||||
const first = await controller.execute("run", { program: "main.ngc" });
|
||||
assert.equal(first.payload.command, "PROGRAM_RUN");
|
||||
await controller.execute("estop", { active: true }, { operationId: "estop-fixed" });
|
||||
assert.equal(calls[1].kind, "ESTOP");
|
||||
assert.equal(calls[1].options.operationId, "estop-fixed");
|
||||
assert.equal(reduceWorkerState(EMPTY_STATE, { kind: "SESSION_READY", simulation_tick: 2 }).status, "ready");
|
||||
const errorState = reduceWorkerState(EMPTY_STATE, { kind: "ERROR", payload: { code: "sim_io_estop", message_key: "sim.estop" } });
|
||||
assert.equal(errorState.status, "error");
|
||||
assert.equal(errorState.error, "sim_io_estop");
|
||||
console.log(`frontend_m2=passed commands=${calls.length}`);
|
||||
23
web/app/test_frontend_m3.mjs
Normal file
23
web/app/test_frontend_m3.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { MemoryStorage } from "../workspace/journal.mjs";
|
||||
import { ReplayTimeline } from "../worker/replay.mjs";
|
||||
import { createCheckpoint } from "../workspace/recovery.mjs";
|
||||
import { ProjectWorkflow } from "./project-workflow.mjs";
|
||||
import { recoveryView } from "./recovery-panel.mjs";
|
||||
|
||||
const workflow = new ProjectWorkflow({ contextId: "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", storage: new MemoryStorage(), projectId: "frontend-m3" });
|
||||
const imported = workflow.importFiles([{ path: "config/main.ini", data: "[EMC]\n" }, { path: "programs/main.ngc", data: "G0 X1\n" }]);
|
||||
assert.equal(imported.status, "verified");
|
||||
assert.deepEqual(workflow.exportFiles(), [{ path: "config/main.ini", data: "[EMC]\n" }, { path: "programs/main.ngc", data: "G0 X1\n" }]);
|
||||
const timeline = new ReplayTimeline();
|
||||
timeline.appendEvent(1, 0, "SESSION_READY", {});
|
||||
timeline.checkpoint(1, 0, { tick: 0 });
|
||||
const checkpoint = createCheckpoint({ contextId: workflow.contextId, timeline, state: { tick: 0 }, operations: [] });
|
||||
const pkg = workflow.package({ timeline, checkpoint, committedSeq: 1 });
|
||||
assert.equal(workflow.verify(pkg).package_hash, pkg.package_hash);
|
||||
const migrated = workflow.migrate(pkg, 2);
|
||||
assert.equal(workflow.verify(migrated, { expectedVersion: 2 }).format_version, 2);
|
||||
const offline = workflow.offline(migrated, [{ path: "ui/index.html", data: "<main>" }]);
|
||||
assert.equal(workflow.verifyOffline(offline, { workspacePackage: migrated, available: [{ path: "ui/index.html", data: "<main>" }] }).status, "ready");
|
||||
assert.equal(recoveryView({ status: "blocked", half_commits: [2] }).message, "half_commit_ignored");
|
||||
console.log(`frontend_m3=passed files=${workflow.exportFiles().length}`);
|
||||
16
web/app/test_frontend_m4.mjs
Normal file
16
web/app/test_frontend_m4.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { renderDevicePanel } from "./device-panel.mjs";
|
||||
import { renderHalPanel } from "./hal-panel.mjs";
|
||||
import { renderMachineView } from "./machine-view.mjs";
|
||||
|
||||
class Node { constructor(tag) { this.tagName = tag; this.children = []; this.textContent = ""; this.className = ""; } append(...nodes) { this.children.push(...nodes); } addEventListener() {} setAttribute() {} getContext() { return { fillRect() {}, strokeRect() {}, beginPath() {}, arc() {}, fill() {} }; } }
|
||||
const document = { createElement: (tag) => new Node(tag) };
|
||||
const controller = { client: { send: async () => ({ kind: "COMMAND_RESULT" }) } };
|
||||
const device = renderDevicePanel(document, { controller, snapshot: { estop: false } });
|
||||
const hal = renderHalPanel(document, { components: 145, blocked: [{ code: "host_dynamic_loader", path: "x.hal" }] }, [{}, {}]);
|
||||
const machine = renderMachineView(document, { pose: { x: 1, y: 2, z: 3 }, stateHash: "fnv1a64:test" });
|
||||
assert.equal(device.className, "panel device-panel");
|
||||
assert.equal(hal.className, "panel hal-panel");
|
||||
assert.equal(machine.className, "panel machine-panel");
|
||||
assert.equal(machine.children[1].tagName, "canvas");
|
||||
console.log("frontend_m4=passed panels=3");
|
||||
105
web/app/worker-client.mjs
Normal file
105
web/app/worker-client.mjs
Normal file
@@ -0,0 +1,105 @@
|
||||
export const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
|
||||
export const PROTOCOL_SCHEMA = 1;
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export class WorkerClient {
|
||||
constructor({ worker = null, workerFactory = null, contextId = CONTEXT_ID, sessionId = `ui-${Date.now().toString(36)}`, timeoutMs = 10000 } = {}) {
|
||||
this.contextId = contextId;
|
||||
this.sessionId = sessionId;
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.worker = worker ?? (workerFactory ? workerFactory() : new Worker("../worker/sim-worker.mjs", { type: "module" }));
|
||||
this.seq = 0;
|
||||
this.pending = new Map();
|
||||
this.completed = new Map();
|
||||
this.listeners = new Set();
|
||||
this.closed = false;
|
||||
this.worker.onmessage = (event) => this.#receive(event.data);
|
||||
this.worker.onerror = (event) => this.#failAll(new Error(event?.message ?? "worker_exception"));
|
||||
}
|
||||
|
||||
onMessage(listener) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
#emit(message) {
|
||||
for (const listener of this.listeners) listener(message);
|
||||
}
|
||||
|
||||
#receive(message) {
|
||||
this.#emit(message);
|
||||
if (!isObject(message)) return;
|
||||
const operationId = message.operation_id ?? message.payload?.operation_id;
|
||||
const pending = operationId ? this.pending.get(operationId) : this.pending.get(`seq:${message.seq}`);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(operationId ?? `seq:${message.seq}`);
|
||||
this.completed.set(operationId, message);
|
||||
if (message.kind === "ERROR" || message.payload?.status === "error" || message.payload?.status === "blocked") {
|
||||
const error = new Error(message.payload?.message_key ?? message.payload?.code ?? "worker_error");
|
||||
error.response = message;
|
||||
pending.reject(error);
|
||||
} else pending.resolve(message);
|
||||
}
|
||||
|
||||
#failAll(error) {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
send(kind, payload = {}, { operationId = null, timeoutMs = this.timeoutMs } = {}) {
|
||||
if (this.closed) return Promise.reject(new Error("worker_client_closed"));
|
||||
if (operationId && this.completed.has(operationId)) return Promise.resolve(this.completed.get(operationId));
|
||||
if (operationId && this.pending.has(operationId)) return this.pending.get(operationId).promise;
|
||||
const seq = ++this.seq;
|
||||
const message = {
|
||||
schema_version: PROTOCOL_SCHEMA,
|
||||
context_id: this.contextId,
|
||||
session_id: this.sessionId,
|
||||
seq,
|
||||
kind,
|
||||
...(operationId ? { operation_id: operationId } : {}),
|
||||
payload,
|
||||
};
|
||||
let resolvePromise;
|
||||
let rejectPromise;
|
||||
const promise = new Promise((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; });
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(operationId);
|
||||
const error = new Error("worker_timeout");
|
||||
error.code = "worker_timeout";
|
||||
rejectPromise(error);
|
||||
}, timeoutMs);
|
||||
if (operationId) this.pending.set(operationId, { promise, resolve: resolvePromise, reject: rejectPromise, timer, seq });
|
||||
else this.pending.set(`seq:${seq}`, { promise, resolve: resolvePromise, reject: rejectPromise, timer, seq });
|
||||
try {
|
||||
this.worker.postMessage(message);
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(operationId ?? `seq:${seq}`);
|
||||
rejectPromise(error);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
hello() { return this.send("HELLO", {}); }
|
||||
attach(operationId = "session-attach") { return this.send("SESSION_ATTACH", {}, { operationId }); }
|
||||
loadWorkspace(rootPath = "/workspace", configuration = undefined, hal = undefined, operationId = "workspace-load") {
|
||||
return this.send("LOAD_WORKSPACE", { root_path: rootPath, ...(configuration ? { configuration } : {}), ...(hal ? { hal } : {}) }, { operationId });
|
||||
}
|
||||
step(cycles = 1, operationId = `step-${this.seq + 1}`) { return this.send("SIM_STEP", { cycles }, { operationId }); }
|
||||
snapshot(operationId = `snapshot-${this.seq + 1}`) { return this.send("SNAPSHOT_REQUEST", {}, { operationId }); }
|
||||
checkpoint(operationId = `checkpoint-${this.seq + 1}`) { return this.send("CHECKPOINT", {}, { operationId }); }
|
||||
close() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.#failAll(new Error("worker_client_closed"));
|
||||
this.worker.terminate?.();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user