diff --git a/src/specify_cli/workflows/steps/if_then/__init__.py b/src/specify_cli/workflows/steps/if_then/__init__.py index b2ed880678..d457e1d953 100644 --- a/src/specify_cli/workflows/steps/if_then/__init__.py +++ b/src/specify_cli/workflows/steps/if_then/__init__.py @@ -22,11 +22,11 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: result = evaluate_condition(condition, context) if result: - branch_name = "then" branch = config.get("then", []) + branch_name = "then" else: - branch_name = "else" branch = config.get("else", []) + branch_name = "else" # The engine does not auto-validate step config (see # ``WorkflowEngine.load_workflow``), and it feeds ``next_steps`` straight diff --git a/tests/test_workflows.py b/tests/test_workflows.py index b1d9841535..75db91458d 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -2130,6 +2130,62 @@ def test_execute_else_branch(self): assert result.output["condition_result"] is False assert result.next_steps[0]["id"] == "b" + @pytest.mark.parametrize("bad_branch", ["oops", {"a": 1}, 42]) + def test_execute_rejects_non_list_branch(self, bad_branch): + """execute() fails cleanly on a non-list then/else branch. The engine + doesn't auto-validate step config, so a non-iterable next_steps would + otherwise crash the run — completing the while/do-while guard (#3519) for + the if step (mirrors the switch step's 'cases' guard).""" + from specify_cli.workflows.steps.if_then import IfThenStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = IfThenStep() + cond = "{{ inputs.scope == 'full' }}" + res_then = step.execute( + {"id": "i", "condition": cond, "then": bad_branch}, + StepContext(inputs={"scope": "full"}), + ) + assert res_then.status is StepStatus.FAILED + assert "'then' must be a list" in (res_then.error or "") + res_else = step.execute( + {"id": "i", "condition": cond, "then": [], "else": bad_branch}, + StepContext(inputs={"scope": "backend"}), + ) + assert res_else.status is StepStatus.FAILED + assert "'else' must be a list" in (res_else.error or "") + + def test_execute_allows_null_else_branch(self): + """`else: null` is the supported 'no else branch' form (validate accepts + it); a false condition must run cleanly (COMPLETED, no next steps), not + be turned into FAILED by the non-list guard.""" + from specify_cli.workflows.steps.if_then import IfThenStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = IfThenStep() + result = step.execute( + {"id": "i", "condition": "{{ inputs.scope == 'full' }}", + "then": [{"id": "a", "command": "speckit.tasks"}], "else": None}, + StepContext(inputs={"scope": "backend"}), + ) + assert result.status is StepStatus.COMPLETED + assert result.output["condition_result"] is False + assert result.next_steps == [] + + def test_execute_null_then_is_failed_not_normalized(self): + """Only `else: null` is normalized to []; `then: null` is invalid + (validate() rejects it), so a true condition must FAIL, not silently + complete an empty branch.""" + from specify_cli.workflows.steps.if_then import IfThenStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = IfThenStep() + result = step.execute( + {"id": "i", "condition": "{{ inputs.scope == 'full' }}", "then": None}, + StepContext(inputs={"scope": "full"}), + ) + assert result.status is StepStatus.FAILED + assert "'then' must be" in (result.error or "") + def test_validate_missing_condition(self): from specify_cli.workflows.steps.if_then import IfThenStep