@@ -862,6 +862,112 @@ def test_dispatch_failure_returns_failed_status(self, tmp_path):
862862 assert result .output ["exit_code" ] == 1
863863
864864
865+
866+
867+ def test_dry_run_returns_completed_without_dispatch (self , tmp_path ):
868+ """Dry-run mode: step returns COMPLETED and shows rendered prompt without invoking CLI."""
869+ from specify_cli .workflows .steps .command import CommandStep
870+ from specify_cli .workflows .base import StepContext , StepStatus
871+
872+ step = CommandStep ()
873+ ctx = StepContext (
874+ inputs = {"name" : "login" },
875+ default_integration = "claude" ,
876+ project_root = str (tmp_path ),
877+ dry_run = True ,
878+ )
879+ config = {
880+ "id" : "test" ,
881+ "command" : "speckit.specify" ,
882+ "input" : {"args" : "{{ inputs.name }}" },
883+ }
884+ result = step .execute (config , ctx )
885+
886+ assert result .status == StepStatus .COMPLETED
887+ assert result .output ["dry_run" ] is True
888+ assert result .output ["dispatched" ] is False
889+ assert result .output ["executed" ] is False
890+ assert result .output ["command" ] == "speckit.specify"
891+ assert result .output ["input" ]["args" ] == "login"
892+ # No AI call was made (shutil.which would fail anyway, but dry_run short-circuits before that)
893+ assert "invoke_command" in result .output
894+ # The DRY RUN preview is published on both ``message`` and
895+ # ``dry_run_message`` so the CLI's render loop can prefer the
896+ # latter without per-step-type special-casing.
897+ assert "DRY RUN" in result .output ["message" ]
898+ assert result .output ["dry_run_message" ] == result .output ["message" ]
899+ def test_dry_run_uses_integration_invocation_string (self , tmp_path ):
900+ """Dry-run preview uses the integration's ``build_command_invocation()``.
901+
902+ Markdown agents (``claude``, ``gemini``) render
903+ ``/speckit.specify <args>``; skills agents (``zed``,
904+ ``kiro-cli``) render ``/speckit-specify <args>``. The dry-run
905+ preview must match what a real run would dispatch, not the
906+ bare ``speckit.specify`` command name.
907+ """
908+ from specify_cli .workflows .steps .command import CommandStep
909+ from specify_cli .workflows .base import StepContext
910+
911+ # Markdown agent — invocation keeps the dotted form.
912+ step = CommandStep ()
913+ ctx = StepContext (
914+ inputs = {"name" : "login" },
915+ default_integration = "claude" ,
916+ project_root = str (tmp_path ),
917+ dry_run = True ,
918+ )
919+ config = {
920+ "id" : "test" ,
921+ "command" : "speckit.specify" ,
922+ "input" : {"args" : "{{ inputs.name }}" },
923+ }
924+ result = step .execute (config , ctx )
925+ # The Claude integration renders ``/speckit-specify`` for the
926+ # ``speckit.specify`` command. The preview should reference it
927+ # (the bare ``speckit.specify`` token is fine too because the
928+ # preview is a quoted string). What matters is the integration
929+ # invocation string is present.
930+ assert "/speckit-specify" in result .output ["message" ]
931+ # And the bare ``speckit.specify`` token should NOT be the
932+ # only thing shown — the slash-command form must be there too.
933+ assert result .output ["message" ].count ("speckit" ) >= 1
934+
935+ # Skills agent — invocation switches to ``/speckit.specify``.
936+ ctx_skill = StepContext (
937+ inputs = {"name" : "login" },
938+ default_integration = "gemini" , # markdown agent — produces /speckit.specify
939+ project_root = str (tmp_path ),
940+ dry_run = True ,
941+ )
942+ result_skill = step .execute (config , ctx_skill )
943+ assert "/speckit.specify" in result_skill .output ["message" ]
944+ def test_dry_run_falls_back_when_integration_unknown (self , tmp_path ):
945+ """Dry-run falls back to the bare command string when no integration is set.
946+
947+ A brand-new project may run ``workflow run --dry-run`` before
948+ any integration is configured. The preview must still emit a
949+ sensible message rather than crashing inside ``get_integration``.
950+ """
951+ from specify_cli .workflows .steps .command import CommandStep
952+ from specify_cli .workflows .base import StepContext , StepStatus
953+
954+ step = CommandStep ()
955+ ctx = StepContext (
956+ inputs = {"name" : "login" },
957+ default_integration = None , # no integration configured
958+ project_root = str (tmp_path ),
959+ dry_run = True ,
960+ )
961+ config = {
962+ "id" : "test" ,
963+ "command" : "speckit.specify" ,
964+ "input" : {"args" : "{{ inputs.name }}" },
965+ }
966+ result = step .execute (config , ctx )
967+ assert result .status == StepStatus .COMPLETED
968+ # Falls back to the bare ``command`` name in the preview.
969+ assert "speckit.specify" in result .output ["message" ]
970+ assert "DRY RUN" in result .output ["message" ]
865971class TestPromptStep :
866972 """Test the prompt step type."""
867973
@@ -1036,6 +1142,39 @@ def test_validate_valid(self):
10361142 assert errors == []
10371143
10381144
1145+
1146+
1147+ def test_dry_run_prompt_short_circuits (self , tmp_path ):
1148+ """PromptStep must honor ``context.dry_run`` — the same contract
1149+ as CommandStep / GateStep. Without this, a workflow with
1150+ ``type: prompt`` makes real AI calls even in dry-run mode."""
1151+ from specify_cli .workflows .steps .prompt import PromptStep
1152+ from specify_cli .workflows .base import StepContext , StepStatus
1153+
1154+ step = PromptStep ()
1155+ ctx = StepContext (
1156+ inputs = {"file" : "auth.py" },
1157+ default_integration = "claude" ,
1158+ project_root = str (tmp_path ),
1159+ dry_run = True ,
1160+ )
1161+ config = {
1162+ "id" : "review" ,
1163+ "type" : "prompt" ,
1164+ "prompt" : "Review {{ inputs.file }} for security issues" ,
1165+ }
1166+ result = step .execute (config , ctx )
1167+
1168+ assert result .status == StepStatus .COMPLETED
1169+ assert result .output ["dry_run" ] is True
1170+ assert result .output ["executed" ] is False
1171+ assert result .output ["dispatched" ] is False
1172+ assert result .output ["exit_code" ] == 0
1173+ # The dry-run preview lives on both message and dry_run_message.
1174+ assert "DRY RUN" in result .output ["message" ]
1175+ assert result .output ["dry_run_message" ] == result .output ["message" ]
1176+ # The rendered prompt must surface in the preview.
1177+ assert "auth.py" in result .output ["message" ]
10391178class TestShellStep :
10401179 """Test the shell step type."""
10411180
@@ -1556,6 +1695,92 @@ def test_templated_show_file_resolving_to_non_string_is_coerced(self):
15561695 assert result .output ["show_file" ] == "123"
15571696
15581697
1698+
1699+
1700+ def test_dry_run_skips_interactive_gate (self , tmp_path ):
1701+ """Dry-run mode: gate step returns COMPLETED without pausing for user input."""
1702+ from specify_cli .workflows .steps .gate import GateStep
1703+ from specify_cli .workflows .base import StepContext , StepStatus
1704+
1705+ step = GateStep ()
1706+ ctx = StepContext (dry_run = True )
1707+ config = {
1708+ "id" : "review" ,
1709+ "message" : "Review the spec." ,
1710+ "options" : ["approve" , "reject" ],
1711+ "on_reject" : "abort" ,
1712+ }
1713+ result = step .execute (config , ctx )
1714+ assert result .status == StepStatus .COMPLETED
1715+ # Dry-run output must be marked so the CLI prints it (mirrors
1716+ # CommandStep's output['dry_run'] = True contract).
1717+ assert result .output ["dry_run" ] is True
1718+ # The original ``message`` is preserved so downstream
1719+ # ``{{ steps.<id>.output.message }}`` references still resolve
1720+ # to the gate prompt; the DRY RUN preview is published on
1721+ # ``dry_run_message`` for the CLI rendering loop.
1722+ assert result .output ["message" ] == "Review the spec."
1723+ assert "DRY RUN" in result .output ["dry_run_message" ]
1724+ assert result .output ["choice" ] == "approve"
1725+ def test_dry_run_normalizes_non_list_options (self , tmp_path ):
1726+ """A workflow that bypasses validation may set ``options`` to a
1727+ non-list (string, dict, scalar). ``GateStep`` must coerce
1728+ gracefully instead of indexing into a string or raising."""
1729+ from specify_cli .workflows .steps .gate import GateStep
1730+ from specify_cli .workflows .base import StepContext , StepStatus
1731+
1732+ step = GateStep ()
1733+ ctx = StepContext (dry_run = True )
1734+ for bad_options in (None , "approve,reject" , {"approve" : True }, 42 , "" ):
1735+ config = {
1736+ "id" : "review" ,
1737+ "message" : "Review the spec." ,
1738+ "options" : bad_options ,
1739+ }
1740+ result = step .execute (config , ctx )
1741+ assert result .status == StepStatus .COMPLETED
1742+ assert result .output ["options" ] == []
1743+ assert result .output ["choice" ] is None
1744+ def test_dry_run_accepts_tuple_options (self , tmp_path ):
1745+ """``options`` may be a tuple (Sequence but not list). GateStep
1746+ should accept any non-str Sequence, not just list."""
1747+ from specify_cli .workflows .steps .gate import GateStep
1748+ from specify_cli .workflows .base import StepContext , StepStatus
1749+
1750+ step = GateStep ()
1751+ ctx = StepContext (dry_run = True )
1752+ config = {
1753+ "id" : "review" ,
1754+ "message" : "Review the spec." ,
1755+ "options" : ("approve" , "reject" ),
1756+ }
1757+ result = step .execute (config , ctx )
1758+ assert result .status == StepStatus .COMPLETED
1759+ assert result .output ["options" ] == ["approve" , "reject" ]
1760+ assert result .output ["choice" ] == "approve"
1761+ def test_dry_run_skips_reject_sentinels_for_choice (self , tmp_path ):
1762+ """In dry-run, ``choice`` should not be a reject/abort sentinel
1763+ that would unintentionally steer downstream branching."""
1764+ from specify_cli .workflows .steps .gate import GateStep
1765+ from specify_cli .workflows .base import StepContext , StepStatus
1766+
1767+ step = GateStep ()
1768+ ctx = StepContext (dry_run = True )
1769+ # First option is the reject sentinel — the dry-run must skip it
1770+ # and fall through to the first non-sentinel option.
1771+ config = {
1772+ "id" : "review" ,
1773+ "message" : "Review the spec." ,
1774+ "options" : ["reject" , "approve" , "skip" ],
1775+ }
1776+ result = step .execute (config , ctx )
1777+ assert result .status == StepStatus .COMPLETED
1778+ assert result .output ["choice" ] == "approve"
1779+
1780+ # All options are sentinels — leave choice as None (neutral).
1781+ config ["options" ] = ["reject" , "abort" ]
1782+ result = step .execute (config , ctx )
1783+ assert result .output ["choice" ] is None
15591784class TestIfThenStep :
15601785 """Test the if/then/else step type."""
15611786
@@ -2954,6 +3179,32 @@ def test_while_loop_multi_step_body_inter_step_refs(self, project_dir):
29543179# "workflow doesn't reference it" backward-compat path.
29553180
29563181
3182+
3183+
3184+ def test_dry_run_persisted_in_run_state (self , project_dir ):
3185+ """Dry-run flag must be saved into RunState so resume() can restore it."""
3186+ from specify_cli .workflows .engine import RunState , WorkflowEngine , WorkflowDefinition
3187+
3188+ yaml_str = """
3189+ schema_version: "1.0"
3190+ workflow:
3191+ id: "dryrun-persist"
3192+ name: "Dry Run Persist"
3193+ version: "1.0.0"
3194+ steps:
3195+ - id: only
3196+ type: gate
3197+ message: "Continue?"
3198+ options: ["yes", "no"]
3199+ """
3200+ definition = WorkflowDefinition .from_string (yaml_str )
3201+ engine = WorkflowEngine (project_dir )
3202+ state = engine .execute (definition , dry_run = True , run_id = "dr-persist" )
3203+
3204+ assert state .dry_run is True
3205+
3206+ reloaded = RunState .load ("dr-persist" , project_dir )
3207+ assert reloaded .dry_run is True
29573208class TestContextRunId :
29583209 """End-to-end tests for `{{ context.run_id }}` in workflow YAML."""
29593210
0 commit comments