A build-it-yourself guide. graph-engine.js is the one part of
ModuLearn with no net at all — it's only ever been poked by hand. This folds the manual
CDP smoke checks (add / connect / serialize / drag / zoom) into a repeatable headless test on the
Playwright harness you already built for the round-trip test.
Every node, port and wire is drawn into a single
<canvas id="graphcanvas">. Playwright can't get_by_text("Iris") a
node — there's nothing in the DOM to select. So you drive the engine two ways:
page.mouse.move/down/up/wheel) at pixel
coordinates — this is what actually exercises drag, zoom and port-connect in the engine.screen = (graph + ds.offset) · ds.scale (see zoomToFit), plus the
canvas's own getBoundingClientRect() origin.serialize() (which includes each node's pos) or
ds.scale and check they changed. Robust, and independent of exact rendering.window.__ml_* seam, inside the IIFEYou already expose __ml_serialize/__ml_rebuild/__ml_clear/__ml_ready. Add a few
more so a test can add a node and locate node titles / ports in viewport pixels.
// ---- canvas-engine test seam (headless e2e) ----
window.__ml_add = (typeId) => { addNode(typeId); };
window.__ml_nodeIds = () => graph._nodes.map((n) => String(n.id));
window.__ml_ds = () => ({ scale: canvas.ds.scale,
offset: [canvas.ds.offset[0], canvas.ds.offset[1]] });
// graph coords -> viewport (client) pixels, for driving page.mouse
window.__ml_toClient = (gx, gy) => {
const r = canvas.canvas.getBoundingClientRect();
return { x: r.left + (gx + canvas.ds.offset[0]) * canvas.ds.scale,
y: r.top + (gy + canvas.ds.offset[1]) * canvas.ds.scale };
};
// a grabbable point on a node's title bar (title sits above node.pos)
window.__ml_nodeTitleClient = (id) => {
const n = graph.getNodeById(+id); if (!n) return null;
return window.__ml_toClient(n.pos[0] + 20, n.pos[1] - LiteGraph.NODE_TITLE_HEIGHT / 2);
};
// the centre of a port nub: isInput=false for an output slot, true for an input
window.__ml_portClient = (id, isInput, slot) => {
const n = graph.getNodeById(+id); if (!n) return null;
const p = new Float32Array(2); n.getConnectionPos(isInput, slot, p);
return window.__ml_toClient(p[0], p[1]);
};
addNode — the same function the palette calls. No behavior changes for real
users; they're inert unless a test calls them.Reuse the live_server fixture and page from the round-trip test.
A couple of small helpers keep the tests readable. Seed graphs with fixed positions via
__ml_rebuild (not __ml_add, which jitters position) so ports land at
known spots.
import pytest
pytestmark = pytest.mark.e2e
# dataset -> model, NO link yet — the connect test will wire it.
_TWO_NODES = {
"nodes": [
{"id": "1", "type": "dataset.iris", "params": {}, "pos": [120, 200]},
{"id": "2", "type": "model.mlp",
"params": {"hidden": [16], "dropout": 0.0}, "pos": [480, 200]},
],
"links": [],
}
def _boot(page, live_server, graph=None):
page.goto(live_server)
page.wait_for_function("() => window.__ml_ready === true", timeout=10000)
if graph is not None:
page.evaluate("(g) => { window.__ml_clear(); window.__ml_rebuild(g); }", graph)
def _node(page, node_id):
nodes = page.evaluate("() => window.__ml_serialize().nodes")
return next((n for n in nodes if n["id"] == node_id), None)
__ml_add is exactly what a palette click calls, so this exercises the engine's
node-creation path and confirms it lands in serialize().
def test_add_node(live_server, page):
_boot(page, live_server)
page.evaluate("() => { window.__ml_clear(); window.__ml_add('dataset.iris'); }")
ids = page.evaluate("() => window.__ml_nodeIds()")
assert len(ids) == 1
assert _node(page, ids[0])["type"] == "dataset.iris"
Grab the title bar, drag it, and assert the node's pos in
serialize() changed. steps= makes the engine see intermediate
move events (a single jump can be missed).
def test_drag_moves_node(live_server, page):
_boot(page, live_server, {"nodes": [_TWO_NODES["nodes"][0]], "links": []})
nid = page.evaluate("() => window.__ml_nodeIds()")[0]
before = _node(page, nid)["pos"]
start = page.evaluate("(id) => window.__ml_nodeTitleClient(id)", nid)
page.mouse.move(start["x"], start["y"])
page.mouse.down()
page.mouse.move(start["x"] + 140, start["y"] + 90, steps=10)
page.mouse.up()
after = _node(page, nid)["pos"]
assert after != before
Move over the canvas, scroll, and assert ds.scale changed. Zoom is a pure
engine behavior with no DOM footprint, so the __ml_ds() read is the only way to
see it.
def test_wheel_zooms(live_server, page):
_boot(page, live_server, {"nodes": [_TWO_NODES["nodes"][0]], "links": []})
before = page.evaluate("() => window.__ml_ds().scale")
box = page.locator("#graphcanvas").bounding_box()
page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
page.mouse.wheel(0, -400) # scroll up over the canvas = zoom in
after = page.evaluate("() => window.__ml_ds().scale")
assert after != before
mouse.move above ensures that), or your
engine may clamp zoom — try a larger delta or scroll the other direction.The real payoff: drag from dataset.iris's output nub (slot 0) to
model.mlp's dataset input (slot 0) and assert a link appears in
serialize().
def test_drag_connects_ports(live_server, page):
_boot(page, live_server, _TWO_NODES)
assert page.evaluate("() => window.__ml_serialize().links.length") == 0
out = page.evaluate("() => window.__ml_portClient('1', false, 0)") # dataset output
inp = page.evaluate("() => window.__ml_portClient('2', true, 0)") # model 'dataset' input
page.mouse.move(out["x"], out["y"])
page.mouse.down()
page.mouse.move(inp["x"], inp["y"], steps=12) # smooth drag so the engine tracks it
page.mouse.up()
links = page.evaluate("() => window.__ml_serialize().links")
assert len(links) == 1
assert links[0]["from"] == "1" and links[0]["to"] == "2"
assert links[0]["to_port"] == "dataset"
__ml_portClient
aims at the exact port centre, which should hit, but if it's intermittent: raise
steps, add a tiny page.wait_for_timeout(50) before mouse-up, or nudge
the end point by a pixel or two. If drag-connect stays unreliable in CI, fall back to asserting
link fidelity through the round-trip test (which already covers serialize/rebuild
of links) and keep drag-connect as a local-only check.The Playwright browser + the e2e marker are already wired from the round-trip
work, so there's nothing new in ci.yml.
pytest tests/test_canvas_engine_e2e.py -q # the four canvas tests
pytest -q # full suite, e2e included
pytest -q -m "not e2e" # fast local loop, browser skipped
e2e, a
contributor without a browser can still run -m "not e2e". CI installs chromium and
runs the full set.git add modulearn/static/graph.js tests/test_canvas_engine_e2e.py
git commit -m "Automate canvas-engine checks: add/drag/zoom/connect in headless Chromium"
git push
gh run list --branch main --limit 1 # expect: success
Then check the {area:"testing", … title:"Automate the canvas-engine checks"}
card in docs/roadmap.html (shipped state is per-browser). That closes the last
Testing/CI card.