-
Notifications
You must be signed in to change notification settings - Fork 118
Expand file tree
/
Copy pathtest_script_runner.py
More file actions
868 lines (701 loc) · 40.3 KB
/
test_script_runner.py
File metadata and controls
868 lines (701 loc) · 40.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
"""Unit tests for script runner functionality."""
import pytest
from pathlib import Path
from unittest.mock import patch, mock_open, MagicMock
import os
import tempfile
import shutil
from apm_cli.core.script_runner import ScriptRunner, PromptCompiler
class TestScriptRunner:
"""Test ScriptRunner functionality."""
def setup_method(self):
"""Set up test fixtures."""
self.script_runner = ScriptRunner()
self.compiled_content = "You are a helpful assistant. Say hello to TestUser!"
self.compiled_path = ".apm/compiled/hello-world.txt"
def test_transform_runtime_command_simple_codex(self):
"""Test simple codex command transformation."""
original = "codex hello-world.prompt.md"
result = self.script_runner._transform_runtime_command(
original, "hello-world.prompt.md", self.compiled_content, self.compiled_path
)
assert result == "codex exec"
def test_transform_runtime_command_codex_with_flags(self):
"""Test codex command with flags before file."""
original = "codex --skip-git-repo-check hello-world.prompt.md"
result = self.script_runner._transform_runtime_command(
original, "hello-world.prompt.md", self.compiled_content, self.compiled_path
)
assert result == "codex exec --skip-git-repo-check"
def test_transform_runtime_command_codex_multiple_flags(self):
"""Test codex command with multiple flags before file."""
original = "codex --verbose --skip-git-repo-check hello-world.prompt.md"
result = self.script_runner._transform_runtime_command(
original, "hello-world.prompt.md", self.compiled_content, self.compiled_path
)
assert result == "codex exec --verbose --skip-git-repo-check"
def test_transform_runtime_command_env_var_simple(self):
"""Test environment variable with simple codex command."""
original = "DEBUG=true codex hello-world.prompt.md"
result = self.script_runner._transform_runtime_command(
original, "hello-world.prompt.md", self.compiled_content, self.compiled_path
)
assert result == "DEBUG=true codex exec"
def test_transform_runtime_command_env_var_with_flags(self):
"""Test environment variable with codex flags."""
original = "DEBUG=true codex --skip-git-repo-check hello-world.prompt.md"
result = self.script_runner._transform_runtime_command(
original, "hello-world.prompt.md", self.compiled_content, self.compiled_path
)
assert result == "DEBUG=true codex exec --skip-git-repo-check"
def test_transform_runtime_command_llm_simple(self):
"""Test simple llm command transformation."""
original = "llm hello-world.prompt.md"
result = self.script_runner._transform_runtime_command(
original, "hello-world.prompt.md", self.compiled_content, self.compiled_path
)
assert result == "llm"
def test_transform_runtime_command_llm_with_options(self):
"""Test llm command with options after file."""
original = "llm hello-world.prompt.md --model gpt-4"
result = self.script_runner._transform_runtime_command(
original, "hello-world.prompt.md", self.compiled_content, self.compiled_path
)
assert result == "llm --model gpt-4"
def test_transform_runtime_command_bare_file(self):
"""Test bare prompt file defaults to codex exec."""
original = "hello-world.prompt.md"
result = self.script_runner._transform_runtime_command(
original, "hello-world.prompt.md", self.compiled_content, self.compiled_path
)
assert result == "codex exec"
def test_transform_runtime_command_fallback(self):
"""Test fallback behavior for unrecognized patterns."""
original = "unknown-command hello-world.prompt.md"
result = self.script_runner._transform_runtime_command(
original, "hello-world.prompt.md", self.compiled_content, self.compiled_path
)
assert result == f"unknown-command {self.compiled_path}"
def test_transform_runtime_command_copilot_simple(self):
"""Test simple copilot command transformation."""
original = "copilot hello-world.prompt.md"
result = self.script_runner._transform_runtime_command(
original, "hello-world.prompt.md", self.compiled_content, self.compiled_path
)
assert result == "copilot"
def test_transform_runtime_command_copilot_with_flags(self):
"""Test copilot command with flags before file."""
original = "copilot --log-level all --log-dir copilot-logs hello-world.prompt.md"
result = self.script_runner._transform_runtime_command(
original, "hello-world.prompt.md", self.compiled_content, self.compiled_path
)
assert result == "copilot --log-level all --log-dir copilot-logs"
def test_transform_runtime_command_copilot_removes_p_flag(self):
"""Test copilot command removes existing -p flag since it's handled separately."""
original = "copilot -p hello-world.prompt.md --log-level all"
result = self.script_runner._transform_runtime_command(
original, "hello-world.prompt.md", self.compiled_content, self.compiled_path
)
assert result == "copilot --log-level all"
def test_detect_runtime_copilot(self):
"""Test runtime detection for copilot commands."""
assert self.script_runner._detect_runtime("copilot --log-level all") == "copilot"
def test_detect_runtime_codex(self):
"""Test runtime detection for codex commands."""
assert self.script_runner._detect_runtime("codex exec --skip-git-repo-check") == "codex"
def test_detect_runtime_llm(self):
"""Test runtime detection for llm commands."""
assert self.script_runner._detect_runtime("llm --model gpt-4") == "llm"
def test_detect_runtime_unknown(self):
"""Test runtime detection for unknown commands."""
assert self.script_runner._detect_runtime("unknown-command") == "unknown"
def test_detect_runtime_model_name_containing_codex(self):
"""codex as a substring of a model name should not be detected as the codex runtime."""
# e.g. copilot --model gpt-5.3-codex - the runtime is copilot, not codex
assert self.script_runner._detect_runtime("copilot --model gpt-5.3-codex") == "copilot"
def test_detect_runtime_hyphenated_codex(self):
"""A hyphen-prefixed codex substring must not trigger codex detection."""
assert self.script_runner._detect_runtime("run-codex-tool --flag") == "unknown"
def test_transform_runtime_command_copilot_with_codex_model(self):
"""copilot command using --model containing 'codex' must not be mis-routed to codex runtime."""
original = "copilot --allow-all-tools --model gpt-5.3-codex -p fix-issue.prompt.md"
result = self.script_runner._transform_runtime_command(
original, "fix-issue.prompt.md", self.compiled_content, self.compiled_path
)
# Should be treated as a copilot command, not transformed into "codex exec ..."
assert result.startswith("copilot")
assert "codex exec" not in result
def test_transform_runtime_command_copilot_with_codex_model_name(self):
"""--model codex (bare word as model name) must not trigger codex runtime path."""
original = "copilot --model codex -p fix-issue.prompt.md"
result = self.script_runner._transform_runtime_command(
original, "fix-issue.prompt.md", self.compiled_content, self.compiled_path
)
assert result.startswith("copilot")
assert "codex exec" not in result
@patch('subprocess.run')
@patch('apm_cli.core.script_runner.shutil.which', return_value=None)
@patch('apm_cli.core.script_runner.setup_runtime_environment')
@patch('apm_cli.core.script_runner.Path.home', return_value=Path('/nonexistent/home'))
def test_execute_runtime_command_with_env_vars(self, mock_home, mock_setup_env, mock_which, mock_subprocess):
"""Test runtime command execution with environment variables."""
mock_setup_env.return_value = {'EXISTING_VAR': 'value'}
mock_subprocess.return_value.returncode = 0
# Test command with environment variable prefix
command = "RUST_LOG=debug codex exec --skip-git-repo-check"
content = "test content"
env = {'EXISTING_VAR': 'value'}
result = self.script_runner._execute_runtime_command(command, content, env)
# Verify subprocess was called with correct arguments and environment
mock_subprocess.assert_called_once()
args, kwargs = mock_subprocess.call_args
# Check command arguments (should not include environment variable)
called_args = args[0]
assert called_args == ["codex", "exec", "--skip-git-repo-check", content]
# Check environment variables were properly set
called_env = kwargs['env']
assert called_env['RUST_LOG'] == 'debug'
assert called_env['EXISTING_VAR'] == 'value' # Existing env should be preserved
@patch('subprocess.run')
@patch('apm_cli.core.script_runner.shutil.which', return_value=None)
@patch('apm_cli.core.script_runner.setup_runtime_environment')
@patch('apm_cli.core.script_runner.Path.home', return_value=Path('/nonexistent/home'))
def test_execute_runtime_command_multiple_env_vars(self, mock_home, mock_setup_env, mock_which, mock_subprocess):
"""Test runtime command execution with multiple environment variables."""
mock_setup_env.return_value = {}
mock_subprocess.return_value.returncode = 0
# Test command with multiple environment variables
command = "DEBUG=1 VERBOSE=true llm --model gpt-4"
content = "test content"
env = {}
result = self.script_runner._execute_runtime_command(command, content, env)
# Verify subprocess was called with correct arguments and environment
mock_subprocess.assert_called_once()
args, kwargs = mock_subprocess.call_args
# Check command arguments (should not include environment variables)
called_args = args[0]
assert called_args == ["llm", "--model", "gpt-4", content]
# Check environment variables were properly set
called_env = kwargs['env']
assert called_env['DEBUG'] == '1'
assert called_env['VERBOSE'] == 'true'
@patch('apm_cli.core.script_runner.Path.exists')
@patch('builtins.open', new_callable=mock_open, read_data="scripts:\n start: 'codex hello.prompt.md'")
def test_list_scripts(self, mock_file, mock_exists):
"""Test listing scripts from apm.yml."""
mock_exists.return_value = True
scripts = self.script_runner.list_scripts()
assert 'start' in scripts
assert scripts['start'] == 'codex hello.prompt.md'
class TestPromptCompiler:
"""Test PromptCompiler functionality."""
def setup_method(self):
"""Set up test fixtures."""
self.compiler = PromptCompiler()
def test_substitute_parameters_simple(self):
"""Test simple parameter substitution."""
content = "Hello ${input:name}!"
params = {"name": "World"}
result = self.compiler._substitute_parameters(content, params)
assert result == "Hello World!"
def test_substitute_parameters_multiple(self):
"""Test multiple parameter substitution."""
content = "Service: ${input:service}, Environment: ${input:env}"
params = {"service": "api", "env": "production"}
result = self.compiler._substitute_parameters(content, params)
assert result == "Service: api, Environment: production"
def test_substitute_parameters_no_params(self):
"""Test content with no parameters to substitute."""
content = "This is a simple prompt with no parameters."
params = {}
result = self.compiler._substitute_parameters(content, params)
assert result == content
def test_substitute_parameters_missing_param(self):
"""Test behavior when parameter is missing."""
content = "Hello ${input:name}!"
params = {}
result = self.compiler._substitute_parameters(content, params)
# Should leave placeholder unchanged when parameter is missing
assert result == "Hello ${input:name}!"
@patch('apm_cli.core.script_runner.Path.mkdir')
@patch('apm_cli.core.script_runner.Path.exists')
@patch('builtins.open', new_callable=mock_open)
def test_compile_with_frontmatter(self, mock_file, mock_exists, mock_mkdir):
"""Test compiling prompt file with frontmatter."""
mock_exists.return_value = True
# Mock file content with frontmatter
file_content = """---
description: Test prompt
input:
- name
---
# Test Prompt
Hello ${input:name}!"""
mock_file.return_value.read.return_value = file_content
result_path = self.compiler.compile("test.prompt.md", {"name": "World"})
# Check that the compiled content was written correctly
mock_file.return_value.write.assert_called_once()
written_content = mock_file.return_value.write.call_args[0][0]
assert "Hello World!" in written_content
assert "---" not in written_content # Frontmatter should be stripped
@patch('apm_cli.core.script_runner.Path.mkdir')
@patch('apm_cli.core.script_runner.Path.exists')
@patch('builtins.open', new_callable=mock_open)
def test_compile_without_frontmatter(self, mock_file, mock_exists, mock_mkdir):
"""Test compiling prompt file without frontmatter."""
mock_exists.return_value = True
# Mock file content without frontmatter
file_content = "Hello ${input:name}!"
mock_file.return_value.read.return_value = file_content
result_path = self.compiler.compile("test.prompt.md", {"name": "World"})
# Check that the compiled content was written correctly
mock_file.return_value.write.assert_called_once()
written_content = mock_file.return_value.write.call_args[0][0]
assert written_content == "Hello World!"
@patch('apm_cli.core.script_runner.Path.exists')
def test_compile_file_not_found(self, mock_exists):
"""Test compiling non-existent prompt file."""
mock_exists.return_value = False
with pytest.raises(FileNotFoundError, match="Prompt file 'nonexistent.prompt.md' not found"):
self.compiler.compile("nonexistent.prompt.md", {})
class TestPromptCompilerDependencyDiscovery:
"""Test PromptCompiler dependency discovery functionality."""
def setup_method(self):
"""Set up test fixtures."""
self.compiler = PromptCompiler()
def test_resolve_prompt_file_local_exists(self):
"""Test resolving prompt file when it exists locally."""
with tempfile.TemporaryDirectory() as tmpdir:
# Change to temp directory for test
original_cwd = Path.cwd()
try:
import os
os.chdir(tmpdir)
# Create local prompt file
prompt_file = Path("hello-world.prompt.md")
prompt_file.write_text("Hello World!")
result = self.compiler._resolve_prompt_file("hello-world.prompt.md")
assert result == prompt_file
finally:
os.chdir(original_cwd)
def test_resolve_prompt_file_dependency_root(self):
"""Test resolving prompt file from dependency root directory."""
with tempfile.TemporaryDirectory() as tmpdir:
original_cwd = Path.cwd()
try:
import os
os.chdir(tmpdir)
# Create apm_modules structure with org/repo hierarchy
dep_dir = Path("apm_modules/microsoft/apm-sample-package")
dep_dir.mkdir(parents=True)
# Create prompt file in dependency root
dep_prompt = dep_dir / "hello-world.prompt.md"
dep_prompt.write_text("Hello from dependency!")
result = self.compiler._resolve_prompt_file("hello-world.prompt.md")
assert result == dep_prompt
finally:
os.chdir(original_cwd)
def test_resolve_prompt_file_dependency_subdirectory(self):
"""Test resolving prompt file from dependency subdirectory."""
with tempfile.TemporaryDirectory() as tmpdir:
original_cwd = Path.cwd()
try:
import os
os.chdir(tmpdir)
# Create apm_modules structure
dep_dir = Path("apm_modules/design-guidelines")
dep_dir.mkdir(parents=True)
# Create prompt file in prompts subdirectory
prompts_dir = dep_dir / "prompts"
prompts_dir.mkdir()
dep_prompt = prompts_dir / "hello-world.prompt.md"
dep_prompt.write_text("Hello from dependency prompts!")
result = self.compiler._resolve_prompt_file("hello-world.prompt.md")
assert result == dep_prompt
finally:
os.chdir(original_cwd)
def test_resolve_prompt_file_multiple_dependencies(self):
"""Test resolving prompt file with multiple dependencies (first match wins)."""
with tempfile.TemporaryDirectory() as tmpdir:
original_cwd = Path.cwd()
try:
import os
os.chdir(tmpdir)
# Create multiple dependency directories with org/repo structure
compliance_dir = Path("apm_modules/acme/compliance-rules")
compliance_dir.mkdir(parents=True)
design_dir = Path("apm_modules/microsoft/apm-sample-package")
design_dir.mkdir(parents=True)
# Create prompt files in both (first one found should win)
compliance_prompt = compliance_dir / "hello-world.prompt.md"
compliance_prompt.write_text("Hello from compliance!")
design_prompt = design_dir / "hello-world.prompt.md"
design_prompt.write_text("Hello from design!")
result = self.compiler._resolve_prompt_file("hello-world.prompt.md")
# Should return one of the matches (doesn't matter which since both exist)
assert result in [compliance_prompt, design_prompt]
assert result.exists()
assert result.read_text().startswith("Hello from")
finally:
os.chdir(original_cwd)
def test_resolve_prompt_file_no_apm_modules(self):
"""Test resolving prompt file when apm_modules directory doesn't exist."""
with tempfile.TemporaryDirectory() as tmpdir:
original_cwd = Path.cwd()
try:
import os
os.chdir(tmpdir)
# No apm_modules directory exists
with pytest.raises(FileNotFoundError) as exc_info:
self.compiler._resolve_prompt_file("hello-world.prompt.md")
error_msg = str(exc_info.value)
assert "Prompt file 'hello-world.prompt.md' not found" in error_msg
assert "Local: hello-world.prompt.md" in error_msg
assert "Run 'apm install'" in error_msg
finally:
os.chdir(original_cwd)
def test_resolve_prompt_file_not_found_anywhere(self):
"""Test resolving prompt file when it's not found anywhere."""
with tempfile.TemporaryDirectory() as tmpdir:
original_cwd = Path.cwd()
try:
import os
os.chdir(tmpdir)
# Create apm_modules with dependencies but no prompt files
compliance_dir = Path("apm_modules/acme/compliance-rules")
compliance_dir.mkdir(parents=True)
design_dir = Path("apm_modules/microsoft/apm-sample-package")
design_dir.mkdir(parents=True)
with pytest.raises(FileNotFoundError) as exc_info:
self.compiler._resolve_prompt_file("hello-world.prompt.md")
error_msg = str(exc_info.value)
assert "Prompt file 'hello-world.prompt.md' not found" in error_msg
assert "Local: hello-world.prompt.md" in error_msg
assert "Dependencies:" in error_msg
assert "acme/compliance-rules/hello-world.prompt.md" in error_msg
assert "microsoft/apm-sample-package/hello-world.prompt.md" in error_msg
finally:
os.chdir(original_cwd)
def test_resolve_prompt_file_local_takes_precedence(self):
"""Test that local file takes precedence over dependency files."""
with tempfile.TemporaryDirectory() as tmpdir:
original_cwd = Path.cwd()
try:
import os
os.chdir(tmpdir)
# Create local prompt file
local_prompt = Path("hello-world.prompt.md")
local_prompt.write_text("Hello from local!")
# Create dependency with same file
dep_dir = Path("apm_modules/microsoft/apm-sample-package")
dep_dir.mkdir(parents=True)
dep_prompt = dep_dir / "hello-world.prompt.md"
dep_prompt.write_text("Hello from dependency!")
result = self.compiler._resolve_prompt_file("hello-world.prompt.md")
# Local should take precedence
assert result == local_prompt
finally:
os.chdir(original_cwd)
@patch('apm_cli.core.script_runner.Path.mkdir')
@patch('builtins.open', new_callable=mock_open)
def test_compile_with_dependency_resolution(self, mock_file, mock_mkdir):
"""Test compile method uses dependency resolution correctly."""
with patch.object(self.compiler, '_resolve_prompt_file') as mock_resolve:
mock_resolve.return_value = Path("apm_modules/microsoft/apm-sample-package/test.prompt.md")
file_content = "Hello ${input:name}!"
mock_file.return_value.read.return_value = file_content
result_path = self.compiler.compile("test.prompt.md", {"name": "World"})
# Verify _resolve_prompt_file was called
mock_resolve.assert_called_once_with("test.prompt.md")
# Verify file was opened with resolved path
mock_file.assert_called()
opened_path = mock_file.call_args_list[0][0][0]
assert str(opened_path).replace("\\", "/") == "apm_modules/microsoft/apm-sample-package/test.prompt.md"
class TestScriptRunnerAutoInstall:
"""Test ScriptRunner auto-install functionality."""
def setup_method(self):
"""Set up test fixtures."""
self.script_runner = ScriptRunner()
def test_is_virtual_package_reference_valid_file(self):
"""Test detection of valid virtual file package references."""
# Valid virtual file package reference
ref = "owner/test-repo/prompts/architecture-blueprint-generator.prompt.md"
assert self.script_runner._is_virtual_package_reference(ref) is True
def test_is_virtual_package_reference_valid_collection(self):
"""Test detection of valid virtual collection package references."""
# Valid virtual collection package reference
ref = "owner/test-repo/collections/project-planning"
assert self.script_runner._is_virtual_package_reference(ref) is True
def test_is_virtual_package_reference_regular_package(self):
"""Test detection rejects regular packages."""
# Regular package (not virtual)
ref = "microsoft/apm-sample-package"
assert self.script_runner._is_virtual_package_reference(ref) is False
def test_is_virtual_package_reference_simple_name(self):
"""Test detection rejects simple names without slashes."""
# Simple name (not a virtual package)
ref = "code-review"
assert self.script_runner._is_virtual_package_reference(ref) is False
def test_is_virtual_package_reference_invalid_format(self):
"""Test detection rejects invalid formats."""
# Invalid format - looks like a file with unsupported extension
ref = "owner/repo/some/invalid/path.txt"
assert self.script_runner._is_virtual_package_reference(ref) is False
@patch('apm_cli.deps.github_downloader.GitHubPackageDownloader')
@patch('apm_cli.core.script_runner.Path.mkdir')
@patch('apm_cli.core.script_runner.Path.exists')
def test_auto_install_virtual_package_file_success(self, mock_exists, mock_mkdir, mock_downloader_class):
"""Test successful auto-install of virtual file package."""
# Setup mocks
mock_exists.return_value = False # Package not already installed
mock_downloader = MagicMock()
mock_downloader_class.return_value = mock_downloader
# Mock package info
mock_package = MagicMock()
mock_package.name = "test-repo-architecture-blueprint-generator"
mock_package.version = "1.0.0"
mock_package_info = MagicMock()
mock_package_info.package = mock_package
mock_downloader.download_virtual_file_package.return_value = mock_package_info
# Test auto-install
ref = "owner/test-repo/prompts/architecture-blueprint-generator.prompt.md"
result = self.script_runner._auto_install_virtual_package(ref)
assert result is True
mock_downloader.download_virtual_file_package.assert_called_once()
@patch('apm_cli.deps.github_downloader.GitHubPackageDownloader')
@patch('apm_cli.core.script_runner.Path.mkdir')
@patch('apm_cli.core.script_runner.Path.exists')
def test_auto_install_virtual_package_collection_success(self, mock_exists, mock_mkdir, mock_downloader_class):
"""Test successful auto-install of virtual collection package."""
# Setup mocks
mock_exists.return_value = False # Package not already installed
mock_downloader = MagicMock()
mock_downloader_class.return_value = mock_downloader
# Mock package info
mock_package = MagicMock()
mock_package.name = "test-repo-project-planning"
mock_package.version = "1.0.0"
mock_package_info = MagicMock()
mock_package_info.package = mock_package
mock_downloader.download_virtual_collection_package.return_value = mock_package_info
# Test auto-install
ref = "owner/test-repo/collections/project-planning"
result = self.script_runner._auto_install_virtual_package(ref)
assert result is True
mock_downloader.download_virtual_collection_package.assert_called_once()
@patch('apm_cli.core.script_runner.Path.exists')
def test_auto_install_virtual_package_already_installed(self, mock_exists):
"""Test auto-install skips when package already installed."""
# Package already exists
mock_exists.return_value = True
ref = "owner/test-repo/prompts/architecture-blueprint-generator.prompt.md"
result = self.script_runner._auto_install_virtual_package(ref)
assert result is True # Should return True (success) without downloading
@patch('apm_cli.deps.github_downloader.GitHubPackageDownloader')
@patch('apm_cli.core.script_runner.Path.mkdir')
@patch('apm_cli.core.script_runner.Path.exists')
def test_auto_install_virtual_package_download_failure(self, mock_exists, mock_mkdir, mock_downloader_class):
"""Test auto-install handles download failures gracefully."""
# Setup mocks
mock_exists.return_value = False
mock_downloader = MagicMock()
mock_downloader_class.return_value = mock_downloader
# Simulate download failure
mock_downloader.download_virtual_file_package.side_effect = RuntimeError("Download failed")
# Test auto-install
ref = "owner/test-repo/prompts/architecture-blueprint-generator.prompt.md"
result = self.script_runner._auto_install_virtual_package(ref)
assert result is False # Should return False on failure
def test_auto_install_virtual_package_invalid_reference(self):
"""Test auto-install rejects invalid references."""
# Not a virtual package
ref = "microsoft/apm-sample-package"
result = self.script_runner._auto_install_virtual_package(ref)
assert result is False
@patch('apm_cli.deps.github_downloader.GitHubPackageDownloader')
@patch('apm_cli.core.script_runner.Path.mkdir')
@patch('apm_cli.core.script_runner.Path.exists')
def test_auto_install_virtual_package_subdirectory_success(self, mock_exists, mock_mkdir, mock_downloader_class):
"""Test successful auto-install of virtual subdirectory (skill) package."""
# Setup mocks
mock_exists.return_value = False # Package not already installed
mock_downloader = MagicMock()
mock_downloader_class.return_value = mock_downloader
# Mock package info
mock_package = MagicMock()
mock_package.name = "architecture-blueprint-generator"
mock_package.version = "1.0.0"
mock_package_info = MagicMock()
mock_package_info.package = mock_package
mock_downloader.download_subdirectory_package.return_value = mock_package_info
# Test auto-install with subdirectory reference (no .prompt.md extension)
ref = "github/awesome-copilot/skills/architecture-blueprint-generator"
result = self.script_runner._auto_install_virtual_package(ref)
assert result is True
mock_downloader.download_subdirectory_package.assert_called_once()
@patch('apm_cli.core.script_runner.ScriptRunner._auto_install_virtual_package')
@patch('apm_cli.core.script_runner.ScriptRunner._discover_prompt_file')
@patch('apm_cli.core.script_runner.ScriptRunner._detect_installed_runtime')
@patch('apm_cli.core.script_runner.ScriptRunner._execute_script_command')
@patch('apm_cli.core.script_runner.Path.exists')
@patch('builtins.open', new_callable=mock_open, read_data="name: test\nscripts: {}")
def test_run_script_triggers_auto_install(self, mock_file, mock_exists, mock_execute,
mock_runtime, mock_discover, mock_auto_install):
"""Test that run_script triggers auto-install for virtual package references."""
mock_exists.return_value = True # apm.yml exists
mock_discover.side_effect = [None, Path("apm_modules/github/test-repo-architecture-blueprint-generator/.apm/prompts/architecture-blueprint-generator.prompt.md")]
mock_auto_install.return_value = True
mock_runtime.return_value = "copilot"
mock_execute.return_value = True
ref = "owner/test-repo/prompts/architecture-blueprint-generator.prompt.md"
result = self.script_runner.run_script(ref, {})
# Verify auto-install was called
mock_auto_install.assert_called_once_with(ref)
# Verify discovery was attempted twice (before and after install)
assert mock_discover.call_count == 2
# Verify script was executed
mock_execute.assert_called_once()
assert result is True
@patch('apm_cli.core.script_runner.ScriptRunner._auto_install_virtual_package')
@patch('apm_cli.core.script_runner.ScriptRunner._discover_prompt_file')
@patch('apm_cli.core.script_runner.Path.exists')
@patch('builtins.open', new_callable=mock_open, read_data="name: test\nscripts: {}")
def test_run_script_auto_install_failure_shows_error(self, mock_file, mock_exists,
mock_discover, mock_auto_install):
"""Test that run_script shows helpful error when auto-install fails."""
mock_exists.return_value = True # apm.yml exists
mock_discover.return_value = None
mock_auto_install.return_value = False # Auto-install failed
ref = "owner/test-repo/prompts/architecture-blueprint-generator.prompt.md"
with pytest.raises(RuntimeError) as exc_info:
self.script_runner.run_script(ref, {})
error_msg = str(exc_info.value)
assert "Script or prompt" in error_msg
assert "not found" in error_msg
@patch('apm_cli.core.script_runner.ScriptRunner._auto_install_virtual_package')
@patch('apm_cli.core.script_runner.ScriptRunner._discover_prompt_file')
@patch('apm_cli.core.script_runner.Path.exists')
@patch('builtins.open', new_callable=mock_open, read_data="name: test\nscripts: {}")
def test_run_script_skips_auto_install_for_simple_names(self, mock_file, mock_exists,
mock_discover, mock_auto_install):
"""Test that run_script doesn't trigger auto-install for simple names."""
mock_exists.return_value = True # apm.yml exists
mock_discover.return_value = None
# Simple name (not a virtual package reference)
ref = "code-review"
with pytest.raises(RuntimeError):
self.script_runner.run_script(ref, {})
# Auto-install should NOT be called for simple names
mock_auto_install.assert_not_called()
@patch('apm_cli.core.script_runner.ScriptRunner._discover_prompt_file')
@patch('apm_cli.core.script_runner.ScriptRunner._detect_installed_runtime')
@patch('apm_cli.core.script_runner.ScriptRunner._execute_script_command')
@patch('apm_cli.core.script_runner.Path.exists')
@patch('builtins.open', new_callable=mock_open, read_data="name: test\nscripts: {}")
def test_run_script_uses_cached_package(self, mock_file, mock_exists, mock_execute,
mock_runtime, mock_discover):
"""Test that run_script uses already-installed package without re-downloading."""
mock_exists.return_value = True # apm.yml exists
# Package already discovered (no auto-install needed)
mock_discover.return_value = Path("apm_modules/github/test-repo-architecture-blueprint-generator/.apm/prompts/architecture-blueprint-generator.prompt.md")
mock_runtime.return_value = "copilot"
mock_execute.return_value = True
ref = "owner/test-repo/prompts/architecture-blueprint-generator.prompt.md"
result = self.script_runner.run_script(ref, {})
# Verify discovery found it on first try
mock_discover.assert_called_once()
# Verify script was executed
mock_execute.assert_called_once()
assert result is True
@patch('apm_cli.core.script_runner.ScriptRunner._auto_install_virtual_package')
@patch('apm_cli.core.script_runner.ScriptRunner._discover_prompt_file')
@patch('apm_cli.core.script_runner.Path.exists')
@patch('builtins.open', new_callable=mock_open, read_data="name: test\nscripts: {}")
def test_run_script_handles_install_success_but_no_prompt(self, mock_file, mock_exists,
mock_discover, mock_auto_install):
"""Test error when package installs successfully but prompt not found."""
mock_exists.return_value = True # apm.yml exists
mock_discover.side_effect = [None, None] # Not found before or after install
mock_auto_install.return_value = True # Install succeeded
ref = "owner/test-repo/prompts/architecture-blueprint-generator.prompt.md"
with pytest.raises(RuntimeError) as exc_info:
self.script_runner.run_script(ref, {})
error_msg = str(exc_info.value)
assert "Package installed successfully but prompt not found" in error_msg
assert "may not contain the expected prompt file" in error_msg
def test_discover_qualified_prompt_finds_skill_md(self):
"""Test that _discover_qualified_prompt finds SKILL.md for subdirectory packages."""
with tempfile.TemporaryDirectory() as temp_dir:
original_dir = os.getcwd()
os.chdir(temp_dir)
try:
# Create subdirectory skill package structure
skill_dir = Path("apm_modules/github/awesome-copilot/skills/architecture-blueprint-generator")
skill_dir.mkdir(parents=True)
skill_file = skill_dir / "SKILL.md"
skill_file.write_text("# Architecture Blueprint Generator Skill")
result = self.script_runner._discover_qualified_prompt(
"github/awesome-copilot/skills/architecture-blueprint-generator"
)
assert result is not None
assert result.name == "SKILL.md"
finally:
os.chdir(original_dir)
def test_discover_simple_name_finds_skill_md(self):
"""Test that _discover_prompt_file finds SKILL.md by simple name."""
with tempfile.TemporaryDirectory() as temp_dir:
original_dir = os.getcwd()
os.chdir(temp_dir)
try:
# Create subdirectory skill package installed in apm_modules
skill_dir = Path("apm_modules/github/awesome-copilot/skills/architecture-blueprint-generator")
skill_dir.mkdir(parents=True)
skill_file = skill_dir / "SKILL.md"
skill_file.write_text("# Architecture Blueprint Generator Skill")
result = self.script_runner._discover_prompt_file(
"architecture-blueprint-generator"
)
assert result is not None
assert result.name == "SKILL.md"
finally:
os.chdir(original_dir)
class TestExecuteRuntimeCommandWindowsResolution:
"""Test that _execute_runtime_command resolves executables on Windows."""
def setup_method(self):
self.runner = ScriptRunner()
@patch("apm_cli.core.script_runner.subprocess.run")
@patch("apm_cli.core.script_runner.shutil.which", return_value=r"C:\npm\copilot.cmd")
@patch("apm_cli.core.script_runner.sys")
def test_resolves_executable_on_windows(self, mock_sys, mock_which, mock_run):
"""On win32, the executable should be resolved via shutil.which."""
mock_sys.platform = "win32"
mock_run.return_value = MagicMock(returncode=0)
self.runner._execute_runtime_command(
"copilot --log-level all", "prompt content", os.environ.copy()
)
mock_which.assert_called_once_with("copilot")
call_args = mock_run.call_args[0][0]
assert call_args[0] == r"C:\npm\copilot.cmd"
@patch("apm_cli.core.script_runner.subprocess.run")
@patch("apm_cli.core.script_runner.shutil.which", return_value=None)
@patch("apm_cli.core.script_runner.sys")
def test_keeps_original_when_which_returns_none(self, mock_sys, mock_which, mock_run):
"""If shutil.which can't find it, keep the original name."""
mock_sys.platform = "win32"
mock_run.return_value = MagicMock(returncode=0)
self.runner._execute_runtime_command(
"copilot -p", "prompt content", os.environ.copy()
)
call_args = mock_run.call_args[0][0]
assert call_args[0] == "copilot"
@patch("apm_cli.core.script_runner.subprocess.run")
@patch("apm_cli.core.script_runner.shutil.which")
@patch("apm_cli.core.script_runner.sys")
def test_skips_resolution_on_non_windows(self, mock_sys, mock_which, mock_run):
"""On non-Windows, shutil.which should not be called."""
mock_sys.platform = "linux"
mock_run.return_value = MagicMock(returncode=0)
self.runner._execute_runtime_command(
"copilot -p", "prompt content", os.environ.copy()
)
mock_which.assert_not_called()