@@ -84,6 +84,194 @@ def sample_workflow_file(project_dir, sample_workflow_yaml):
8484 return wf_path
8585
8686
87+ # ===== Workflow CLI Input Tests =====
88+
89+ class TestWorkflowCliInputs :
90+ """Test workflow run input normalization at the CLI boundary."""
91+
92+ def test_inline_input_still_works (self , project_dir , monkeypatch ):
93+ from specify_cli import _parse_workflow_inputs
94+
95+ monkeypatch .chdir (project_dir )
96+
97+ inputs = _parse_workflow_inputs (
98+ ["spec=Build a kanban board" , "scope=full" ],
99+ None ,
100+ )
101+
102+ assert inputs == {
103+ "spec" : "Build a kanban board" ,
104+ "scope" : "full" ,
105+ }
106+
107+ def test_at_file_input_reads_file_contents_for_generic_key (
108+ self ,
109+ project_dir ,
110+ monkeypatch ,
111+ ):
112+ from specify_cli import _parse_workflow_inputs
113+
114+ desc_file = project_dir / "desc.md"
115+ desc_text = "# Description\n \n Build a workflow.\n "
116+ desc_file .write_text (desc_text , encoding = "utf-8" )
117+ monkeypatch .chdir (project_dir )
118+
119+ inputs = _parse_workflow_inputs (["description=@desc.md" ], None )
120+
121+ assert inputs == {"description" : desc_text }
122+
123+ @pytest .mark .parametrize ("literal" , ["@alice" , "@" ])
124+ def test_missing_at_file_stays_literal (self , literal , project_dir , monkeypatch ):
125+ from specify_cli import _parse_workflow_inputs
126+
127+ monkeypatch .chdir (project_dir )
128+
129+ inputs = _parse_workflow_inputs ([f"assignee={ literal } " ], None )
130+
131+ assert inputs == {"assignee" : literal }
132+
133+ def test_missing_input_file_fails_cleanly (self , project_dir , monkeypatch ):
134+ from specify_cli import _parse_workflow_inputs
135+
136+ monkeypatch .chdir (project_dir )
137+
138+ with pytest .raises (ValueError , match = "not found" ):
139+ _parse_workflow_inputs (None , "missing.json" )
140+
141+ def test_input_file_loads_json_object (self , project_dir , monkeypatch ):
142+ from specify_cli import _parse_workflow_inputs
143+
144+ payload_file = project_dir / "payload.json"
145+ payload_file .write_text (
146+ json .dumps ({"prompt" : "Build a workflow" , "scope" : "full" }),
147+ encoding = "utf-8" ,
148+ )
149+ monkeypatch .chdir (project_dir )
150+
151+ inputs = _parse_workflow_inputs (None , "payload.json" )
152+
153+ assert inputs == {
154+ "prompt" : "Build a workflow" ,
155+ "scope" : "full" ,
156+ }
157+
158+ def test_direct_input_overrides_input_file (self , project_dir , monkeypatch ):
159+ from specify_cli import _parse_workflow_inputs
160+
161+ payload_file = project_dir / "payload.json"
162+ payload_file .write_text (
163+ json .dumps ({"prompt" : "Build a workflow" , "scope" : "full" }),
164+ encoding = "utf-8" ,
165+ )
166+ monkeypatch .chdir (project_dir )
167+
168+ inputs = _parse_workflow_inputs (["scope=minimal" ], "payload.json" )
169+
170+ assert inputs == {
171+ "prompt" : "Build a workflow" ,
172+ "scope" : "minimal" ,
173+ }
174+
175+ def test_invalid_json_input_file_fails_cleanly (self , project_dir , monkeypatch ):
176+ from specify_cli import _parse_workflow_inputs
177+
178+ payload_file = project_dir / "payload.json"
179+ payload_file .write_text ("{invalid json" , encoding = "utf-8" )
180+ monkeypatch .chdir (project_dir )
181+
182+ with pytest .raises (ValueError , match = "Invalid JSON" ):
183+ _parse_workflow_inputs (None , "payload.json" )
184+
185+ @pytest .mark .parametrize ("payload" , ["[]" , '"not an object"' ])
186+ def test_non_object_json_input_file_fails_cleanly (
187+ self ,
188+ payload ,
189+ project_dir ,
190+ monkeypatch ,
191+ ):
192+ from specify_cli import _parse_workflow_inputs
193+
194+ payload_file = project_dir / "payload.json"
195+ payload_file .write_text (payload , encoding = "utf-8" )
196+ monkeypatch .chdir (project_dir )
197+
198+ with pytest .raises (ValueError , match = "JSON object" ):
199+ _parse_workflow_inputs (None , "payload.json" )
200+
201+ def test_malformed_inline_input_fails_cleanly (self ):
202+ from specify_cli import _parse_workflow_inputs
203+
204+ with pytest .raises (ValueError , match = "expected key=value" ):
205+ _parse_workflow_inputs (["spec" ], None )
206+
207+ def test_workflow_run_passes_normalized_inputs_to_engine (
208+ self ,
209+ project_dir ,
210+ monkeypatch ,
211+ ):
212+ from typer .testing import CliRunner
213+ from specify_cli import app
214+ from specify_cli .workflows import engine as engine_module
215+
216+ payload_file = project_dir / "payload.json"
217+ payload_file .write_text (
218+ json .dumps ({"spec" : "Build a kanban board" , "scope" : "minimal" }),
219+ encoding = "utf-8" ,
220+ )
221+ captured : dict [str , object ] = {}
222+
223+ class FakeDefinition :
224+ id = "speckit"
225+ name = "Spec Kit"
226+ version = "1.0.0"
227+
228+ class FakeStatus :
229+ value = "completed"
230+
231+ class FakeState :
232+ status = FakeStatus ()
233+ run_id = "run-1"
234+
235+ class FakeWorkflowEngine :
236+ def __init__ (self , project_root ):
237+ self .project_root = project_root
238+ self .on_step_start = None
239+
240+ def load_workflow (self , source ):
241+ captured ["source" ] = source
242+ return FakeDefinition ()
243+
244+ def validate (self , definition ):
245+ return []
246+
247+ def execute (self , definition , inputs ):
248+ captured ["inputs" ] = inputs
249+ return FakeState ()
250+
251+ monkeypatch .setattr (engine_module , "WorkflowEngine" , FakeWorkflowEngine )
252+ monkeypatch .chdir (project_dir )
253+
254+ result = CliRunner ().invoke (
255+ app ,
256+ [
257+ "workflow" ,
258+ "run" ,
259+ "speckit" ,
260+ "--input-file" ,
261+ "payload.json" ,
262+ "--input" ,
263+ "scope=full" ,
264+ ],
265+ )
266+
267+ assert result .exit_code == 0 , result .output
268+ assert captured ["source" ] == "speckit"
269+ assert captured ["inputs" ] == {
270+ "spec" : "Build a kanban board" ,
271+ "scope" : "full" ,
272+ }
273+
274+
87275# ===== Step Registry Tests =====
88276
89277class TestStepRegistry :
0 commit comments