"""Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives.""" import sys import pytest from specify_cli.integrations.base import ( IntegrationBase, IntegrationOption, MarkdownIntegration, SkillsIntegration, ) from specify_cli.integrations.manifest import IntegrationManifest from .conftest import StubIntegration class TestIntegrationOption: def test_defaults(self): opt = IntegrationOption(name="--flag") assert opt.name == "--flag" assert opt.is_flag is False assert opt.required is False assert opt.default is None assert opt.help == "" def test_flag_option(self): opt = IntegrationOption(name="--skills", is_flag=True, default=True, help="Enable skills") assert opt.is_flag is True assert opt.default is True assert opt.help == "Enable skills" def test_required_option(self): opt = IntegrationOption(name="--commands-dir", required=True, help="Dir path") assert opt.required is True def test_frozen(self): opt = IntegrationOption(name="--x") with pytest.raises(AttributeError): opt.name = "--y" # type: ignore[misc] class TestIntegrationBase: def test_key_and_config(self): i = StubIntegration() assert i.key == "stub" assert i.config["name"] == "Stub Agent" assert i.registrar_config["format"] == "markdown" def test_options_default_empty(self): assert StubIntegration.options() == [] def test_shared_commands_dir(self): i = StubIntegration() cmd_dir = i.shared_commands_dir() assert cmd_dir is not None assert cmd_dir.is_dir() def test_setup_uses_shared_templates(self, tmp_path): i = StubIntegration() manifest = IntegrationManifest("stub", tmp_path) created = i.setup(tmp_path, manifest) assert len(created) > 0 for f in created: assert f.parent == tmp_path / ".stub" / "commands" assert f.name.startswith("speckit.") assert f.name.endswith(".md") def test_setup_copies_templates(self, tmp_path, monkeypatch): tpl = tmp_path / "_templates" tpl.mkdir() (tpl / "plan.md").write_text("plan content", encoding="utf-8") (tpl / "specify.md").write_text("spec content", encoding="utf-8") i = StubIntegration() monkeypatch.setattr(type(i), "list_command_templates", lambda self: sorted(tpl.glob("*.md"))) project = tmp_path / "project" project.mkdir() created = i.setup(project, IntegrationManifest("stub", project)) assert len(created) == 2 assert (project / ".stub" / "commands" / "speckit.plan.md").exists() assert (project / ".stub" / "commands" / "speckit.specify.md").exists() def test_install_delegates_to_setup(self, tmp_path): i = StubIntegration() manifest = IntegrationManifest("stub", tmp_path) result = i.install(tmp_path, manifest) assert len(result) > 0 def test_uninstall_delegates_to_teardown(self, tmp_path): i = StubIntegration() manifest = IntegrationManifest("stub", tmp_path) removed, skipped = i.uninstall(tmp_path, manifest) assert removed == [] assert skipped == [] class TestMarkdownIntegration: def test_is_subclass_of_base(self): assert issubclass(MarkdownIntegration, IntegrationBase) def test_stub_is_markdown(self): assert isinstance(StubIntegration(), MarkdownIntegration) class TestBasePrimitives: def test_shared_commands_dir_returns_path(self): i = StubIntegration() cmd_dir = i.shared_commands_dir() assert cmd_dir is not None assert cmd_dir.is_dir() def test_shared_templates_dir_returns_path(self): i = StubIntegration() tpl_dir = i.shared_templates_dir() assert tpl_dir is not None assert tpl_dir.is_dir() def test_list_command_templates_returns_md_files(self): i = StubIntegration() templates = i.list_command_templates() assert len(templates) > 0 assert all(t.suffix == ".md" for t in templates) def test_list_command_templates_keeps_checklist_after_plan(self): i = StubIntegration() stems = [template.stem for template in i.list_command_templates()] assert stems.index("plan") < stems.index("checklist") def test_command_filename_default(self): i = StubIntegration() assert i.command_filename("plan") == "speckit.plan.md" def test_commands_dest(self, tmp_path): i = StubIntegration() dest = i.commands_dest(tmp_path) assert dest == tmp_path / ".stub" / "commands" def test_commands_dest_no_config_raises(self, tmp_path): class NoConfig(MarkdownIntegration): key = "noconfig" with pytest.raises(ValueError, match="config is not set"): NoConfig().commands_dest(tmp_path) def test_copy_command_to_directory(self, tmp_path): src = tmp_path / "source.md" src.write_text("content", encoding="utf-8") dest_dir = tmp_path / "output" result = IntegrationBase.copy_command_to_directory(src, dest_dir, "speckit.plan.md") assert result == dest_dir / "speckit.plan.md" assert result.read_text(encoding="utf-8") == "content" def test_record_file_in_manifest(self, tmp_path): f = tmp_path / "f.txt" f.write_text("hello", encoding="utf-8") m = IntegrationManifest("test", tmp_path) IntegrationBase.record_file_in_manifest(f, tmp_path, m) assert "f.txt" in m.files def test_write_file_and_record(self, tmp_path): m = IntegrationManifest("test", tmp_path) dest = tmp_path / "sub" / "f.txt" result = IntegrationBase.write_file_and_record("content", dest, tmp_path, m) assert result == dest assert dest.read_text(encoding="utf-8") == "content" assert "sub/f.txt" in m.files def test_setup_copies_shared_templates(self, tmp_path): i = StubIntegration() m = IntegrationManifest("stub", tmp_path) created = i.setup(tmp_path, m) assert len(created) > 0 for f in created: assert f.parent.name == "commands" assert f.name.startswith("speckit.") assert f.name.endswith(".md") class TestBuildCommandInvocation: """Tests for build_command_invocation across integration types.""" def test_base_core_command_dotted(self): i = StubIntegration() assert i.build_command_invocation("speckit.plan") == "/speckit.plan" def test_base_core_command_bare(self): i = StubIntegration() assert i.build_command_invocation("plan") == "/speckit.plan" def test_base_core_command_with_args(self): i = StubIntegration() assert i.build_command_invocation("plan", "my feature") == "/speckit.plan my feature" def test_base_extension_command(self): i = StubIntegration() assert i.build_command_invocation("speckit.git.commit") == "/speckit.git.commit" def test_base_extension_command_bare(self): i = StubIntegration() assert i.build_command_invocation("git.commit") == "/speckit.git.commit" def test_skills_core_command(self): from specify_cli.integrations import get_integration i = get_integration("codex") assert i.build_command_invocation("speckit.plan") == "/speckit-plan" assert i.build_command_invocation("plan") == "/speckit-plan" def test_skills_extension_command(self): from specify_cli.integrations import get_integration i = get_integration("codex") assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit" assert i.build_command_invocation("git.commit") == "/speckit-git-commit" def test_skills_extension_command_with_args(self): from specify_cli.integrations import get_integration i = get_integration("codex") assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo" class TestResolveCommandRefs: """Tests for __SPECKIT_COMMAND___ placeholder resolution.""" def test_dot_separator_core_command(self): text = "Run `__SPECKIT_COMMAND_PLAN__` to plan." result = IntegrationBase.resolve_command_refs(text, ".") assert result == "Run `/speckit.plan` to plan." def test_hyphen_separator_core_command(self): text = "Run `__SPECKIT_COMMAND_PLAN__` to plan." result = IntegrationBase.resolve_command_refs(text, "-") assert result == "Run `/speckit-plan` to plan." def test_multiple_placeholders(self): text = "__SPECKIT_COMMAND_SPECIFY__ then __SPECKIT_COMMAND_PLAN__ then __SPECKIT_COMMAND_TASKS__" result = IntegrationBase.resolve_command_refs(text, ".") assert result == "/speckit.specify then /speckit.plan then /speckit.tasks" def test_extension_command_dot(self): text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit." result = IntegrationBase.resolve_command_refs(text, ".") assert result == "Run /speckit.git.commit to commit." def test_extension_command_hyphen(self): text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit." result = IntegrationBase.resolve_command_refs(text, "-") assert result == "Run /speckit-git-commit to commit." def test_no_placeholders_unchanged(self): text = "No placeholders here." assert IntegrationBase.resolve_command_refs(text, ".") == text def test_default_separator_is_dot(self): text = "__SPECKIT_COMMAND_PLAN__" assert IntegrationBase.resolve_command_refs(text) == "/speckit.plan" def test_invoke_separator_class_attribute(self): assert IntegrationBase.invoke_separator == "." assert SkillsIntegration.invoke_separator == "-" def test_effective_invoke_separator_default(self): """Base classes return invoke_separator regardless of parsed_options.""" from .conftest import StubIntegration stub = StubIntegration() assert stub.effective_invoke_separator() == "." assert stub.effective_invoke_separator({"skills": True}) == "." def test_process_template_resolves_placeholders(self): content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now." result = IntegrationBase.process_template( content, "test-agent", "sh", invoke_separator="." ) assert "/speckit.plan" in result assert "__SPECKIT_COMMAND_" not in result def test_process_template_skills_separator(self): content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now." result = IntegrationBase.process_template( content, "test-agent", "sh", invoke_separator="-" ) assert "/speckit-plan" in result assert "__SPECKIT_COMMAND_" not in result def test_unclosed_placeholder_unchanged(self): text = "Run __SPECKIT_COMMAND_PLAN to plan." assert IntegrationBase.resolve_command_refs(text, ".") == text def test_empty_name_not_matched(self): text = "Run __SPECKIT_COMMAND___ to plan." assert IntegrationBase.resolve_command_refs(text, ".") == text def test_lowercase_placeholder_not_matched(self): text = "Run __SPECKIT_COMMAND_plan__ to plan." assert IntegrationBase.resolve_command_refs(text, ".") == text def test_placeholder_adjacent_to_text(self): text = "foo__SPECKIT_COMMAND_PLAN__bar" result = IntegrationBase.resolve_command_refs(text, ".") assert result == "foo/speckit.planbar" def test_placeholder_with_digits(self): text = "__SPECKIT_COMMAND_V2_PLAN__" result = IntegrationBase.resolve_command_refs(text, ".") assert result == "/speckit.v2.plan" class TestResolvePythonInterpreter: def test_returns_python_on_path(self, monkeypatch): # Positive: when python3 is on PATH it is preferred over python. def fake_which(name): return f"/usr/bin/{name}" if name in ("python3", "python") else None monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", fake_which ) assert IntegrationBase.resolve_python_interpreter() == "python3" def test_falls_back_to_python_when_no_python3(self, monkeypatch): def fake_which(name): return "/usr/bin/python" if name == "python" else None monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", fake_which ) assert IntegrationBase.resolve_python_interpreter() == "python" def test_falls_back_to_sys_executable_when_nothing_found(self, monkeypatch): # Negative: nothing on PATH and no venv -> the running interpreter # (sys.executable) is used so the command works in this environment. monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", lambda name: None ) monkeypatch.setattr( "specify_cli.integrations.base.sys.executable", "/opt/py/bin/python" ) assert IntegrationBase.resolve_python_interpreter() == "/opt/py/bin/python" def test_falls_back_to_python3_when_no_interpreter_at_all(self, monkeypatch): # Negative edge: neither PATH nor sys.executable resolves. monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", lambda name: None ) monkeypatch.setattr( "specify_cli.integrations.base.sys.executable", "" ) assert IntegrationBase.resolve_python_interpreter() == "python3" def test_prefers_project_venv_posix(self, monkeypatch, tmp_path): venv_python = tmp_path / ".venv" / "bin" / "python" venv_python.parent.mkdir(parents=True) venv_python.write_text("") # Even if python3 is on PATH, the project venv wins. The returned # path is relative to the project root for portability. monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", lambda name: "/usr/bin/python3", ) result = IntegrationBase.resolve_python_interpreter(tmp_path) assert result == ".venv/bin/python" def test_prefers_project_venv_windows(self, monkeypatch, tmp_path): venv_python = tmp_path / ".venv" / "Scripts" / "python.exe" venv_python.parent.mkdir(parents=True) venv_python.write_text("") monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", lambda name: None ) result = IntegrationBase.resolve_python_interpreter(tmp_path) assert result == ".venv/Scripts/python.exe" def test_ignores_missing_venv(self, monkeypatch, tmp_path): # Negative: no venv directory -> PATH resolution is used instead. monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", lambda name: "/usr/bin/python3" if name == "python3" else None, ) assert IntegrationBase.resolve_python_interpreter(tmp_path) == "python3" class TestProcessTemplatePyScriptType: CONTENT = ( "---\n" "scripts:\n" " sh: scripts/bash/check-prerequisites.sh --json\n" " ps: scripts/powershell/check-prerequisites.ps1 -Json\n" " py: scripts/python/check-prerequisites.py --json\n" "---\n" "Run {SCRIPT} now." ) def test_py_prefixes_interpreter(self, monkeypatch): # Positive: py script type prefixes a resolved interpreter and the # script path is rewritten to the .specify location. monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", lambda name: "/usr/bin/python3" if name == "python3" else None, ) result = IntegrationBase.process_template(self.CONTENT, "agent", "py") assert "python3 .specify/scripts/python/check-prerequisites.py --json" in result # The scripts: frontmatter block is stripped. assert "scripts:" not in result def test_sh_does_not_prefix_interpreter(self): # Negative: non-py script types are never prefixed with an interpreter. result = IntegrationBase.process_template(self.CONTENT, "agent", "sh") assert ".specify/scripts/bash/check-prerequisites.sh --json" in result assert "python" not in result def test_py_quotes_interpreter_with_spaces(self, monkeypatch): # An interpreter path containing whitespace (e.g. Windows # ``Program Files``) must be quoted so it isn't split into args. monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", lambda name: None ) monkeypatch.setattr( "specify_cli.integrations.base.sys.executable", r"C:\Program Files\Python\python.exe", ) result = IntegrationBase.process_template(self.CONTENT, "agent", "py") assert ( '"C:\\Program Files\\Python\\python.exe" ' ".specify/scripts/python/check-prerequisites.py --json" ) in result def test_py_does_not_quote_interpreter_without_spaces(self, monkeypatch): # Negative: a whitespace-free interpreter is left unquoted. monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", lambda name: "/usr/bin/python3" if name == "python3" else None, ) result = IntegrationBase.process_template(self.CONTENT, "agent", "py") assert '"' not in result.split("check-prerequisites.py")[0] def test_py_uses_project_venv(self, monkeypatch, tmp_path): venv_python = tmp_path / ".venv" / "bin" / "python" venv_python.parent.mkdir(parents=True) venv_python.write_text("") result = IntegrationBase.process_template( self.CONTENT, "agent", "py", project_root=tmp_path ) assert ".venv/bin/python .specify/scripts/python/check-prerequisites.py" in result class TestInstallScriptsPython: def _make_integration_with_scripts(self, monkeypatch, tmp_path): scripts_src = tmp_path / "bundled_scripts" scripts_src.mkdir() (scripts_src / "common.py").write_text("print('hi')\n") (scripts_src / "common.sh").write_text("echo hi\n") (scripts_src / "notes.txt").write_text("not executable\n") integration = StubIntegration() monkeypatch.setattr( integration, "integration_scripts_dir", lambda: scripts_src ) return integration def test_copies_all_script_files(self, monkeypatch, tmp_path): # Cross-platform: every bundled file is copied into the project. integration = self._make_integration_with_scripts(monkeypatch, tmp_path) project_root = tmp_path / "proj" project_root.mkdir() manifest = IntegrationManifest("stub", project_root.resolve()) created = integration.install_scripts(project_root, manifest) names = {p.name for p in created} assert {"common.py", "common.sh", "notes.txt"} == names @pytest.mark.skipif( sys.platform == "win32", reason="chmod exec bit not reliable on Windows" ) def test_marks_py_and_sh_executable(self, monkeypatch, tmp_path): integration = self._make_integration_with_scripts(monkeypatch, tmp_path) project_root = tmp_path / "proj" project_root.mkdir() manifest = IntegrationManifest("stub", project_root.resolve()) integration.install_scripts(project_root, manifest) dest = project_root / ".specify" / "integrations" / "stub" / "scripts" py_file = dest / "common.py" sh_file = dest / "common.sh" txt_file = dest / "notes.txt" # Positive: .py and .sh are executable. assert py_file.stat().st_mode & 0o111 assert sh_file.stat().st_mode & 0o111 # Negative: a non-script file is not made executable. assert not (txt_file.stat().st_mode & 0o111)