From a1c9db0014dd630540622bd39c3f942822232bf5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:06:17 +0000 Subject: [PATCH] Add fpp-diagram-render: CLI that renders FPP diagrams to SVG A Node command line (editors/code/cli) that produces the component, topology, and connection-group diagrams of the VS Code panel as standalone SVG, from the same pipeline: fpp-diagram --format sprotty for lowering, the extension's FppLayoutEngine/FppDiagramConfig for ELK layout, and the webview shapes and stylesheet for drawing. Label sizes are estimated from Helvetica metrics in place of the webview's DOM measurement. Multiple .fpp inputs are accepted; include directives are inlined relative to each file so a whole source tree can be passed. Additive only: new cli/ sources and tsconfig, a fourth webpack bundle (dist/fpp-diagram-render.js), a smoke test with fixtures, and package.json scripts (compile:cli, render, test:render). Existing Rust and TypeScript are unchanged. Co-authored-by: Michael Starch --- editors/code/cli/README.md | 43 ++++++ editors/code/cli/src/fpp-source.ts | 94 +++++++++++++ editors/code/cli/src/main.ts | 135 ++++++++++++++++++ editors/code/cli/src/svg.ts | 160 ++++++++++++++++++++++ editors/code/cli/src/text-metrics.ts | 43 ++++++ editors/code/cli/tsconfig.json | 18 +++ editors/code/package.json | 8 +- editors/code/test/fixtures/component.fpp | 14 ++ editors/code/test/fixtures/component.json | 6 + editors/code/test/smoke-render.js | 75 ++++++++++ editors/code/webpack.config.js | 29 +++- 11 files changed, 623 insertions(+), 2 deletions(-) create mode 100644 editors/code/cli/README.md create mode 100644 editors/code/cli/src/fpp-source.ts create mode 100644 editors/code/cli/src/main.ts create mode 100644 editors/code/cli/src/svg.ts create mode 100644 editors/code/cli/src/text-metrics.ts create mode 100644 editors/code/cli/tsconfig.json create mode 100644 editors/code/test/fixtures/component.fpp create mode 100644 editors/code/test/fixtures/component.json create mode 100644 editors/code/test/smoke-render.js diff --git a/editors/code/cli/README.md b/editors/code/cli/README.md new file mode 100644 index 00000000..a23178c6 --- /dev/null +++ b/editors/code/cli/README.md @@ -0,0 +1,43 @@ +# fpp-diagram-render + +Command-line rendering of FPP diagrams to SVG, using the same pipeline as the +VS Code extension's diagram panel: + +1. `fpp-diagram --format sprotty` (the Rust `fpp_diagram` lowering the language + server uses) produces a sprotty `SGraph`. +2. Labels are sized from font metrics (the webview measures them in the DOM). +3. The extension's ELK layout (`src/diagram/layout.ts`, + `src/diagram/layout-config.ts`) places nodes, ports and labels. +4. The laid-out graph is written as standalone SVG with the webview's shapes + and stylesheet (`webview/css/diagram.css`, theme variables resolved). + +## Build + +```sh +cargo build --release -p fpp_diagram --bin fpp-diagram # repo root +cd editors/code && yarn install && yarn build # emits dist/fpp-diagram-render.js +``` + +## Use + +```sh +# One component from the F Prime framework sources +node dist/fpp-diagram-render.js --name Svc.CommandDispatcher -o CommandDispatcher.svg \ + $FPRIME/cmake/platform/unix/Platform/PlatformTypes.fpp \ + $(find $FPRIME/Fpp $FPRIME/Fw $FPRIME/Svc $FPRIME/Drv $FPRIME/Os $FPRIME/Utils $FPRIME/default/config -name '*.fpp') + +# A topology, pruning unconnected ports +node dist/fpp-diagram-render.js --kind topology --name Ref.Ref --hide-unused-ports -o Ref.svg + +# PNG, if wanted (any SVG rasterizer works; headless Chrome shown) +google-chrome --headless=new --screenshot=CommandDispatcher.png --window-size=400,1100 CommandDispatcher.svg +``` + +Multiple `.fpp` files may be given; `include "..."` directives are resolved +relative to each file and inlined before analysis, so a whole source tree can be +passed. The `fpp-diagram` binary is found in `target/{release,debug}` of the +workspace or on `PATH`; override with `--fpp-diagram`. + +`--kind` accepts `component` (default), `topology`, and `connectionGroup` +(`--name .`). State machines are not rendered by this tool; +`fpp-diagram --format mermaid` already emits them as Mermaid text. diff --git a/editors/code/cli/src/fpp-source.ts b/editors/code/cli/src/fpp-source.ts new file mode 100644 index 00000000..717c292a --- /dev/null +++ b/editors/code/cli/src/fpp-source.ts @@ -0,0 +1,94 @@ +/** + * Front end of the CLI: turn a set of FPP files into a sprotty `SGraph` using + * the existing `fpp-diagram` binary (the same `fpp_diagram` lowering the + * language server runs for the VS Code diagram panel). + * + * `fpp-diagram` reads a single translation unit, so the input files are + * concatenated into one temporary file. `include "..."` directives are + * resolved relative to the file that contains them and inlined, which keeps + * the concatenated unit self-contained. + */ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { spawnSync } from "child_process"; +import { SGraph } from "sprotty-protocol"; + +const INCLUDE_RE = /^(\s*)include\s+"([^"]+)"\s*$/; + +/** Inline `include` directives of a single FPP file, recursively. */ +export function inlineIncludes(file: string, seen: Set = new Set()): string { + const resolved = path.resolve(file); + if (seen.has(resolved)) { + throw new Error(`include cycle at ${resolved}`); + } + seen.add(resolved); + const dir = path.dirname(resolved); + const lines = fs.readFileSync(resolved, "utf8").split(/\r?\n/); + const out: string[] = []; + for (const line of lines) { + const m = INCLUDE_RE.exec(line); + if (m) { + out.push(inlineIncludes(path.join(dir, m[2]), seen)); + } else { + out.push(line); + } + } + seen.delete(resolved); + return out.join("\n"); +} + +/** Concatenate FPP files into one translation unit with includes inlined. */ +export function concatenateSources(files: string[]): string { + return files.map(f => inlineIncludes(f)).join("\n\n"); +} + +/** Candidate locations for `fpp-diagram` when none is given explicitly. */ +export function findFppDiagram(explicit?: string): string { + if (explicit) { + return explicit; + } + // A workspace build next to this extension (editors/code -> repo root). + const repoRoot = path.resolve(__dirname, "..", "..", ".."); + for (const profile of ["release", "debug"]) { + const candidate = path.join(repoRoot, "target", profile, "fpp-diagram"); + if (fs.existsSync(candidate)) { + return candidate; + } + } + return "fpp-diagram"; // rely on PATH +} + +export interface LowerOptions { + fppDiagram: string; + kind: string; + name: string; + hideUnusedPorts: boolean; +} + +/** Run `fpp-diagram` on the FPP files and parse its sprotty JSON. */ +export function lowerToSGraph(files: string[], opts: LowerOptions): SGraph { + const unit = concatenateSources(files); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fpp-diagram-render-")); + const tmpFile = path.join(tmpDir, "unit.fpp"); + try { + fs.writeFileSync(tmpFile, unit); + const args = ["--kind", opts.kind, "--name", opts.name, "--format", "sprotty"]; + if (opts.hideUnusedPorts) { + args.push("--hide-unused-ports"); + } + args.push(tmpFile); + const result = spawnSync(opts.fppDiagram, args, { encoding: "utf8", maxBuffer: 1 << 28 }); + if (result.error) { + throw new Error(`failed to run ${opts.fppDiagram}: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error( + `${opts.fppDiagram} exited with ${result.status}\n${result.stderr}${result.stdout}` + ); + } + return JSON.parse(result.stdout) as SGraph; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} diff --git a/editors/code/cli/src/main.ts b/editors/code/cli/src/main.ts new file mode 100644 index 00000000..34d72ac5 --- /dev/null +++ b/editors/code/cli/src/main.ts @@ -0,0 +1,135 @@ +/** + * `fpp-diagram-render`: command-line rendering of FPP diagrams to SVG. + * + * Pipeline (same stages as the VS Code diagram panel, minus the webview): + * 1. `fpp-diagram --format sprotty` lowers the FPP model to a sprotty SGraph. + * 2. Labels get sizes (the webview measures them in the DOM; here they are + * estimated from font metrics). + * 3. `FppLayoutEngine` + `FppDiagramConfig` (the extension's ELK layout) place + * nodes, ports and labels. + * 4. The laid-out graph is written as SVG using the webview's shapes and CSS. + */ +import * as fs from "fs"; +import ELK from "elkjs/lib/elk.bundled.js"; +import { SGraph, SLabel, SModelElement } from "sprotty-protocol"; +import { FppLayoutEngine } from "../../src/diagram/layout"; +import { FppDiagramConfig, DiagramType } from "../../src/diagram/layout-config"; +import { findFppDiagram, lowerToSGraph } from "./fpp-source"; +import { textHeight, textWidth } from "./text-metrics"; +import { Theme, toSvg } from "./svg"; + +interface CliArgs { + name?: string; + kind: DiagramType; + output?: string; + json?: string; + fppDiagram?: string; + hideUnusedPorts: boolean; + theme: Theme; + files: string[]; +} + +const USAGE = `usage: fpp-diagram-render --name [options] ... + +Render an FPP component, topology, or connection group diagram to SVG using the +same lowering (fpp-diagram) and layout (ELK) as the VS Code extension. + +options: + --name fully qualified name, e.g. Svc.CommandDispatcher + (connection group: .) + --kind component | topology | connectionGroup [component] + -o, --output SVG output path [stdout] + --json read a sprotty SGraph JSON instead of running fpp-diagram + --fpp-diagram fpp-diagram binary [target/{release,debug}/fpp-diagram, PATH] + --hide-unused-ports prune ports with no connections (topologies) + --theme color palette [dark] + -h, --help show this help + +Multiple .fpp files may be given; \`include\` directives are resolved relative to +each file and inlined before analysis.`; + +function parseArgs(argv: string[]): CliArgs { + const args: CliArgs = { kind: DiagramType.component, hideUnusedPorts: false, theme: "dark", files: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + const next = () => { + if (i + 1 >= argv.length) throw new Error(`${a} requires a value`); + return argv[++i]; + }; + switch (a) { + case "--name": args.name = next(); break; + case "--kind": { + const k = next(); + if (!Object.values(DiagramType).includes(k as DiagramType) || k === DiagramType.stateMachine) { + throw new Error(`unsupported kind '${k}' (component | topology | connectionGroup)`); + } + args.kind = k as DiagramType; + break; + } + case "-o": case "--output": args.output = next(); break; + case "--json": args.json = next(); break; + case "--fpp-diagram": args.fppDiagram = next(); break; + case "--hide-unused-ports": args.hideUnusedPorts = true; break; + case "--theme": { + const t = next(); + if (t !== "dark" && t !== "light") throw new Error(`unknown theme '${t}'`); + args.theme = t; + break; + } + case "-h": case "--help": console.log(USAGE); process.exit(0); + default: + if (a.startsWith("-")) throw new Error(`unknown option '${a}'`); + args.files.push(a); + } + } + return args; +} + +/** Give every label a size, as the webview's bounds round-trip would. */ +function measureLabels(element: SModelElement): void { + if (element.type.startsWith("label")) { + const label = element as SLabel; + label.size = { width: textWidth(label.text), height: textHeight() }; + } + for (const child of element.children ?? []) { + measureLabels(child); + } +} + +async function layout(graph: SGraph, kind: DiagramType, hideUnusedPorts: boolean): Promise { + const config = new FppDiagramConfig(); + config.currentDiagramType = kind; + config.hideUnusedPorts = hideUnusedPorts; + const engine = new FppLayoutEngine(() => new ELK(), undefined, config); + return engine.layout(graph); +} + +export async function run(argv: string[]): Promise { + const args = parseArgs(argv); + let graph: SGraph; + if (args.json) { + graph = JSON.parse(fs.readFileSync(args.json, "utf8")) as SGraph; + } else { + if (!args.name) throw new Error("--name is required"); + if (args.files.length === 0) throw new Error("at least one .fpp file is required"); + graph = lowerToSGraph(args.files, { + fppDiagram: findFppDiagram(args.fppDiagram), + kind: args.kind, + name: args.name, + hideUnusedPorts: args.hideUnusedPorts, + }); + } + measureLabels(graph); + const laidOut = await layout(graph, args.kind, args.hideUnusedPorts); + const svg = toSvg(laidOut, args.theme); + if (args.output) { + fs.writeFileSync(args.output, svg); + } else { + process.stdout.write(svg); + } +} + +run(process.argv.slice(2)).catch(e => { + console.error(`fpp-diagram-render: ${e instanceof Error ? e.message : e}`); + process.exit(1); +}); diff --git a/editors/code/cli/src/svg.ts b/editors/code/cli/src/svg.ts new file mode 100644 index 00000000..9072be57 --- /dev/null +++ b/editors/code/cli/src/svg.ts @@ -0,0 +1,160 @@ +/** + * Serialize a laid-out sprotty `SGraph` to standalone SVG. + * + * The markup mirrors the webview views (`webview/src/views.tsx`): component + * nodes are rounded rectangles classed by component kind, ports are small + * rectangles classed by port kind, labels are `` elements, and edges are + * polylines with an arrow marker. The stylesheet is `webview/css/diagram.css` + * with the VS Code theme variables resolved to fixed colors. + */ +import { SModelElement, SNode, SPort, SLabel, SEdge, SGraph, Point } from "sprotty-protocol"; +import { FONT_FAMILY, FONT_SIZE_PX, textBaseline } from "./text-metrics"; + +export type Theme = "dark" | "light"; + +interface Palette { + background: string; + nodeFill: string; + foreground: string; + edge: string; +} + +const PALETTES: Record = { + // VS Code "Dark Modern" defaults for the variables diagram.css uses. + dark: { background: "#1f1f1f", nodeFill: "#0078d4", foreground: "#cccccc", edge: "#cccccc" }, + // VS Code "Light Modern" defaults. + light: { background: "#ffffff", nodeFill: "#005fb8", foreground: "#3b3b3b", edge: "#171184" }, +}; + +function stylesheet(p: Palette): string { + return ` + text { font-family: ${FONT_FAMILY}; font-size: ${FONT_SIZE_PX}px; font-weight: normal; } + .sprotty-node { fill: ${p.nodeFill}; stroke-width: 1; } + .sprotty-label { fill: ${p.foreground}; } + .sprotty-port { fill: ${p.foreground}; stroke: #7b337d; stroke-width: 1; } + .sprotty-edge { fill: none; stroke: ${p.edge}; } + .sprotty-edge-arrow { fill: ${p.edge}; stroke-width: 0; } + .component-active { stroke: #3FB984; } + .component-passive { stroke: #fe92a8; } + .port-sync { fill: #fe92a8; } + .port-guarded { fill: #FDD35C; } + `; +} + +function esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function fmt(n: number): string { + return Number.isInteger(n) ? String(n) : n.toFixed(2); +} + +function basicType(e: SModelElement): string { + const i = e.type.indexOf(":"); + return i >= 0 ? e.type.substring(0, i) : e.type; +} + +type Shaped = SModelElement & { position?: Point; size?: { width: number; height: number } }; + +function renderLabel(label: SLabel): string { + const pos = label.position ?? { x: 0, y: 0 }; + const y = pos.y + textBaseline(); + return `${esc(label.text)}`; +} + +function renderPort(port: SPort & { kind?: string }): string { + const pos = port.position ?? { x: 0, y: 0 }; + const size = port.size ?? { width: 10, height: 10 }; + const classes = ["sprotty-port"]; + if (port.kind === "sync") classes.push("port-sync"); + if (port.kind === "async") classes.push("port-async"); + if (port.kind === "guarded") classes.push("port-guarded"); + const children = (port.children ?? []).map(renderChild).join(""); + return `` + + `${esc(port.id)}${port.kind ? ` (${esc(port.kind)})` : ""}` + + `` + + children + ``; +} + +function renderNode(node: SNode & { kind?: string }): string { + const pos = node.position ?? { x: 0, y: 0 }; + const size = node.size ?? { width: 0, height: 0 }; + const classes = ["sprotty-node", "task"]; + if (node.kind === "active") classes.push("component-active"); + if (node.kind === "queued") classes.push("component-queued"); + if (node.kind === "passive") classes.push("component-passive"); + const children = (node.children ?? []).map(renderChild).join(""); + return `` + + `` + + children + ``; +} + +function renderEdge(edge: SEdge & { detail?: string }): string { + const pts = edge.routingPoints ?? []; + if (pts.length < 2) { + return ""; + } + const d = pts.map((p, i) => `${i === 0 ? "M" : "L"} ${fmt(p.x)},${fmt(p.y)}`).join(" "); + const title = edge.detail ? `${esc(edge.detail)}` : ""; + const labels = (edge.children ?? []).map(renderChild).join(""); + return `${title}${labels}`; +} + +function renderChild(e: SModelElement): string { + switch (basicType(e)) { + case "node": return renderNode(e as SNode); + case "port": return renderPort(e as SPort); + case "label": return renderLabel(e as SLabel); + case "edge": return renderEdge(e as SEdge); + default: return ""; + } +} + +/** + * Bounding box of everything drawn, for the viewBox. Port labels sit outside + * their node, so nested shapes are walked with their accumulated offsets. + */ +function bounds(graph: SGraph): { x: number; y: number; width: number; height: number } { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + const add = (x: number, y: number) => { + minX = Math.min(minX, x); minY = Math.min(minY, y); + maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); + }; + const walk = (e: SModelElement, dx: number, dy: number) => { + if (basicType(e) === "edge") { + for (const p of (e as SEdge).routingPoints ?? []) add(dx + p.x, dy + p.y); + for (const c of e.children ?? []) walk(c, dx, dy); + return; + } + const s = e as Shaped; + const pos = s.position ?? { x: 0, y: 0 }; + const size = s.size ?? { width: 0, height: 0 }; + const x = dx + pos.x, y = dy + pos.y; + add(x, y); + add(x + size.width, y + size.height); + for (const c of e.children ?? []) walk(c, x, y); + }; + for (const c of graph.children ?? []) walk(c, 0, 0); + if (!Number.isFinite(minX)) { + return { x: 0, y: 0, width: 1, height: 1 }; + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} + +export function toSvg(graph: SGraph, theme: Theme = "dark", padding: number = 20): string { + const p = PALETTES[theme]; + const b = bounds(graph); + const x = b.x - padding, y = b.y - padding; + const w = b.width + 2 * padding, h = b.height + 2 * padding; + const body = (graph.children ?? []).map(renderChild).join("\n"); + return ` + + + + + + +${body} + +`; +} diff --git a/editors/code/cli/src/text-metrics.ts b/editors/code/cli/src/text-metrics.ts new file mode 100644 index 00000000..9d4bec46 --- /dev/null +++ b/editors/code/cli/src/text-metrics.ts @@ -0,0 +1,43 @@ +/** + * Approximate text measurement for headless rendering. + * + * The webview measures label bounds with the browser's `getBBox()`; there is no + * DOM in the CLI, so label sizes are estimated from Helvetica advance widths + * (AFM metrics, 1/1000 em). The diagram CSS forces an 11 px sans-serif font, + * which is what the generated SVG declares, so the estimate is close to what a + * viewer draws. + */ + +export const FONT_SIZE_PX = 11; +export const FONT_FAMILY = "Helvetica, Arial, sans-serif"; + +/** Advance widths for printable ASCII 0x20..0x7E, in 1/1000 em (Helvetica). */ +const ADVANCES: number[] = [ + 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, // ' ' .. '/' + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, // '0' .. '?' + 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, // '@' .. 'O' + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, // 'P' .. '_' + 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, // '`' .. 'o' + 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, // 'p' .. '~' +]; +const DEFAULT_ADVANCE = 556; + +export function textWidth(text: string, fontSize: number = FONT_SIZE_PX): number { + let units = 0; + for (const ch of text) { + const code = ch.codePointAt(0) ?? 0; + const idx = code - 0x20; + units += idx >= 0 && idx < ADVANCES.length ? ADVANCES[idx] : DEFAULT_ADVANCE; + } + return Math.ceil((units / 1000) * fontSize); +} + +/** Line box for a single line of text (line-height ~1.2). */ +export function textHeight(fontSize: number = FONT_SIZE_PX): number { + return Math.ceil(fontSize * 1.2); +} + +/** Baseline offset from the top of the line box (ascent ~0.9 em in a 1.2 line box). */ +export function textBaseline(fontSize: number = FONT_SIZE_PX): number { + return Math.round(fontSize * 0.95); +} diff --git a/editors/code/cli/tsconfig.json b/editors/code/cli/tsconfig.json new file mode 100644 index 00000000..1d455c8e --- /dev/null +++ b/editors/code/cli/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": "..", + "outDir": "../out/cli", + "lib": ["es2021"], + "types": ["node"], + "declaration": false, + "declarationMap": false, + "strict": true + }, + "include": [ + ".", + "../src/diagram/layout.ts", + "../src/diagram/layout-config.ts", + "../common" + ] +} diff --git a/editors/code/package.json b/editors/code/package.json index fb04f033..42924f23 100644 --- a/editors/code/package.json +++ b/editors/code/package.json @@ -576,14 +576,20 @@ } ] }, + "bin": { + "fpp-diagram-render": "./dist/fpp-diagram-render.js" + }, "scripts": { "vscode:prepublish": "rm -rf dist && yarn package", "package": "webpack --mode production", "build": "webpack --mode development", "watch": "webpack --mode development --watch", "compile": "tsc -p ./src --noEmit", + "compile:cli": "tsc -p ./cli --noEmit", + "render": "node ./dist/fpp-diagram-render.js", "test:smoke": "node ./test/smoke.js", - "test": "yarn compile && yarn build && yarn test:smoke", + "test:render": "node ./test/smoke-render.js", + "test": "yarn compile && yarn compile:cli && yarn build && yarn test:smoke && yarn test:render", "vsix": "vsce package" }, "dependencies": { diff --git a/editors/code/test/fixtures/component.fpp b/editors/code/test/fixtures/component.fpp new file mode 100644 index 00000000..919f41df --- /dev/null +++ b/editors/code/test/fixtures/component.fpp @@ -0,0 +1,14 @@ +module Fw { + port Cmd + port Tlm + port Log +} +module Demo { + active component Widget { + async input port cmdIn: Fw.Cmd + guarded input port tlmIn: Fw.Tlm + sync input port logIn: Fw.Log + output port tlmOut: Fw.Tlm + output port logOut: [2] Fw.Log + } +} diff --git a/editors/code/test/fixtures/component.json b/editors/code/test/fixtures/component.json new file mode 100644 index 00000000..426f463d --- /dev/null +++ b/editors/code/test/fixtures/component.json @@ -0,0 +1,6 @@ +{"id":"root","type":"graph","children":[{"id":"Demo.Widget","type":"node:component","kind":"active","children":[ +{"id":"Demo.Widget.label.name","type":"label:node:component","text":"Widget","size":{"width":100,"height":15}}, +{"id":"Demo.Widget.cmdIn.0","type":"port","kind":"async","isOutput":false,"size":{"width":10,"height":10},"children":[{"id":"Demo.Widget.cmdIn.0.label","type":"label:port","text":"cmdIn","size":{"width":50,"height":10}}]}, +{"id":"Demo.Widget.tlmIn.0","type":"port","kind":"guarded","isOutput":false,"size":{"width":10,"height":10},"children":[{"id":"Demo.Widget.tlmIn.0.label","type":"label:port","text":"tlmIn","size":{"width":50,"height":10}}]}, +{"id":"Demo.Widget.tlmOut.0","type":"port","kind":"output","isOutput":true,"size":{"width":10,"height":10},"children":[{"id":"Demo.Widget.tlmOut.0.label","type":"label:port","text":"tlmOut","size":{"width":50,"height":10}}]} +]}]} diff --git a/editors/code/test/smoke-render.js b/editors/code/test/smoke-render.js new file mode 100644 index 00000000..1b860af9 --- /dev/null +++ b/editors/code/test/smoke-render.js @@ -0,0 +1,75 @@ +// Smoke test for the `fpp-diagram-render` CLI bundle (dist/fpp-diagram-render.js). +// +// Always: lay out a canned sprotty SGraph (`--json`) and check the SVG has the +// component box, every port, and port labels placed on the expected side. +// When an `fpp-diagram` binary is available (workspace build or PATH): also run +// the full pipeline on a small FPP model. + +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const here = __dirname; +const cli = path.join(here, "..", "dist", "fpp-diagram-render.js"); +const fixtures = path.join(here, "fixtures"); + +function run(args) { + const result = spawnSync(process.execPath, [cli, ...args], { encoding: "utf8" }); + assert.strictEqual(result.status, 0, `cli failed: ${result.stderr}`); + return result.stdout; +} + +function translateX(svg, id) { + const m = new RegExp(`tlmOut"), "port label text"); + // Input ports sit on the west edge (x <= 0 within the node), outputs on the east. + const widgetWidth = Number(/= widgetWidth - 10, "output port on east side"); + // Light theme swaps the palette. + const light = run(["--json", path.join(fixtures, "component.json"), "--theme", "light"]); + assert.ok(light.includes('fill="#ffffff"'), "light background"); + console.log("smoke-render: layout + svg OK"); +} + +// 2. Full pipeline through fpp-diagram, when a binary is available. +{ + const repoRoot = path.resolve(here, "..", "..", ".."); + const candidates = [ + path.join(repoRoot, "target", "release", "fpp-diagram"), + path.join(repoRoot, "target", "debug", "fpp-diagram"), + ]; + let bin = candidates.find(p => fs.existsSync(p)); + if (!bin) { + const which = spawnSync("fpp-diagram", ["--version"], { encoding: "utf8" }); + if (!which.error && which.status === 0) { + bin = "fpp-diagram"; + } + } + if (!bin) { + console.log("smoke-render: fpp-diagram not found, skipping full pipeline"); + } else { + const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "fpp-render-smoke-")), "widget.svg"); + run(["--fpp-diagram", bin, "--name", "Demo.Widget", "-o", out, path.join(fixtures, "component.fpp")]); + const svg = fs.readFileSync(out, "utf8"); + assert.ok(svg.includes('id="Demo.Widget"'), "component node"); + assert.ok(svg.includes('id="Demo.Widget.logOut.1"'), "array port index 1"); + assert.ok(/class="sprotty-port port-sync"/.test(svg), "sync port styling"); + console.log(`smoke-render: full pipeline OK (${bin})`); + } +} diff --git a/editors/code/webpack.config.js b/editors/code/webpack.config.js index 268b29ae..4e3a3b90 100644 --- a/editors/code/webpack.config.js +++ b/editors/code/webpack.config.js @@ -97,8 +97,35 @@ const stateMachineWebviewConfig = { ...commonConfig, }; +/**@type {import('webpack').Configuration}*/ +const renderCliConfig = { + target: 'node', + entry: './cli/src/main.ts', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'fpp-diagram-render.js', + devtoolModuleFilenameTemplate: "../[resource-path]", + }, + plugins: [ + new (require('webpack').BannerPlugin)({ banner: '#!/usr/bin/env node', raw: true }), + ], + resolve: { + extensions: ['.ts', '.js'], + }, + module: { + rules: [ + { + test: /\.ts$/, + exclude: /node_modules/, + use: [{ loader: 'ts-loader', options: { configFile: path.resolve(__dirname, 'cli/tsconfig.json') } }], + }, + ], + }, +}; + module.exports = [ extensionConfig, diagramWebviewConfig, - stateMachineWebviewConfig + stateMachineWebviewConfig, + renderCliConfig ];