Skip to content

Commit bca1879

Browse files
committed
test(workflows): restore 9 dry_run tests lost during rebase onto main
The rebase onto upstream/main (e39cb51) dropped the dry_run test cases that were added in commits c2b23a2 and df398b0. The conflict resolution at the from_json section inadvertently kept only the upstream from_json content, removing all 9 dry_run test methods across TestCommandStep, TestPromptStep, TestGateStep, and TestWorkflowEngine. Restored: - TestCommandStep: test_dry_run_returns_completed_without_dispatch, test_dry_run_uses_integration_invocation_string, test_dry_run_falls_back_when_integration_unknown - TestPromptStep: test_dry_run_prompt_short_circuits - TestGateStep: test_dry_run_skips_interactive_gate, test_dry_run_normalizes_non_list_options, test_dry_run_accepts_tuple_options, test_dry_run_skips_reject_sentinels_for_choice - TestWorkflowEngine: test_dry_run_persisted_in_run_state All 9 tests pass; remaining 14 pre-existing failures (TestWorkflowStep* and TestWorkflowRunExitCodes/test_gate_abort) also fail on upstream/main unmodified and are unrelated to this PR.
1 parent 293d734 commit bca1879

1 file changed

Lines changed: 251 additions & 0 deletions

File tree

tests/test_workflows.py

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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"]
865971
class 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"]
10391178
class 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
15591784
class 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
29573208
class TestContextRunId:
29583209
"""End-to-end tests for `{{ context.run_id }}` in workflow YAML."""
29593210

0 commit comments

Comments
 (0)