|
6 | 6 | from __future__ import annotations |
7 | 7 |
|
8 | 8 | import json |
| 9 | +import shutil |
9 | 10 | from pathlib import Path |
10 | 11 | from unittest.mock import patch |
11 | 12 |
|
12 | 13 | import pytest |
| 14 | +import yaml |
13 | 15 | from typer.testing import CliRunner |
14 | 16 |
|
15 | 17 | from specify_cli import app |
@@ -218,3 +220,135 @@ def test_failed_registration_keeps_extension_disabled_and_retry_works( |
218 | 220 | assert retried.exit_code == 0, retried.output |
219 | 221 | assert _enabled(project, "test-ext") is True |
220 | 222 | assert generated.exists() |
| 223 | + |
| 224 | + |
| 225 | +class TestExtensionEnableOutputLayouts: |
| 226 | + """``enable`` checks the outputs registration actually writes.""" |
| 227 | + |
| 228 | + def test_unusable_skills_dir_fails_enable_and_retry_works_after_repair( |
| 229 | + self, tmp_path, extension_dir |
| 230 | + ): |
| 231 | + project = _init_project(tmp_path, "opencode") |
| 232 | + assert _run_in_project(project, ["extension", "add", str(extension_dir), "--dev"]).exit_code == 0 |
| 233 | + assert _run_in_project(project, ["extension", "disable", "test-ext"]).exit_code == 0 |
| 234 | + assert _run_in_project( |
| 235 | + project, |
| 236 | + ["integration", "switch", "copilot", "--script", "sh", |
| 237 | + "--integration-options", "--skills"], |
| 238 | + ).exit_code == 0 |
| 239 | + skills_dir = project / ".github" / "skills" |
| 240 | + shutil.rmtree(skills_dir) |
| 241 | + skills_dir.write_text("not a directory\n", encoding="utf-8") |
| 242 | + |
| 243 | + failed = _run_in_project(project, ["extension", "enable", "test-ext"]) |
| 244 | + |
| 245 | + assert failed.exit_code == 1, failed.output |
| 246 | + assert _enabled(project, "test-ext") is False |
| 247 | + |
| 248 | + skills_dir.unlink() |
| 249 | + retried = _run_in_project(project, ["extension", "enable", "test-ext"]) |
| 250 | + |
| 251 | + assert retried.exit_code == 0, retried.output |
| 252 | + assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").is_file() |
| 253 | + |
| 254 | + def test_home_relative_outputs_survive_disable_enable( |
| 255 | + self, tmp_path, extension_dir, monkeypatch |
| 256 | + ): |
| 257 | + home = tmp_path / "home" |
| 258 | + home.mkdir() |
| 259 | + monkeypatch.setattr(Path, "home", lambda: home) |
| 260 | + project = _init_project(tmp_path, "hermes") |
| 261 | + assert _run_in_project(project, ["extension", "add", str(extension_dir), "--dev"]).exit_code == 0 |
| 262 | + skill = home / ".hermes" / "skills" / "speckit-test-ext-hello" / "SKILL.md" |
| 263 | + assert skill.exists() |
| 264 | + skill.unlink() |
| 265 | + skill.write_text("customized by the user\n", encoding="utf-8") |
| 266 | + assert _run_in_project(project, ["extension", "disable", "test-ext"]).exit_code == 0 |
| 267 | + |
| 268 | + result = _run_in_project(project, ["extension", "enable", "test-ext"]) |
| 269 | + |
| 270 | + assert result.exit_code == 0, result.output |
| 271 | + assert _enabled(project, "test-ext") is True |
| 272 | + assert skill.read_text(encoding="utf-8") == "customized by the user\n" |
| 273 | + |
| 274 | + def test_legacy_command_dir_outputs_survive_disable_enable( |
| 275 | + self, tmp_path, extension_dir |
| 276 | + ): |
| 277 | + project = _init_project(tmp_path, "opencode") |
| 278 | + assert _run_in_project(project, ["extension", "add", str(extension_dir), "--dev"]).exit_code == 0 |
| 279 | + (project / ".opencode" / "commands").rename(project / ".opencode" / "command") |
| 280 | + legacy = project / ".opencode" / "command" / "speckit.test-ext.hello.md" |
| 281 | + assert legacy.exists() |
| 282 | + legacy.unlink() |
| 283 | + legacy.write_text("customized by the user\n", encoding="utf-8") |
| 284 | + assert _run_in_project(project, ["extension", "disable", "test-ext"]).exit_code == 0 |
| 285 | + |
| 286 | + result = _run_in_project(project, ["extension", "enable", "test-ext"]) |
| 287 | + |
| 288 | + assert result.exit_code == 0, result.output |
| 289 | + assert _enabled(project, "test-ext") is True |
| 290 | + assert legacy.read_text(encoding="utf-8") == "customized by the user\n" |
| 291 | + |
| 292 | + def test_failure_after_primary_write_rolls_back_and_retry_completes( |
| 293 | + self, tmp_path, extension_dir |
| 294 | + ): |
| 295 | + """Copilot command mode writes an agent file and a companion prompt; a |
| 296 | + prompt failure must not leave a half-registered, enabled extension.""" |
| 297 | + from specify_cli.agents import CommandRegistrar |
| 298 | + |
| 299 | + project = _init_project(tmp_path, "opencode") |
| 300 | + assert _run_in_project(project, ["extension", "add", str(extension_dir), "--dev"]).exit_code == 0 |
| 301 | + assert _run_in_project(project, ["extension", "disable", "test-ext"]).exit_code == 0 |
| 302 | + assert _run_in_project( |
| 303 | + project, |
| 304 | + ["integration", "switch", "copilot", "--script", "sh", |
| 305 | + "--integration-options", "--commands"], |
| 306 | + ).exit_code == 0 |
| 307 | + agent_file = project / ".github" / "agents" / "speckit.test-ext.hello.agent.md" |
| 308 | + prompt_file = project / ".github" / "prompts" / "speckit.test-ext.hello.prompt.md" |
| 309 | + |
| 310 | + def fail_prompt(project_root, cmd_name): |
| 311 | + raise OSError("disk full") |
| 312 | + |
| 313 | + with patch.object(CommandRegistrar, "write_copilot_prompt", staticmethod(fail_prompt)): |
| 314 | + failed = _run_in_project(project, ["extension", "enable", "test-ext"]) |
| 315 | + |
| 316 | + assert failed.exit_code == 1, failed.output |
| 317 | + assert _enabled(project, "test-ext") is False |
| 318 | + assert not agent_file.exists() |
| 319 | + |
| 320 | + retried = _run_in_project(project, ["extension", "enable", "test-ext"]) |
| 321 | + |
| 322 | + assert retried.exit_code == 0, retried.output |
| 323 | + assert agent_file.is_file() |
| 324 | + assert prompt_file.is_file() |
| 325 | + |
| 326 | + def test_partially_present_outputs_are_not_rewritten( |
| 327 | + self, tmp_path, extension_dir |
| 328 | + ): |
| 329 | + """With one of two commands' outputs gone, enable must not re-register |
| 330 | + the extension and overwrite the other, customized output.""" |
| 331 | + manifest_path = extension_dir / "extension.yml" |
| 332 | + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) |
| 333 | + manifest["provides"]["commands"].append( |
| 334 | + {"name": "speckit.test-ext.bye", "file": "commands/bye.md", |
| 335 | + "description": "Bye"} |
| 336 | + ) |
| 337 | + manifest_path.write_text(yaml.dump(manifest), encoding="utf-8") |
| 338 | + (extension_dir / "commands" / "bye.md").write_text( |
| 339 | + "---\ndescription: \"Bye\"\n---\n\n# Bye\n\n$ARGUMENTS\n", encoding="utf-8" |
| 340 | + ) |
| 341 | + project = _init_project(tmp_path, "claude") |
| 342 | + assert _run_in_project(project, ["extension", "add", str(extension_dir), "--dev"]).exit_code == 0 |
| 343 | + skills = project / ".claude" / "skills" |
| 344 | + hello = skills / "speckit-test-ext-hello" / "SKILL.md" |
| 345 | + bye = skills / "speckit-test-ext-bye" / "SKILL.md" |
| 346 | + hello.unlink() |
| 347 | + hello.write_text("customized by the user\n", encoding="utf-8") |
| 348 | + shutil.rmtree(bye.parent) |
| 349 | + assert _run_in_project(project, ["extension", "disable", "test-ext"]).exit_code == 0 |
| 350 | + |
| 351 | + result = _run_in_project(project, ["extension", "enable", "test-ext"]) |
| 352 | + |
| 353 | + assert result.exit_code == 0, result.output |
| 354 | + assert hello.read_text(encoding="utf-8") == "customized by the user\n" |
0 commit comments