Просмотр исходного кода

feat: record which node a single-node test run targeted

executeToNode sends _targetNodeId to trim a loop body to one node and
one item, which is correct behaviour for testing a node in isolation.
But the marker was erased from the trigger data with nothing put in
its place, so the stored execution record looked exactly like a full
run whose loop mysteriously stopped after one item.

Add singleNodeTarget to ExecutionResult, set from the same
_targetNodeId trigger data field before it is erased, and serialize it
in toJson() only when non-empty - absent means a normal run, present
names the targeted node. _targetNodeId itself is still erased from the
stored trigger data since it is an internal control field, not user
data; singleNodeTarget is now the record of it.

Extend verify-node.py with "trigger" (body sent to the execute call)
and "expectExecution"/"expectExecutionAbsent" (assertions against the
execution record itself, not a node inside it), and add two cases: a
full run of a 3-item loop proving singleNodeTarget is absent and every
iteration still ran, and a targeted run proving singleNodeTarget names
the node and the loop body ran once.
fszontagh 1 месяц назад
Родитель
Сommit
30a1247137

+ 9 - 1
scripts/verify-node.py

@@ -252,7 +252,7 @@ def main():
                 print("  " + f)
                 print("  " + f)
             return 1 if failures else 0
             return 1 if failures else 0
 
 
-        started = call("POST", f"/workflows/{workflow_id}/execute", token, {})
+        started = call("POST", f"/workflows/{workflow_id}/execute", token, case.get("trigger", {}))
         execution_id = started["executionId"]
         execution_id = started["executionId"]
 
 
         if "resume" in case:
         if "resume" in case:
@@ -338,6 +338,14 @@ def main():
                 f"got {execution.get('error')!r}"
                 f"got {execution.get('error')!r}"
             )
             )
 
 
+        # Assertions against the execution record itself, not a node inside it -
+        # e.g. "singleNodeTarget" naming which node a 'test this node' run
+        # targeted, absent entirely on an ordinary run.
+        failures += subset_matches(case.get("expectExecution", {}), execution, "execution")
+        for key in case.get("expectExecutionAbsent", []):
+            if key in execution:
+                failures.append(f"execution.{key}: expected absent, got {execution[key]!r}")
+
         for node_id in case.get("expectMissing", []):
         for node_id in case.get("expectMissing", []):
             got = by_id.get(node_id)
             got = by_id.get(node_id)
             if got is not None and got.get("status") != "skipped":
             if got is not None and got.get("status") != "skipped":

+ 8 - 0
src/runner/workflow_engine.cpp

@@ -313,6 +313,13 @@ nlohmann::json ExecutionResult::toJson() const {
         j["pauseExpiresAt"] = pause_expires_at;
         j["pauseExpiresAt"] = pause_expires_at;
     }
     }
 
 
+    // Absent means a normal, full run. Present names the node a "test this
+    // node" run targeted, so the record does not read as a full run whose
+    // loop mysteriously stopped after one item.
+    if (!single_node_target.empty()) {
+        j["singleNodeTarget"] = single_node_target;
+    }
+
     return j;
     return j;
 }
 }
 
 
@@ -413,6 +420,7 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
     result.trigger_data = actual_trigger_data;
     result.trigger_data = actual_trigger_data;
     result.started_at = TimeUtils::nowMs();
     result.started_at = TimeUtils::nowMs();
     result.call_depth = call_depth;
     result.call_depth = call_depth;
+    result.single_node_target = target_node_id;
 
 
     if (!assigned_execution_id.empty()) {
     if (!assigned_execution_id.empty()) {
         result.execution_id = assigned_execution_id;
         result.execution_id = assigned_execution_id;

+ 11 - 0
src/runner/workflow_engine.hpp

@@ -164,6 +164,17 @@ struct ExecutionResult {
     // towards the same depth limit as one called outside it.
     // towards the same depth limit as one called outside it.
     int call_depth = 0;
     int call_depth = 0;
 
 
+    // Set when this run was started as a "test this node" single-node
+    // execution (the trigger data carried "_targetNodeId"). That marker is
+    // an internal control field, not user data, and is stripped from the
+    // stored trigger data - so without this, the record left behind is
+    // indistinguishable from a normal run. A loop with several items still
+    // reports its usual item count and "loop completed" even though only
+    // one iteration actually ran; this field is the only thing on the
+    // record that says why. Empty for an ordinary run - absence, not a
+    // sentinel, is what toJson() uses to decide whether to emit it.
+    std::string single_node_target;
+
     bool stop_requested = false;
     bool stop_requested = false;
     std::string stopped_node_id;
     std::string stopped_node_id;
     std::string stop_reason;
     std::string stop_reason;

+ 28 - 0
tests/nodes/single-node-marker-full-run.json

@@ -0,0 +1,28 @@
+{
+  "name": "verify-single-node-marker-full-run",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "items", "name": "Items", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { items: ['a', 'b', 'c'] };"}},
+    {"id": "loop", "name": "Loop", "type": "loop", "position": {"x": 0, "y": 200},
+     "config": {"inputField": "data.result.items"}},
+    {"id": "work", "name": "Work", "type": "set-fields", "position": {"x": 0, "y": 300},
+     "config": {"mode": "only-set", "fields": [{"name": "seen", "value": "{{loop.item}}"}]}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "items", "targetInput": "data"},
+    {"sourceNodeId": "items", "sourceOutput": "main", "targetNodeId": "loop", "targetInput": "data"},
+    {"sourceNodeId": "loop", "sourceOutput": "loop", "targetNodeId": "work", "targetInput": "data"},
+    {"sourceNodeId": "work", "sourceOutput": "main", "targetNodeId": "loop", "targetInput": "data"}
+  ],
+  "expect": {
+    "work": {"status": "completed"}
+  },
+  "expectCounts": {
+    "work": 3
+  },
+  "expectExecution": {
+    "status": "completed"
+  },
+  "expectExecutionAbsent": ["singleNodeTarget"]
+}

+ 29 - 0
tests/nodes/single-node-marker-targeted.json

@@ -0,0 +1,29 @@
+{
+  "name": "verify-single-node-marker-targeted",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "items", "name": "Items", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { items: ['a', 'b', 'c'] };"}},
+    {"id": "loop", "name": "Loop", "type": "loop", "position": {"x": 0, "y": 200},
+     "config": {"inputField": "data.result.items"}},
+    {"id": "work", "name": "Work", "type": "set-fields", "position": {"x": 0, "y": 300},
+     "config": {"mode": "only-set", "fields": [{"name": "seen", "value": "{{loop.item}}"}]}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "items", "targetInput": "data"},
+    {"sourceNodeId": "items", "sourceOutput": "main", "targetNodeId": "loop", "targetInput": "data"},
+    {"sourceNodeId": "loop", "sourceOutput": "loop", "targetNodeId": "work", "targetInput": "data"},
+    {"sourceNodeId": "work", "sourceOutput": "main", "targetNodeId": "loop", "targetInput": "data"}
+  ],
+  "trigger": {"_targetNodeId": "work"},
+  "expect": {
+    "work": {"status": "completed"}
+  },
+  "expectCounts": {
+    "work": 1
+  },
+  "expectExecution": {
+    "status": "completed",
+    "singleNodeTarget": "work"
+  }
+}