|
|
@@ -0,0 +1,323 @@
|
|
|
+#!/usr/bin/env python3
|
|
|
+"""Characterisation tests for loop execution.
|
|
|
+
|
|
|
+Runs a set of workflows that exercise the loop walk and writes a normalised
|
|
|
+summary of what each one did. The point is NOT that the recorded behaviour is
|
|
|
+correct - it is to notice when a change to the engine alters any of it, so
|
|
|
+every difference has to be looked at and justified rather than discovered
|
|
|
+later in production.
|
|
|
+
|
|
|
+ scripts/characterise-loops.py before.json # before your change
|
|
|
+ ... rebuild the runner and restart it ...
|
|
|
+ scripts/characterise-loops.py after.json # after it
|
|
|
+ diff before.json after.json
|
|
|
+
|
|
|
+This exists because the node suite cannot catch this class of change. When the
|
|
|
+two graph walks were unified (2026-08-13), four behaviours differed between a
|
|
|
+node inside a loop and the same node outside one; three of the four were
|
|
|
+invisible to all 92 node tests, which passed green throughout. What caught them
|
|
|
+was diffing these baselines.
|
|
|
+
|
|
|
+Needs the webserver and a runner up, and logs in as admin/admin. Every workflow
|
|
|
+it creates is named "zz char: ..." and is deleted again, including on failure.
|
|
|
+"""
|
|
|
+import json, sys, time, urllib.request
|
|
|
+
|
|
|
+API = "http://localhost:8090/api/v1"
|
|
|
+
|
|
|
+
|
|
|
+def req(method, path, body=None, token=None):
|
|
|
+ data = json.dumps(body).encode() if body is not None else None
|
|
|
+ r = urllib.request.Request(API + path, data=data, method=method,
|
|
|
+ headers={"Content-Type": "application/json"})
|
|
|
+ if token:
|
|
|
+ r.add_header("Authorization", "Bearer " + token)
|
|
|
+ return json.loads(urllib.request.urlopen(r).read())
|
|
|
+
|
|
|
+
|
|
|
+TOKEN = req("POST", "/auth/login", {"username": "admin", "password": "admin"})["accessToken"]
|
|
|
+
|
|
|
+
|
|
|
+def conn(src, tgt, src_out="main", tgt_in="data"):
|
|
|
+ return {"sourceNodeId": src, "sourceOutput": src_out,
|
|
|
+ "targetNodeId": tgt, "targetInput": tgt_in}
|
|
|
+
|
|
|
+
|
|
|
+def seed(items, node_id="seed"):
|
|
|
+ return {"id": node_id, "type": "code", "name": node_id, "position": {"x": 0, "y": 0},
|
|
|
+ "config": {"code": "return { items: %s }" % json.dumps(items)}}
|
|
|
+
|
|
|
+
|
|
|
+def loop_node(node_id="loop", field="result.items", cont=True, x=240):
|
|
|
+ return {"id": node_id, "type": "loop", "name": node_id, "position": {"x": x, "y": 0},
|
|
|
+ "config": {"inputField": field, "continueOnError": cont}}
|
|
|
+
|
|
|
+
|
|
|
+def setf(node_id, name, value, x=480, disabled=False):
|
|
|
+ n = {"id": node_id, "type": "set-fields", "name": node_id, "position": {"x": x, "y": 0},
|
|
|
+ "config": {"fields": [{"name": name, "value": value, "type": "string"}]}}
|
|
|
+ if disabled:
|
|
|
+ n["disabled"] = True
|
|
|
+ return n
|
|
|
+
|
|
|
+
|
|
|
+CASES = {}
|
|
|
+
|
|
|
+
|
|
|
+def case(name):
|
|
|
+ def deco(fn):
|
|
|
+ CASES[name] = fn
|
|
|
+ return fn
|
|
|
+ return deco
|
|
|
+
|
|
|
+
|
|
|
+@case("simple_three_items_two_body_nodes")
|
|
|
+def _():
|
|
|
+ return {
|
|
|
+ "nodes": [
|
|
|
+ seed([{"n": "a"}, {"n": "b"}, {"n": "c"}]),
|
|
|
+ loop_node(),
|
|
|
+ setf("first", "seen", "{{ loop.item.n }}"),
|
|
|
+ # The item must still be readable on the SECOND body node.
|
|
|
+ setf("second", "alsoSeen", "{{ loop.item.n }}-{{ loop.index }}", x=720),
|
|
|
+ setf("after", "done", "yes", x=480),
|
|
|
+ ],
|
|
|
+ "connections": [
|
|
|
+ conn("seed", "loop"), conn("loop", "first", "loop"),
|
|
|
+ conn("first", "second"), conn("loop", "after", "done"),
|
|
|
+ ],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+@case("nested_loop_two_by_two")
|
|
|
+def _():
|
|
|
+ return {
|
|
|
+ "nodes": [
|
|
|
+ seed([{"n": "x"}, {"n": "y"}]),
|
|
|
+ loop_node("outer"),
|
|
|
+ {"id": "inner_seed", "type": "code", "name": "inner_seed",
|
|
|
+ "position": {"x": 480, "y": 0},
|
|
|
+ "config": {"code": "return { sub: [{m:'1'}, {m:'2'}] }"}},
|
|
|
+ loop_node("inner", "result.sub", x=720),
|
|
|
+ setf("leaf", "pair", "{{ loop.item.m }}", x=960),
|
|
|
+ setf("after", "done", "yes", x=480),
|
|
|
+ ],
|
|
|
+ "connections": [
|
|
|
+ conn("seed", "outer"), conn("outer", "inner_seed", "loop"),
|
|
|
+ conn("inner_seed", "inner"), conn("inner", "leaf", "loop"),
|
|
|
+ conn("outer", "after", "done"),
|
|
|
+ ],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+@case("branch_gating_in_body")
|
|
|
+def _():
|
|
|
+ return {
|
|
|
+ "nodes": [
|
|
|
+ seed([{"n": "a"}, {"n": "b"}]),
|
|
|
+ loop_node(),
|
|
|
+ setf("mark", "seen", "{{ loop.item.n }}"),
|
|
|
+ {"id": "gate", "type": "if-condition", "name": "gate", "position": {"x": 720, "y": 0},
|
|
|
+ "config": {"conditions": [{"field": "data.seen", "operator": "equals", "value": "a"}]}},
|
|
|
+ setf("on_true", "took", "true", x=960),
|
|
|
+ setf("on_false", "took", "false", x=960),
|
|
|
+ setf("after", "done", "yes", x=480),
|
|
|
+ ],
|
|
|
+ "connections": [
|
|
|
+ conn("seed", "loop"), conn("loop", "mark", "loop"), conn("mark", "gate"),
|
|
|
+ conn("gate", "on_true", "true"), conn("gate", "on_false", "false"),
|
|
|
+ conn("loop", "after", "done"),
|
|
|
+ ],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+@case("back_edge_true_port_only")
|
|
|
+def _():
|
|
|
+ return {
|
|
|
+ "nodes": [
|
|
|
+ seed([{"n": "a"}, {"n": "b"}, {"n": "c"}]),
|
|
|
+ loop_node(),
|
|
|
+ setf("mark", "seen", "{{ loop.item.n }}"),
|
|
|
+ {"id": "gate", "type": "if-condition", "name": "gate", "position": {"x": 720, "y": 0},
|
|
|
+ "config": {"conditions": [{"field": "data.seen", "operator": "not_equals", "value": "a"}]}},
|
|
|
+ setf("after", "done", "yes", x=480),
|
|
|
+ ],
|
|
|
+ "connections": [
|
|
|
+ conn("seed", "loop"), conn("loop", "mark", "loop"), conn("mark", "gate"),
|
|
|
+ conn("gate", "loop", "true"), conn("loop", "after", "done"),
|
|
|
+ ],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+@case("disabled_node_in_body_is_transparent")
|
|
|
+def _():
|
|
|
+ return {
|
|
|
+ "nodes": [
|
|
|
+ seed([{"n": "a"}, {"n": "b"}]),
|
|
|
+ loop_node(),
|
|
|
+ setf("mark", "seen", "{{ loop.item.n }}"),
|
|
|
+ setf("off", "ignored", "x", x=720, disabled=True),
|
|
|
+ setf("tail", "reached", "yes", x=960),
|
|
|
+ setf("after", "done", "yes", x=480),
|
|
|
+ ],
|
|
|
+ "connections": [
|
|
|
+ conn("seed", "loop"), conn("loop", "mark", "loop"),
|
|
|
+ conn("mark", "off"), conn("off", "tail"), conn("loop", "after", "done"),
|
|
|
+ ],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+@case("body_failure_continue_on_error_true")
|
|
|
+def _():
|
|
|
+ return {
|
|
|
+ "nodes": [
|
|
|
+ seed([{"n": "a"}, {"n": "b"}, {"n": "c"}]),
|
|
|
+ loop_node(cont=True),
|
|
|
+ # NOTE: a code node's body cannot see `loop` - it is available to
|
|
|
+ # expressions, not as a variable in the sandbox - so this throws on
|
|
|
+ # every item, not only on 'b'. Left as it is because a baseline only
|
|
|
+ # has to be stable and this one exercises "every item failed, run
|
|
|
+ # completed anyway"; do not read the case name as a promise that
|
|
|
+ # exactly one item fails.
|
|
|
+ {"id": "boom", "type": "code", "name": "boom", "position": {"x": 480, "y": 0},
|
|
|
+ "config": {"code": "if (loop.item.n === 'b') { throw new Error('planned'); } return { ok: loop.item.n }"}},
|
|
|
+ setf("after", "done", "yes", x=480),
|
|
|
+ ],
|
|
|
+ "connections": [
|
|
|
+ conn("seed", "loop"), conn("loop", "boom", "loop"), conn("loop", "after", "done"),
|
|
|
+ ],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+@case("body_failure_continue_on_error_false")
|
|
|
+def _():
|
|
|
+ return {
|
|
|
+ "nodes": [
|
|
|
+ seed([{"n": "a"}, {"n": "b"}, {"n": "c"}]),
|
|
|
+ loop_node(cont=False),
|
|
|
+ {"id": "boom", "type": "code", "name": "boom", "position": {"x": 480, "y": 0},
|
|
|
+ "config": {"code": "if (loop.item.n === 'b') { throw new Error('planned'); } return { ok: loop.item.n }"}},
|
|
|
+ setf("after", "done", "yes", x=480),
|
|
|
+ ],
|
|
|
+ "connections": [
|
|
|
+ conn("seed", "loop"), conn("loop", "boom", "loop"), conn("loop", "after", "done"),
|
|
|
+ ],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _halt_in_loop(mode):
|
|
|
+ """A loop of three whose middle item trips a stop-and-error."""
|
|
|
+ return {
|
|
|
+ "nodes": [
|
|
|
+ seed([{"n": "a"}, {"n": "b"}, {"n": "c"}]),
|
|
|
+ loop_node(),
|
|
|
+ setf("mark", "seen", "{{ loop.item.n }}"),
|
|
|
+ {"id": "gate", "type": "if-condition", "name": "gate", "position": {"x": 720, "y": 0},
|
|
|
+ "config": {"conditions": [{"field": "data.seen", "operator": "equals", "value": "b"}]}},
|
|
|
+ {"id": "halt", "type": "stop-and-error", "name": "halt",
|
|
|
+ "position": {"x": 960, "y": -80},
|
|
|
+ "config": {"mode": mode, "message": "item b says stop"}},
|
|
|
+ setf("keep", "kept", "yes", x=960),
|
|
|
+ setf("after", "done", "yes", x=480),
|
|
|
+ ],
|
|
|
+ "connections": [
|
|
|
+ conn("seed", "loop"), conn("loop", "mark", "loop"), conn("mark", "gate"),
|
|
|
+ conn("gate", "halt", "true"), conn("gate", "keep", "false"),
|
|
|
+ conn("loop", "after", "done"),
|
|
|
+ ],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+# The three ways a body node can end something, which are three different
|
|
|
+# things: fail the whole run, end the whole run cleanly, and drop this one item
|
|
|
+# and carry on. error mode used to do none of them inside a loop - it threw, and
|
|
|
+# the loop's Continue On Error caught it - so all three are pinned here.
|
|
|
+@case("halt_in_loop_error_fails_the_run")
|
|
|
+def _():
|
|
|
+ return _halt_in_loop("error")
|
|
|
+
|
|
|
+
|
|
|
+@case("halt_in_loop_stop_ends_the_run")
|
|
|
+def _():
|
|
|
+ return _halt_in_loop("stop")
|
|
|
+
|
|
|
+
|
|
|
+@case("halt_in_loop_skip_drops_one_item")
|
|
|
+def _():
|
|
|
+ return _halt_in_loop("skip")
|
|
|
+
|
|
|
+
|
|
|
+@case("empty_item_list")
|
|
|
+def _():
|
|
|
+ return {
|
|
|
+ "nodes": [
|
|
|
+ seed([]),
|
|
|
+ loop_node(),
|
|
|
+ setf("mark", "seen", "{{ loop.item.n }}"),
|
|
|
+ setf("after", "done", "yes", x=480),
|
|
|
+ ],
|
|
|
+ "connections": [
|
|
|
+ conn("seed", "loop"), conn("loop", "mark", "loop"), conn("loop", "after", "done"),
|
|
|
+ ],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def run_case(name, spec):
|
|
|
+ wf = req("POST", "/workflows", {"name": "zz char: " + name, "active": False,
|
|
|
+ "nodes": spec["nodes"],
|
|
|
+ "connections": spec["connections"]}, TOKEN)
|
|
|
+ wf_id = wf.get("_id") or wf.get("id")
|
|
|
+ try:
|
|
|
+ ex = req("POST", f"/workflows/{wf_id}/execute", {}, TOKEN)
|
|
|
+ exec_id = ex.get("executionId")
|
|
|
+ detail = {}
|
|
|
+ for _ in range(40):
|
|
|
+ time.sleep(1.5)
|
|
|
+ detail = req("GET", "/executions/" + exec_id, token=TOKEN)
|
|
|
+ if detail.get("status") not in ("running", "pending"):
|
|
|
+ break
|
|
|
+
|
|
|
+ records = []
|
|
|
+ for rec in detail.get("nodeExecutions", []):
|
|
|
+ out = rec.get("output")
|
|
|
+ # Only the fields a workflow author would see, and drop the engine's
|
|
|
+ # internal loop bookkeeping, which is noisy and not behaviour.
|
|
|
+ if isinstance(out, dict):
|
|
|
+ out = {k: v for k, v in out.items() if not k.startswith("_")}
|
|
|
+ records.append({
|
|
|
+ "node": rec.get("nodeId"),
|
|
|
+ "status": rec.get("status"),
|
|
|
+ "iteration": rec.get("loopIteration"),
|
|
|
+ "loopNode": rec.get("loopNodeId"),
|
|
|
+ "error": (rec.get("error") or "").split("\n")[0][:110],
|
|
|
+ "output": out,
|
|
|
+ })
|
|
|
+ # Sorted so a run-to-run ordering wobble is not mistaken for a change.
|
|
|
+ records.sort(key=lambda r: (r["node"], str(r["iteration"])))
|
|
|
+ return {"status": detail.get("status"),
|
|
|
+ "error": (detail.get("error") or "").split("\n")[0][:110],
|
|
|
+ "stopped": detail.get("stopped"),
|
|
|
+ "stopReason": detail.get("stopReason"),
|
|
|
+ "toleratedErrorCount": detail.get("toleratedErrorCount"),
|
|
|
+ "records": records}
|
|
|
+ finally:
|
|
|
+ urllib.request.urlopen(urllib.request.Request(
|
|
|
+ API + "/workflows/" + wf_id, method="DELETE",
|
|
|
+ headers={"Authorization": "Bearer " + TOKEN}))
|
|
|
+
|
|
|
+
|
|
|
+out = {}
|
|
|
+for name in sorted(CASES):
|
|
|
+ print("running", name, flush=True)
|
|
|
+ try:
|
|
|
+ out[name] = run_case(name, CASES[name]())
|
|
|
+ except Exception as e:
|
|
|
+ out[name] = {"harness_error": repr(e)}
|
|
|
+
|
|
|
+path = sys.argv[1] if len(sys.argv) > 1 else "loop_characterisation.json"
|
|
|
+with open(path, "w") as f:
|
|
|
+ json.dump(out, f, indent=2, sort_keys=True)
|
|
|
+print("\nwrote", path)
|
|
|
+for name in sorted(out):
|
|
|
+ r = out[name]
|
|
|
+ print(" %-42s %s" % (name, r.get("status") or r.get("harness_error")))
|