mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
2240 lines
95 KiB
Python
2240 lines
95 KiB
Python
"""Tests for events module: integration runtime events."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import platform
|
|
import shlex
|
|
from pathlib import Path, PurePath
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from specify_cli.events import (
|
|
CANONICAL_EVENTS,
|
|
EVENTS_DISPATCHER_REL,
|
|
collect_extension_events,
|
|
install_integration_events,
|
|
remove_integration_events,
|
|
resolve_events,
|
|
validate_events,
|
|
resolve_and_run_event_command,
|
|
)
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
from specify_cli.integrations.claude import ClaudeIntegration
|
|
from specify_cli.integrations.cursor_agent import CursorAgentIntegration
|
|
from specify_cli.integrations.opencode import OpencodeIntegration
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
|
|
|
|
# -- resolve_events --------------------------------------------------------
|
|
|
|
class TestResolveEvents:
|
|
"""Test the 4-layer event resolution chain."""
|
|
|
|
def test_layer1_disabled_returns_empty(self, tmp_path):
|
|
"""--events false returns empty dict."""
|
|
result = resolve_events(
|
|
"claude",
|
|
{"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
|
|
tmp_path,
|
|
{"events": "false"},
|
|
)
|
|
assert result == {}
|
|
|
|
def test_layer4_built_in_defaults(self, tmp_path):
|
|
"""Returns baseline defaults when no overrides exist."""
|
|
result = resolve_events(
|
|
"claude",
|
|
{"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
|
|
tmp_path,
|
|
None,
|
|
)
|
|
assert result == {"post_tool_use": [{"command": "speckit.tdd.validate"}]}
|
|
|
|
def test_layer3_extension_events_appended(self, tmp_path):
|
|
"""Extension-declared events are resolved and appended."""
|
|
ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
|
|
ext_dir.mkdir(parents=True)
|
|
ext_yml = ext_dir / "extension.yml"
|
|
ext_yml.write_text(
|
|
"events:\n session_start:\n command: speckit.my-ext.boot\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = resolve_events(
|
|
"claude",
|
|
{"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
|
|
tmp_path,
|
|
None,
|
|
)
|
|
assert "post_tool_use" in result
|
|
assert "session_start" in result
|
|
assert result["session_start"] == [{"command": "speckit.my-ext.boot"}]
|
|
|
|
def test_layer3_multiple_extensions_same_event_accumulate(self, tmp_path):
|
|
"""Two extensions declaring the same event both run (#2)."""
|
|
for ext_id, cmd in (("my-ext", "speckit.my-ext.boot"), ("other-ext", "speckit.other.boot")):
|
|
ext_dir = tmp_path / ".specify" / "extensions" / ext_id
|
|
ext_dir.mkdir(parents=True)
|
|
(ext_dir / "extension.yml").write_text(
|
|
f"events:\n session_start:\n command: {cmd}\n",
|
|
encoding="utf-8",
|
|
)
|
|
result = resolve_events("claude", None, tmp_path, None)
|
|
assert result["session_start"] == [
|
|
{"command": "speckit.my-ext.boot"},
|
|
{"command": "speckit.other.boot"},
|
|
]
|
|
|
|
def test_layer2_yaml_override_replaces(self, tmp_path):
|
|
"""integration-events.yml override replaces baseline entirely."""
|
|
override_file = tmp_path / ".specify" / "integration-events.yml"
|
|
override_file.parent.mkdir(parents=True, exist_ok=True)
|
|
override_file.write_text(
|
|
"integrations:\n"
|
|
" claude:\n"
|
|
" events:\n"
|
|
" stop:\n"
|
|
" command: speckit.override.stop\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = resolve_events(
|
|
"claude",
|
|
{"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
|
|
tmp_path,
|
|
None,
|
|
)
|
|
assert result == {"stop": [{"command": "speckit.override.stop"}]}
|
|
|
|
def test_layer2_empty_events_disables(self, tmp_path):
|
|
"""Empty events override disables events."""
|
|
override_file = tmp_path / ".specify" / "integration-events.yml"
|
|
override_file.parent.mkdir(parents=True, exist_ok=True)
|
|
override_file.write_text(
|
|
"integrations:\n"
|
|
" claude:\n"
|
|
" events: {}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = resolve_events(
|
|
"claude",
|
|
{"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
|
|
tmp_path,
|
|
None,
|
|
)
|
|
assert result == {}
|
|
|
|
def test_no_config_no_events(self, tmp_path):
|
|
"""Safe fallback with empty config/options."""
|
|
result = resolve_events("claude", None, tmp_path, None)
|
|
assert result == {}
|
|
|
|
|
|
# -- collect_extension_events -----------------------------------------------
|
|
|
|
class TestCollectExtensionEvents:
|
|
"""Test scanning extension.yml files for events: declarations."""
|
|
|
|
def test_no_extensions_dir(self, tmp_path):
|
|
assert collect_extension_events(tmp_path) == {}
|
|
|
|
def test_no_events_in_extension(self, tmp_path):
|
|
ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
|
|
ext_dir.mkdir(parents=True)
|
|
(ext_dir / "extension.yml").write_text("extension:\n id: my-ext\n", encoding="utf-8")
|
|
assert collect_extension_events(tmp_path) == {}
|
|
|
|
def test_events_collected(self, tmp_path):
|
|
ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
|
|
ext_dir.mkdir(parents=True)
|
|
(ext_dir / "extension.yml").write_text(
|
|
"events:\n pre_tool_use:\n command: speckit.my-ext.check\n",
|
|
encoding="utf-8",
|
|
)
|
|
result = collect_extension_events(tmp_path)
|
|
assert result == {"pre_tool_use": [{"command": "speckit.my-ext.check"}]}
|
|
|
|
def test_invalid_yaml_skipped(self, tmp_path):
|
|
ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
|
|
ext_dir.mkdir(parents=True)
|
|
(ext_dir / "extension.yml").write_text("invalid: - - -", encoding="utf-8")
|
|
assert collect_extension_events(tmp_path) == {}
|
|
|
|
def test_event_command_ref_canonicalized_via_manifest(self, tmp_path):
|
|
"""R1: events are read from a validated ExtensionManifest, so an
|
|
obsolete command ref (e.g. my-ext.boot) is canonicalized
|
|
(speckit.my-ext.boot) the same way hook refs are at install."""
|
|
from specify_cli.extensions import ExtensionRegistry
|
|
import yaml as _yaml
|
|
|
|
ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
|
|
ext_dir.mkdir(parents=True)
|
|
# Manifest declares an alias-form event command ref (my-ext.boot)
|
|
# alongside the command it resolves to; the validated manifest lifts
|
|
# the ref to speckit.my-ext.boot (C11).
|
|
(ext_dir / "extension.yml").write_text(
|
|
_yaml.dump({
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": "my-ext",
|
|
"name": "My Ext",
|
|
"version": "1.0.0",
|
|
"description": "test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1"},
|
|
"provides": {
|
|
"commands": [
|
|
{"name": "speckit.my-ext.boot", "file": "commands/boot.md"}
|
|
]
|
|
},
|
|
"events": {"session_start": {"command": "my-ext.boot"}},
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
ExtensionRegistry(tmp_path / ".specify" / "extensions").add(
|
|
"my-ext", {"enabled": True}
|
|
)
|
|
|
|
result = collect_extension_events(tmp_path)
|
|
# The ref was canonicalized to speckit.my-ext.boot by the validated
|
|
# manifest, so dispatch can match it (raw-YAML reading would have
|
|
# emitted the obsolete my-ext.boot and the hook would no-op).
|
|
assert result == {"session_start": [{"command": "speckit.my-ext.boot"}]}
|
|
|
|
|
|
# -- Class-driven mappings --------------------------------------------------
|
|
|
|
class TestCanonicalEventMapping:
|
|
"""Verify registry-driven mapping is correct on integration classes."""
|
|
|
|
def test_claude_identity(self):
|
|
integration = ClaudeIntegration()
|
|
assert integration.supports_events() is True
|
|
assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "PreToolUse"
|
|
assert integration.CANONICAL_TO_NATIVE["session_start"] == "SessionStart"
|
|
|
|
def test_cursor_camelcase(self):
|
|
integration = CursorAgentIntegration()
|
|
assert integration.supports_events() is True
|
|
assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "preToolUse"
|
|
assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "beforeSubmitPrompt"
|
|
|
|
def test_opencode_limited(self):
|
|
integration = OpencodeIntegration()
|
|
assert integration.supports_events() is True
|
|
assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "tool.execute.before"
|
|
assert "stop" not in integration.CANONICAL_TO_NATIVE
|
|
|
|
def test_copilot_mapping(self):
|
|
integration = CopilotIntegration()
|
|
assert integration.supports_events() is True
|
|
assert integration.CANONICAL_TO_NATIVE["session_start"] == "sessionStart"
|
|
assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "userPromptSubmitted"
|
|
|
|
def test_gemini_mapping_includes_before_agent(self):
|
|
# S6: Gemini exposes BeforeAgent for user_prompt_submit and AfterAgent
|
|
# for stop (verified against Gemini CLI's hooks docs).
|
|
from specify_cli.integrations.gemini import GeminiIntegration
|
|
integration = GeminiIntegration()
|
|
assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "BeforeTool"
|
|
assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "BeforeAgent"
|
|
assert integration.CANONICAL_TO_NATIVE["stop"] == "AfterAgent"
|
|
|
|
def test_tabnine_mapping_includes_before_agent(self):
|
|
# S7: Tabnine's Gemini-compatible schema provides BeforeAgent/AfterAgent.
|
|
from specify_cli.integrations.tabnine import TabnineIntegration
|
|
integration = TabnineIntegration()
|
|
assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "BeforeAgent"
|
|
assert integration.CANONICAL_TO_NATIVE["stop"] == "AfterAgent"
|
|
|
|
|
|
# -- Event-capable adapters declare --events (#8, #9) ------------------------
|
|
|
|
class TestEventCapableOptionsComposition:
|
|
"""Event-capable integrations must declare --events so the documented
|
|
--events false opt-out is accepted."""
|
|
|
|
def _has_option(self, opts, name):
|
|
return any(o.name == name for o in opts)
|
|
|
|
def test_copilot_declares_events(self):
|
|
# #9: CopilotIntegration.options() composed with super() so --events
|
|
# is declared alongside --skills.
|
|
opts = CopilotIntegration().options()
|
|
assert self._has_option(opts, "--skills")
|
|
assert self._has_option(opts, "--events"), (
|
|
"Copilot is event-capable but --events is not declared; "
|
|
"--integration-options \"--events false\" would be rejected."
|
|
)
|
|
|
|
def test_devin_declares_events(self):
|
|
# #8: DevinIntegration.options() composed with super() so --events
|
|
# is declared alongside --skills.
|
|
from specify_cli.integrations.devin import DevinIntegration
|
|
opts = DevinIntegration().options()
|
|
assert self._has_option(opts, "--skills")
|
|
assert self._has_option(opts, "--events"), (
|
|
"Devin is event-capable but --events is not declared; "
|
|
"--integration-options \"--events false\" would be rejected."
|
|
)
|
|
|
|
def test_cursor_declares_events(self):
|
|
# Cursor already composed correctly; assert it stays that way.
|
|
opts = CursorAgentIntegration().options()
|
|
assert self._has_option(opts, "--skills")
|
|
assert self._has_option(opts, "--events")
|
|
|
|
def test_codex_declares_events(self):
|
|
# Codex already composed correctly; assert it stays that way.
|
|
from specify_cli.integrations.codex import CodexIntegration
|
|
opts = CodexIntegration().options()
|
|
assert self._has_option(opts, "--skills")
|
|
assert self._has_option(opts, "--events")
|
|
|
|
|
|
# -- validate_events --------------------------------------------------------
|
|
|
|
class TestValidateEvents:
|
|
"""Test manifest validation."""
|
|
|
|
def test_unknown_event_rejected(self):
|
|
from specify_cli.extensions import ValidationError
|
|
data = {"events": {"unknown_event": {"command": "speckit.tdd.validate"}}}
|
|
with pytest.raises(ValidationError) as exc:
|
|
validate_events(data)
|
|
assert "Unknown event" in str(exc.value)
|
|
|
|
def test_known_event_accepted(self):
|
|
data = {"events": {"pre_tool_use": {"command": "speckit.tdd.validate"}}}
|
|
validate_events(data) # no raise
|
|
|
|
def test_all_canonical_events_accepted(self):
|
|
data = {
|
|
"events": {
|
|
name: {"command": "speckit.test"}
|
|
for name in CANONICAL_EVENTS
|
|
}
|
|
}
|
|
validate_events(data) # no raise
|
|
|
|
|
|
# -- Claude settings JSON merging -------------------------------------------
|
|
|
|
class TestClaudeJsonMerging:
|
|
"""Test Claude settings JSON merging and cleanup."""
|
|
|
|
def test_merge_into_empty_file(self, tmp_path):
|
|
integration = ClaudeIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
|
|
events = {
|
|
"pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit|Write"}],
|
|
}
|
|
install_integration_events(integration, tmp_path, manifest, events)
|
|
|
|
config_path = tmp_path / ".claude/settings.json"
|
|
assert config_path.is_file()
|
|
data = json.loads(config_path.read_text())
|
|
assert "hooks" in data
|
|
assert "PreToolUse" in data["hooks"]
|
|
assert data["hooks"]["PreToolUse"][0]["matcher"] == "Edit|Write"
|
|
# #6: native schema is a single `command` string, not command+args.
|
|
inner = data["hooks"]["PreToolUse"][0]["hooks"][0]
|
|
assert isinstance(inner["command"], str)
|
|
assert "args" not in inner
|
|
assert "speckit.tdd.validate" in inner["command"]
|
|
assert "pre_tool_use" in inner["command"]
|
|
# The dispatcher path must be prefixed with ${CLAUDE_PROJECT_DIR}/ for
|
|
# Claude, and double-quoted so a project path with spaces doesn't
|
|
# word-split (C2) while the variable still expands.
|
|
assert "${CLAUDE_PROJECT_DIR}/" in inner["command"]
|
|
assert '"${CLAUDE_PROJECT_DIR}/.specify/events.py"' in inner["command"]
|
|
|
|
def test_claude_emits_all_handlers_for_same_event(self, tmp_path):
|
|
"""#2: two handlers on the same event both appear in the native config."""
|
|
integration = ClaudeIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
|
|
events = {
|
|
"pre_tool_use": [
|
|
{"command": "speckit.tdd.validate"},
|
|
{"command": "speckit.other.check"},
|
|
],
|
|
}
|
|
install_integration_events(integration, tmp_path, manifest, events)
|
|
|
|
data = json.loads((tmp_path / ".claude/settings.json").read_text())
|
|
inner_hooks = data["hooks"]["PreToolUse"][0]["hooks"]
|
|
commands = [h["command"] for h in inner_hooks]
|
|
assert any("speckit.tdd.validate" in c for c in commands)
|
|
assert any("speckit.other.check" in c for c in commands)
|
|
|
|
def test_remove_preserves_user_hooks(self, tmp_path):
|
|
integration = ClaudeIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
|
|
# Pre-seed user setting
|
|
config_path = tmp_path / ".claude/settings.json"
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
config_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{
|
|
"matcher": "Bash",
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "user-check",
|
|
}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
}
|
|
)
|
|
)
|
|
|
|
events = {
|
|
"pre_tool_use": [{"command": "speckit.tdd.validate"}],
|
|
}
|
|
install_integration_events(integration, tmp_path, manifest, events)
|
|
remove_integration_events(integration, tmp_path, manifest)
|
|
|
|
data = json.loads(config_path.read_text())
|
|
assert "hooks" in data
|
|
assert "PreToolUse" in data["hooks"]
|
|
assert len(data["hooks"]["PreToolUse"]) == 1
|
|
assert data["hooks"]["PreToolUse"][0]["matcher"] == "Bash"
|
|
|
|
|
|
# -- Copilot events JSON writing --------------------------------------------
|
|
|
|
class TestCopilotJsonWriting:
|
|
"""Test Copilot dedicated .github/hooks/speckit.json generation."""
|
|
|
|
def test_copilot_json_generation(self, tmp_path):
|
|
integration = CopilotIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
|
|
events = {
|
|
"session_start": [{"command": "speckit.agent-context.update", "timeout": 60}],
|
|
}
|
|
install_integration_events(integration, tmp_path, manifest, events)
|
|
|
|
config_path = tmp_path / ".github/hooks/speckit.json"
|
|
assert config_path.is_file()
|
|
data = json.loads(config_path.read_text())
|
|
assert data["version"] == 1
|
|
assert "hooks" in data
|
|
assert "sessionStart" in data["hooks"]
|
|
entry = data["hooks"]["sessionStart"][0]
|
|
assert entry["type"] == "command"
|
|
# #6: a complete shell command string (not command+args).
|
|
assert "speckit.agent-context.update" in entry["bash"]
|
|
assert "session_start" in entry["bash"]
|
|
# S4: bash and powershell get independent OS-targeted interpreters so
|
|
# a config generated on one OS works on the other. R2: command/event
|
|
# args are shell-quoted for each target shell.
|
|
assert "speckit.agent-context.update" in entry["powershell"]
|
|
assert entry["bash"] != entry["powershell"]
|
|
# bash uses POSIX interpreter python3 (shlex.quote leaves safe tokens
|
|
# bare); powershell uses python single-quoted with the & call operator
|
|
# so the quoted command is actually invoked (C1).
|
|
assert entry["bash"].startswith("python3 ")
|
|
assert entry["powershell"].startswith("& 'python' ")
|
|
# PowerShell always single-quotes; POSIX leaves metacharacter-free
|
|
# identifiers bare (shlex.quote only quotes when needed).
|
|
assert "'speckit.agent-context.update'" in entry["powershell"]
|
|
assert "speckit.agent-context.update" in entry["bash"]
|
|
# R2: native timeout gets the buffer (60 + 5 = 65) so the agent's
|
|
# outer cap fires after the dispatcher's inner subprocess timeout.
|
|
assert entry["timeoutSec"] == 65
|
|
|
|
|
|
# -- Cursor hooks.json version + matcher grouping (#7, S3) -------------------
|
|
|
|
class TestCursorJsonWriting:
|
|
"""#7: .cursor/hooks.json requires top-level version:1; S3: matcher grouping."""
|
|
|
|
def test_cursor_json_includes_version(self, tmp_path):
|
|
integration = CursorAgentIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"session_start": [{"command": "speckit.boot"}]},
|
|
)
|
|
data = json.loads((tmp_path / ".cursor/hooks.json").read_text())
|
|
assert data["version"] == 1
|
|
assert "sessionStart" in data["hooks"]
|
|
|
|
def test_cursor_json_preserves_user_version(self, tmp_path):
|
|
integration = CursorAgentIntegration()
|
|
config_path = tmp_path / ".cursor/hooks.json"
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
config_path.write_text(json.dumps({"version": 1, "hooks": {}}))
|
|
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"session_start": [{"command": "speckit.boot"}]},
|
|
)
|
|
data = json.loads(config_path.read_text())
|
|
assert data["version"] == 1
|
|
|
|
def test_nested_matcher_grouping_per_distinct_matcher(self, tmp_path):
|
|
"""S3: two handlers with different matchers produce two matcher-groups."""
|
|
integration = ClaudeIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
|
|
events = {
|
|
"pre_tool_use": [
|
|
{"command": "speckit.first", "matcher": "Edit"},
|
|
{"command": "speckit.second", "matcher": "Bash"},
|
|
],
|
|
}
|
|
install_integration_events(integration, tmp_path, manifest, events)
|
|
data = json.loads((tmp_path / ".claude/settings.json").read_text())
|
|
groups = data["hooks"]["PreToolUse"]
|
|
matchers = sorted(g["matcher"] for g in groups)
|
|
assert matchers == ["Bash", "Edit"]
|
|
# Each group holds exactly its own handler.
|
|
by_matcher = {g["matcher"]: g["hooks"] for g in groups}
|
|
assert len(by_matcher["Edit"]) == 1
|
|
assert "speckit.first" in by_matcher["Edit"][0]["command"]
|
|
assert len(by_matcher["Bash"]) == 1
|
|
assert "speckit.second" in by_matcher["Bash"][0]["command"]
|
|
|
|
def test_nested_shared_matcher_stays_one_group(self, tmp_path):
|
|
"""S3: handlers sharing a matcher stay in a single matcher-group."""
|
|
integration = ClaudeIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
|
|
events = {
|
|
"pre_tool_use": [
|
|
{"command": "speckit.first", "matcher": "Edit"},
|
|
{"command": "speckit.second", "matcher": "Edit"},
|
|
],
|
|
}
|
|
install_integration_events(integration, tmp_path, manifest, events)
|
|
data = json.loads((tmp_path / ".claude/settings.json").read_text())
|
|
groups = data["hooks"]["PreToolUse"]
|
|
assert len(groups) == 1
|
|
assert groups[0]["matcher"] == "Edit"
|
|
assert len(groups[0]["hooks"]) == 2
|
|
|
|
|
|
# -- Gemini timeout unit (#7) ------------------------------------------------
|
|
|
|
class TestGeminiTimeoutUnit:
|
|
"""Gemini measures hook timeouts in milliseconds, not seconds."""
|
|
|
|
def test_gemini_timeout_converted_to_ms(self, tmp_path):
|
|
from specify_cli.integrations.gemini import GeminiIntegration
|
|
from specify_cli.events import _native_timeout
|
|
|
|
integration = GeminiIntegration()
|
|
# 60 (seconds) -> 60000 (ms) for Gemini; unchanged for seconds-based agents.
|
|
assert _native_timeout(integration, 60) == 60000
|
|
assert _native_timeout(ClaudeIntegration(), 60) == 60
|
|
|
|
def test_tabnine_timeout_converted_to_ms(self):
|
|
"""R5: Tabnine mirrors Gemini's ms-based hook schema."""
|
|
from specify_cli.integrations.tabnine import TabnineIntegration
|
|
from specify_cli.events import _native_timeout
|
|
|
|
assert _native_timeout(TabnineIntegration(), 60) == 60000
|
|
|
|
def test_qwen_timeout_converted_to_ms(self):
|
|
"""U1: Qwen Code command hooks use milliseconds (default 60000)."""
|
|
from specify_cli.integrations.qwen import QwenIntegration
|
|
from specify_cli.events import _native_timeout
|
|
|
|
assert _native_timeout(QwenIntegration(), 60) == 60000
|
|
|
|
|
|
# -- Devin root-nested format (U2) + Copilot agentStop (U3) ------------------
|
|
|
|
class TestDevinRootNestedFormat:
|
|
"""U2: Devin's hooks.v1.json is a root event map with no 'hooks' wrapper."""
|
|
|
|
def test_devin_events_written_at_root(self, tmp_path):
|
|
from specify_cli.integrations.devin import DevinIntegration
|
|
integration = DevinIntegration()
|
|
assert integration.events_format == "json-root-nested"
|
|
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
manifest.remove = MagicMock()
|
|
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
|
|
)
|
|
data = json.loads((tmp_path / ".devin/hooks.v1.json").read_text())
|
|
# Event keys are top-level (no "hooks" wrapper).
|
|
assert "PreToolUse" in data
|
|
assert "hooks" not in data
|
|
|
|
def test_devin_teardown_removes_owned_and_preserves_user(self, tmp_path):
|
|
from specify_cli.integrations.devin import DevinIntegration
|
|
integration = DevinIntegration()
|
|
config_path = tmp_path / ".devin/hooks.v1.json"
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
config_path.write_text(json.dumps({
|
|
"PreToolUse": [{
|
|
"matcher": "exec",
|
|
"hooks": [{"type": "command", "command": "user-check"}],
|
|
}]
|
|
}))
|
|
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
manifest.remove = MagicMock()
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"stop": [{"command": "speckit.end"}]},
|
|
)
|
|
remove_integration_events(integration, tmp_path, manifest)
|
|
|
|
data = json.loads(config_path.read_text())
|
|
# User hook preserved at the root; Specify's Stop gone.
|
|
assert "Stop" not in data
|
|
assert data["PreToolUse"][0]["matcher"] == "exec"
|
|
|
|
|
|
class TestCopilotAgentStop:
|
|
"""U3: Copilot maps the canonical stop lifecycle to native agentStop."""
|
|
|
|
def test_copilot_stop_mapping(self):
|
|
integration = CopilotIntegration()
|
|
assert integration.CANONICAL_TO_NATIVE.get("stop") == "agentStop"
|
|
|
|
|
|
# -- Shell quoting & matcher escaping (R2, R4) -------------------------------
|
|
|
|
class TestDispatcherCommandQuoting:
|
|
"""R2: dispatcher command components are shell-quoted so spaces and shell
|
|
metacharacters are passed as single arguments, not reinterpreted."""
|
|
|
|
def test_command_metacharacters_are_quoted_posix(self, tmp_path):
|
|
from specify_cli.events import _dispatcher_command
|
|
|
|
cmd = _dispatcher_command(
|
|
ClaudeIntegration(), tmp_path, "speckit.x; rm -rf /", "pre_tool_use",
|
|
target_os="posix",
|
|
)
|
|
# The metacharacter-bearing command is single-quoted as one argument.
|
|
assert "'speckit.x; rm -rf /'" in cmd
|
|
|
|
def test_interpreter_with_space_is_quoted_posix(self, tmp_path):
|
|
import shlex
|
|
from specify_cli.events import _dispatcher_command
|
|
# Simulate a venv interpreter under a path with spaces.
|
|
venv = tmp_path / ".venv" / "bin" / "python"
|
|
venv.parent.mkdir(parents=True)
|
|
venv.write_text("#!/bin/sh\n")
|
|
proj = tmp_path
|
|
cmd = _dispatcher_command(
|
|
ClaudeIntegration(), proj, "speckit.x.y", "stop", target_os="host",
|
|
)
|
|
# The command must tokenize back into interpreter + dispatcher + 2 args.
|
|
tokens = shlex.split(cmd)
|
|
# dispatcher token carries the ${CLAUDE_PROJECT_DIR} prefix (double-
|
|
# quoted in the raw string, but shlex.split strips the quotes).
|
|
assert any("events.py" in t for t in tokens)
|
|
assert "speckit.x.y" in tokens
|
|
assert "stop" in tokens
|
|
|
|
def test_windows_target_uses_powershell_quoting(self, tmp_path):
|
|
from specify_cli.events import _dispatcher_command
|
|
|
|
cmd = _dispatcher_command(
|
|
CopilotIntegration(), tmp_path, "speckit.x.y", "session_start",
|
|
target_os="windows",
|
|
)
|
|
# PowerShell single-quoted literals, and the & call operator so the
|
|
# quoted interpreter is actually invoked (C1).
|
|
assert cmd.startswith("& ")
|
|
assert "'speckit.x.y'" in cmd
|
|
assert "'session_start'" in cmd
|
|
|
|
def test_host_target_never_emits_powershell_quotes(self, tmp_path):
|
|
"""C1: the host target uses POSIX quoting on every platform so a
|
|
single-command-string hook (Claude/Gemini/etc.) stays invocable —
|
|
never 'python' (which PowerShell wouldn't invoke without &)."""
|
|
from specify_cli.events import _shell_quote
|
|
# Safe tokens pass through bare under host (POSIX), not PS-quoted.
|
|
assert _shell_quote("python3", "host") == "python3"
|
|
assert _shell_quote("speckit.x.y", "host") == "speckit.x.y"
|
|
|
|
def test_claude_dispatcher_double_quoted_for_spaces(self, tmp_path):
|
|
"""C2: Claude's ${CLAUDE_PROJECT_DIR} dispatcher path is double-quoted
|
|
so a project path containing spaces doesn't word-split."""
|
|
from specify_cli.events import _dispatcher_command
|
|
cmd = _dispatcher_command(
|
|
ClaudeIntegration(), tmp_path, "speckit.x.y", "stop", target_os="host",
|
|
)
|
|
assert '"${CLAUDE_PROJECT_DIR}/.specify/events.py"' in cmd
|
|
|
|
|
|
class TestTomlMatcherEscaping:
|
|
"""R4: the TOML matcher is escaped like command, not raw-interpolated."""
|
|
|
|
def test_matcher_with_quote_stays_valid_toml(self, tmp_path):
|
|
from specify_cli.integrations.codex import CodexIntegration
|
|
|
|
integration = CodexIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
manifest.remove = MagicMock()
|
|
|
|
# A matcher containing a double quote would break a raw TOML basic
|
|
# string; it must be escaped.
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"pre_tool_use": [{"command": "speckit.x.y", "matcher": 'Ba"sh'}]},
|
|
)
|
|
content = (tmp_path / ".codex" / "config.toml").read_text()
|
|
# Round-trips through a TOML parser without error.
|
|
try:
|
|
import tomllib
|
|
parsed = tomllib.loads(content)
|
|
except ModuleNotFoundError:
|
|
import tomli as tomllib # type: ignore
|
|
parsed = tomllib.loads(content)
|
|
# The matcher value survived intact.
|
|
group = parsed["hooks"]["PreToolUse"][0]
|
|
assert group["matcher"] == 'Ba"sh'
|
|
|
|
|
|
# -- Opencode TS Plugin merging ---------------------------------------------
|
|
|
|
class TestOpencodePluginMerging:
|
|
"""Test Opencode typescript plugin generation."""
|
|
|
|
def test_opencode_ts_plugin_generation(self, tmp_path):
|
|
integration = OpencodeIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
|
|
events = {
|
|
"pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit"}],
|
|
"session_start": [{"command": "speckit.agent-context.update"}],
|
|
}
|
|
install_integration_events(integration, tmp_path, manifest, events)
|
|
|
|
plugin_path = tmp_path / ".opencode/plugin/speckit-events.ts"
|
|
assert plugin_path.is_file()
|
|
content = plugin_path.read_text()
|
|
assert "runEvent" in content
|
|
assert "tool.execute.before" in content
|
|
assert "session.created" in content
|
|
assert "speckit.tdd.validate" in content
|
|
assert "speckit.agent-context.update" in content
|
|
# #13: failures must propagate via throw, not process.exit(2) which
|
|
# would kill the OpenCode host process.
|
|
assert "process.exit(2)" not in content
|
|
assert "throw new Error" in content
|
|
|
|
def test_opencode_ts_plugin_resolves_interpreter_and_directory_at_load(self, tmp_path):
|
|
"""C8/C9: the dispatcher + interpreter are resolved per-project at
|
|
plugin load from the `directory` OpenCode passes (not process.cwd()),
|
|
preferring a project venv, and the dispatcher is launched via
|
|
execFileSync (argv, no shell)."""
|
|
integration = OpencodeIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
|
|
# Create a project venv so the plugin's runtime resolver prefers it.
|
|
venv_bin = tmp_path / ".venv" / "bin" / "python"
|
|
venv_bin.parent.mkdir(parents=True)
|
|
venv_bin.write_text("#!/bin/sh\n")
|
|
|
|
events = {"session_start": [{"command": "speckit.boot"}]}
|
|
install_integration_events(integration, tmp_path, manifest, events)
|
|
content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text()
|
|
# Runtime venv-interpreter preference is baked into the resolver.
|
|
assert ".venv" in content and "python" in content
|
|
# Dispatcher is resolved from `directory`, not process.cwd() (C8).
|
|
assert "path.join(process.cwd()" not in content
|
|
assert "directory" in content
|
|
# execFileSync (argv, no shell) instead of a shell command string (C9).
|
|
assert "execFileSync" in content
|
|
assert "execSync(`" not in content
|
|
# R2: venv interpreter is probed for specify_cli importability before
|
|
# selection (an unrelated project venv shouldn't shadow the fallback).
|
|
assert "canImportSpecifyCli" in content
|
|
# S2: the PATH fallback is python on Windows (python3 is commonly
|
|
# absent there), python3 on POSIX.
|
|
assert "process.platform === 'win32'" in content
|
|
assert "'python'" in content
|
|
assert "'python3'" in content
|
|
|
|
def test_opencode_ts_plugin_emits_all_handlers(self, tmp_path):
|
|
"""#2: multiple handlers on the same native event all invoke runEvent."""
|
|
integration = OpencodeIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
|
|
events = {
|
|
"session_start": [
|
|
{"command": "speckit.first.boot"},
|
|
{"command": "speckit.second.boot"},
|
|
],
|
|
}
|
|
install_integration_events(integration, tmp_path, manifest, events)
|
|
content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text()
|
|
assert "speckit.first.boot" in content
|
|
assert "speckit.second.boot" in content
|
|
# Suppressed #6: each handler call is wrapped in try/catch and errors aggregated.
|
|
assert "try {" in content
|
|
assert "errors.push(" in content
|
|
assert "throw new Error(errors.join" in content
|
|
|
|
def test_opencode_ts_plugin_forwards_output(self, tmp_path):
|
|
"""C7: tool callbacks forward both input and output to runEvent so
|
|
pre_tool_use can inspect tool args and post_tool_use the result."""
|
|
integration = OpencodeIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
|
|
events = {
|
|
"pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit"}],
|
|
"post_tool_use": [{"command": "speckit.tdd.after"}],
|
|
}
|
|
install_integration_events(integration, tmp_path, manifest, events)
|
|
content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text()
|
|
# runEvent signature carries both input and output. S1: command/event
|
|
# are JSON string literals (double-quoted, escaped). S3: the per-
|
|
# handler timeout (seconds) is threaded as the 5th arg.
|
|
assert 'runEvent("speckit.tdd.validate", "pre_tool_use", input, output, 60)' in content
|
|
assert 'runEvent("speckit.tdd.after", "post_tool_use", input, output, 60)' in content
|
|
# Tool callbacks pass both arguments through.
|
|
assert "_pre_tool_use(input, output)" in content
|
|
assert "_post_tool_use(input, output)" in content
|
|
|
|
def test_opencode_ts_plugin_escapes_metacharacters(self, tmp_path):
|
|
"""S1: command/matcher values with quotes/backticks are serialized as
|
|
JSON string literals so they can't break the generated TypeScript or
|
|
inject code."""
|
|
integration = OpencodeIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
|
|
# A command and matcher containing characters that would break a
|
|
# single-quoted TS literal.
|
|
events = {
|
|
"pre_tool_use": [{"command": "speckit.x'y`code", "matcher": "Ed'it"}],
|
|
}
|
|
install_integration_events(integration, tmp_path, manifest, events)
|
|
content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text()
|
|
# The value must appear inside a JSON double-quoted literal, not a
|
|
# single-quoted TS literal (which a quote/backtick would break).
|
|
assert json.dumps("speckit.x'y`code") in content
|
|
# The dangerous single-quoted form (runEvent('speckit.x'y...')) —
|
|
# where the embedded quote would terminate the literal — is absent.
|
|
assert "runEvent('speckit.x" not in content
|
|
assert json.dumps("ed'it") in content
|
|
|
|
|
|
# -- Command runner test (core execution) -----------------------------------
|
|
|
|
class TestCommandRunner:
|
|
"""Test the core command/script resolution and runner."""
|
|
|
|
def test_run_command_not_found(self, tmp_path):
|
|
code = resolve_and_run_event_command("nonexistent.command", "session_start", "{}", tmp_path)
|
|
assert code == 0 # no-ops gracefully
|
|
|
|
def test_extension_command_resolves_when_file_stem_differs(self, tmp_path):
|
|
"""S8: an extension command whose declared file differs from its
|
|
command name resolves via the manifest, not a file-stem scan."""
|
|
from specify_cli.events import _find_command_template
|
|
from specify_cli.extensions import ExtensionRegistry
|
|
|
|
ext_id = "selftest"
|
|
ext_dir = tmp_path / ".specify" / "extensions" / ext_id
|
|
cmds_dir = ext_dir / "commands"
|
|
cmds_dir.mkdir(parents=True)
|
|
# Command name is speckit.selftest.extension but the file is selftest.md.
|
|
(ext_dir / "extension.yml").write_text(
|
|
"schema_version: '1.0'\n"
|
|
"extension:\n"
|
|
" id: selftest\n"
|
|
" name: Selftest\n"
|
|
" version: 1.0.0\n"
|
|
" description: test\n"
|
|
"requires:\n"
|
|
" speckit_version: '>=0.1'\n"
|
|
"provides:\n"
|
|
" commands:\n"
|
|
" - name: speckit.selftest.extension\n"
|
|
" file: commands/selftest.md\n",
|
|
encoding="utf-8",
|
|
)
|
|
(cmds_dir / "selftest.md").write_text(
|
|
"---\ndescription: \"x\"\n---\nBody\n", encoding="utf-8"
|
|
)
|
|
ExtensionRegistry(tmp_path / ".specify" / "extensions").add(
|
|
ext_id, {"enabled": True}
|
|
)
|
|
|
|
template, resolved_ext = _find_command_template(
|
|
"speckit.selftest.extension", tmp_path
|
|
)
|
|
assert template is not None
|
|
assert template.name == "selftest.md"
|
|
assert resolved_ext == ext_id
|
|
|
|
def test_disabled_extension_command_not_resolved(self, tmp_path):
|
|
"""S1: a disabled extension's command is skipped by
|
|
_find_command_template (both the manifest loop and the disk-fallback
|
|
scan), so a stale hook can't execute a disabled extension."""
|
|
from specify_cli.events import _find_command_template
|
|
from specify_cli.extensions import ExtensionRegistry
|
|
import yaml as _yaml
|
|
|
|
# Manifest-resolvable path (step 1).
|
|
ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
|
|
cmds_dir = ext_dir / "commands"
|
|
cmds_dir.mkdir(parents=True)
|
|
(ext_dir / "extension.yml").write_text(
|
|
_yaml.dump({
|
|
"schema_version": "1.0",
|
|
"extension": {"id": "my-ext", "name": "My Ext", "version": "1.0.0",
|
|
"description": "test"},
|
|
"requires": {"speckit_version": ">=0.1"},
|
|
"provides": {"commands": [{"name": "speckit.my-ext.boot",
|
|
"file": "commands/boot.md"}]},
|
|
"events": {"session_start": {"command": "speckit.my-ext.boot"}},
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
(cmds_dir / "boot.md").write_text("---\ndescription: \"x\"\n---\nBody\n", encoding="utf-8")
|
|
ExtensionRegistry(tmp_path / ".specify" / "extensions").add(
|
|
"my-ext", {"enabled": False}
|
|
)
|
|
template, _ = _find_command_template("speckit.my-ext.boot", tmp_path)
|
|
assert template is None, "Disabled extension's command was resolved (manifest loop)."
|
|
|
|
# Disk-fallback path (step 2): stem == command name.
|
|
(cmds_dir / "speckit.my-ext.boot.md").write_text("---\ndescription: \"x\"\n---\nBody\n", encoding="utf-8")
|
|
template, _ = _find_command_template("speckit.my-ext.boot", tmp_path)
|
|
assert template is None, "Disabled extension's command was resolved (disk fallback)."
|
|
|
|
def test_run_command_resolves_and_executes(self, tmp_path):
|
|
# Create a mock core command md file
|
|
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
|
|
cmd_dir.mkdir(parents=True)
|
|
cmd_file = cmd_dir / "test.md"
|
|
cmd_file.write_text(
|
|
"---\n"
|
|
"description: \"Test\"\n"
|
|
"scripts:\n"
|
|
" sh: scripts/test.sh\n"
|
|
"---\n"
|
|
"Body\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
script_dir = tmp_path / ".specify" / "scripts"
|
|
script_dir.mkdir(parents=True)
|
|
script_file = script_dir / "test.sh"
|
|
script_file.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
|
script_file.chmod(0o755)
|
|
|
|
# Skip on Windows because sh is POSIX
|
|
if platform.system().lower().startswith("win"):
|
|
return
|
|
|
|
code = resolve_and_run_event_command("speckit.test", "session_start", "{}", tmp_path)
|
|
assert code == 0
|
|
|
|
def test_py_variant_anchored_under_specify(self, tmp_path):
|
|
"""S2: the py variant resolves scripts/... under .specify/, not the
|
|
project root, and prepends the resolved interpreter."""
|
|
from specify_cli.events import _resolve_event_command_argv
|
|
|
|
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
|
|
cmd_dir.mkdir(parents=True)
|
|
(cmd_dir / "boot.md").write_text(
|
|
"---\n"
|
|
"description: \"Boot\"\n"
|
|
"scripts:\n"
|
|
" py: scripts/python/boot.py\n"
|
|
"---\nBody\n",
|
|
encoding="utf-8",
|
|
)
|
|
py_dir = tmp_path / ".specify" / "scripts" / "python"
|
|
py_dir.mkdir(parents=True)
|
|
(py_dir / "boot.py").write_text("import sys; sys.exit(0)\n", encoding="utf-8")
|
|
|
|
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
|
|
assert argv is not None
|
|
# Interpreter first, then the .specify-anchored script path. Compare in
|
|
# POSIX form so the assertion holds on Windows (backslash paths) too.
|
|
assert len(argv) >= 2
|
|
assert PurePath(argv[1]).as_posix().endswith(".specify/scripts/python/boot.py")
|
|
assert ".specify" in argv[1]
|
|
|
|
def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path):
|
|
"""S6: the ps variant prefixes argv with pwsh/powershell -File so
|
|
subprocess.run(shell=False) can execute the .ps1 script."""
|
|
from specify_cli.events import _resolve_event_command_argv
|
|
|
|
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
|
|
cmd_dir.mkdir(parents=True)
|
|
(cmd_dir / "boot.md").write_text(
|
|
"---\n"
|
|
"description: \"Boot\"\n"
|
|
"scripts:\n"
|
|
" ps: scripts/powershell/boot.ps1\n"
|
|
"---\nBody\n",
|
|
encoding="utf-8",
|
|
)
|
|
ps_dir = tmp_path / ".specify" / "scripts" / "powershell"
|
|
ps_dir.mkdir(parents=True)
|
|
(ps_dir / "boot.ps1").write_text("exit 0\n", encoding="utf-8")
|
|
|
|
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
|
|
assert argv is not None
|
|
# Launcher (pwsh or powershell), -File, then the .specify-anchored
|
|
# script. shutil.which may return a full path with an .EXE suffix on
|
|
# Windows, so match by stem (case-insensitive).
|
|
assert PurePath(argv[0]).stem.lower() in ("pwsh", "powershell")
|
|
assert argv[1] == "-File"
|
|
assert PurePath(argv[2]).as_posix().endswith(".specify/scripts/powershell/boot.ps1")
|
|
|
|
def test_run_command_executes_with_project_root_cwd(self, tmp_path):
|
|
"""R1: the event command runs with cwd set to the project root, not the
|
|
caller's arbitrary working directory, so project-relative script logic
|
|
resolves correctly even when the agent fires the hook elsewhere."""
|
|
if platform.system().lower().startswith("win"):
|
|
return
|
|
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
|
|
cmd_dir.mkdir(parents=True)
|
|
(cmd_dir / "cwd.md").write_text(
|
|
"---\ndescription: \"cwd\"\nscripts:\n sh: scripts/cwd.sh\n---\nBody\n",
|
|
encoding="utf-8",
|
|
)
|
|
script_dir = tmp_path / ".specify" / "scripts"
|
|
script_dir.mkdir(parents=True)
|
|
out_file = tmp_path / "cwd.out"
|
|
script = script_dir / "cwd.sh"
|
|
# The script records its working directory.
|
|
script.write_text(f"#!/bin/sh\npwd > {shlex.quote(str(out_file))}\nexit 0\n", encoding="utf-8")
|
|
script.chmod(0o755)
|
|
|
|
# Invoke from a different working directory to prove cwd is forced.
|
|
import os as _os
|
|
prev = _os.getcwd()
|
|
subdir = tmp_path / "sub"
|
|
subdir.mkdir()
|
|
try:
|
|
_os.chdir(subdir)
|
|
code = resolve_and_run_event_command("speckit.cwd", "session_start", "{}", tmp_path)
|
|
finally:
|
|
_os.chdir(prev)
|
|
assert code == 0
|
|
recorded = out_file.read_text().strip()
|
|
assert Path(recorded).resolve() == tmp_path.resolve()
|
|
|
|
def test_dispatcher_is_self_contained(self, tmp_path):
|
|
"""R1: the generated dispatcher prefers `import specify_cli` (durable
|
|
install) and falls back to an inline stdlib resolver so it works
|
|
without a persistent `specify` executable (e.g. one-time uvx)."""
|
|
integration = ClaudeIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
|
|
)
|
|
content = (tmp_path / EVENTS_DISPATCHER_REL).read_text()
|
|
# Delegates to specify_cli when importable.
|
|
assert "from specify_cli.events import resolve_and_run_event_command" in content
|
|
assert "except ImportError" in content
|
|
# Inline stdlib fallback resolver for one-time/temporary installs.
|
|
assert "_run_inline" in content
|
|
assert "_find_command_template" in content
|
|
# No dependency on a persistent `specify` executable.
|
|
assert '["specify"]' not in content
|
|
|
|
def test_dispatcher_inline_fallback_runs_script(self, tmp_path):
|
|
"""R1: with specify_cli.events NOT importable, the inline resolver
|
|
finds the command template and runs its script (stdlib only)."""
|
|
import subprocess as _sp
|
|
import sys as _sys
|
|
|
|
# Install events (generates the dispatcher + native config).
|
|
integration = ClaudeIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"session_start": [{"command": "speckit.boot"}]},
|
|
)
|
|
dispatcher = tmp_path / EVENTS_DISPATCHER_REL
|
|
assert dispatcher.is_file()
|
|
|
|
# Create a core command template whose script writes its payload.
|
|
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
|
|
cmd_dir.mkdir(parents=True)
|
|
out_file = tmp_path / "payload.out"
|
|
(cmd_dir / "boot.md").write_text(
|
|
"---\ndescription: \"Boot\"\nscripts:\n sh: scripts/boot.sh\n---\nBody\n",
|
|
encoding="utf-8",
|
|
)
|
|
script_dir = tmp_path / ".specify" / "scripts"
|
|
script_dir.mkdir(parents=True)
|
|
script = script_dir / "boot.sh"
|
|
script.write_text(f"#!/bin/sh\ncat > {shlex.quote(str(out_file))}\nexit 0\n", encoding="utf-8")
|
|
script.chmod(0o755)
|
|
|
|
if platform.system().lower().startswith("win"):
|
|
return # sh is POSIX
|
|
|
|
# Force the inline fallback: shadow `specify_cli` with an empty package
|
|
# (no `events` submodule) so `from specify_cli.events import ...` raises
|
|
# ModuleNotFoundError (an ImportError subclass), simulating a one-time
|
|
# install where the package is unavailable at runtime.
|
|
fake_dir = tmp_path / "_fake"
|
|
(fake_dir / "specify_cli").mkdir(parents=True)
|
|
(fake_dir / "specify_cli" / "__init__.py").write_text("", encoding="utf-8")
|
|
env = dict(os.environ)
|
|
env["PYTHONPATH"] = str(fake_dir)
|
|
result = _sp.run(
|
|
[_sys.executable, str(dispatcher), "speckit.boot", "session_start", "60"],
|
|
input='{"tool_name":"x"}',
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
cwd=str(tmp_path),
|
|
)
|
|
# The inline resolver ran the script with the payload.
|
|
assert out_file.exists(), f"inline fallback did not run script; stderr={result.stderr!r} rc={result.returncode}"
|
|
assert out_file.read_text() == '{"tool_name":"x"}'
|
|
|
|
def test_dispatcher_threads_per_handler_timeout(self, tmp_path):
|
|
"""S4: the generated dispatcher reads an optional 4th timeout arg and
|
|
uses it for the inner subprocess, instead of a fixed 120s cap that
|
|
would kill a handler configured for longer."""
|
|
integration = ClaudeIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"pre_tool_use": [{"command": "speckit.tdd.validate", "timeout": 300}]},
|
|
)
|
|
content = (tmp_path / EVENTS_DISPATCHER_REL).read_text()
|
|
# The dispatcher accepts a 4th argv element as the timeout.
|
|
assert "sys.argv[3]" in content
|
|
assert "timeout=timeout" in content
|
|
|
|
def test_native_command_carries_resolved_timeout(self, tmp_path):
|
|
"""S4: the generated native hook command appends the resolved timeout
|
|
so the dispatcher receives it. Claude uses seconds (default unit)."""
|
|
from specify_cli.events import _dispatcher_command
|
|
cmd = _dispatcher_command(
|
|
ClaudeIntegration(), tmp_path, "speckit.x.y", "stop",
|
|
timeout_seconds=300,
|
|
)
|
|
# R2: the raw seconds (no conversion, no buffer) are appended as the
|
|
# 4th arg; the buffer goes on the native hook timeout field instead.
|
|
assert " 300" in cmd
|
|
|
|
def test_dispatcher_timeout_not_unit_converted_for_ms_adapters(self, tmp_path):
|
|
"""R2: the dispatcher arg is always seconds — for Gemini/Qwen/Tabnine
|
|
(ms adapters) the timeout must NOT be converted to milliseconds
|
|
(which previously yielded 60000 seconds)."""
|
|
from specify_cli.events import _dispatcher_command
|
|
from specify_cli.integrations.gemini import GeminiIntegration
|
|
cmd = _dispatcher_command(
|
|
GeminiIntegration(), tmp_path, "speckit.x.y", "pre_tool_use",
|
|
timeout_seconds=60,
|
|
)
|
|
# 60 seconds (not 60000) is passed to the dispatcher.
|
|
assert " 60" in cmd
|
|
assert " 60000" not in cmd
|
|
|
|
def test_sh_variant_uses_launcher_on_windows(self, tmp_path):
|
|
"""S5: on Windows the sh variant prefixes a bash/sh launcher so
|
|
subprocess.run(shell=False) can execute the .sh script."""
|
|
from specify_cli.events import _resolve_event_command_argv
|
|
|
|
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
|
|
cmd_dir.mkdir(parents=True)
|
|
(cmd_dir / "boot.md").write_text(
|
|
"---\ndescription: \"Boot\"\nscripts:\n sh: scripts/bash/boot.sh\n---\nBody\n",
|
|
encoding="utf-8",
|
|
)
|
|
sh_dir = tmp_path / ".specify" / "scripts" / "bash"
|
|
sh_dir.mkdir(parents=True)
|
|
(sh_dir / "boot.sh").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
|
|
|
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
|
|
assert argv is not None
|
|
# On POSIX the script runs directly; on Windows a launcher prefixes it.
|
|
if platform.system().lower().startswith("win"):
|
|
assert PurePath(argv[0]).stem.lower() in ("bash", "sh")
|
|
assert PurePath(argv[1]).as_posix().endswith(".specify/scripts/bash/boot.sh")
|
|
else:
|
|
assert PurePath(argv[0]).as_posix().endswith(".specify/scripts/bash/boot.sh")
|
|
|
|
|
|
# -- Merge/teardown idempotency & safety (Tier 3) ----------------------------
|
|
|
|
def _claude_manifest(tmp_path):
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
manifest.remove = MagicMock()
|
|
return manifest
|
|
|
|
|
|
class TestMergeIdempotency:
|
|
"""#9/#11: marker recursion and full-clean-before-add."""
|
|
|
|
def test_upgrade_does_not_duplicate_nested_hooks(self, tmp_path):
|
|
"""#9: re-running install replaces prior Specify inner hooks instead of
|
|
appending a second matcher-group on every upgrade."""
|
|
integration = ClaudeIntegration()
|
|
config_path = tmp_path / ".claude/settings.json"
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
events = {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}
|
|
for _ in range(2):
|
|
manifest = _claude_manifest(tmp_path)
|
|
install_integration_events(integration, tmp_path, manifest, events)
|
|
|
|
data = json.loads(config_path.read_text())
|
|
groups = data["hooks"]["PreToolUse"]
|
|
# Exactly one matcher-group for Specify (no duplication).
|
|
assert len(groups) == 1
|
|
inner = groups[0]["hooks"]
|
|
assert len(inner) == 1
|
|
assert "speckit.tdd.validate" in inner[0]["command"]
|
|
|
|
def test_override_change_removes_stale_event(self, tmp_path):
|
|
"""#11: when the resolved set changes from pre_tool_use to stop, the
|
|
old marked pre_tool_use entry is removed, not left active."""
|
|
integration = ClaudeIntegration()
|
|
config_path = tmp_path / ".claude/settings.json"
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
install_integration_events(
|
|
integration, tmp_path, _claude_manifest(tmp_path),
|
|
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
|
|
)
|
|
install_integration_events(
|
|
integration, tmp_path, _claude_manifest(tmp_path),
|
|
{"stop": [{"command": "speckit.end"}]},
|
|
)
|
|
|
|
data = json.loads(config_path.read_text())
|
|
assert "PreToolUse" not in data["hooks"]
|
|
assert "Stop" in data["hooks"]
|
|
|
|
|
|
class TestEmptyMapRemoval:
|
|
"""#3: --events false / empty resolved map strips prior hooks."""
|
|
|
|
def test_empty_events_removes_prior_hooks(self, tmp_path):
|
|
integration = ClaudeIntegration()
|
|
config_path = tmp_path / ".claude/settings.json"
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
install_integration_events(
|
|
integration, tmp_path, _claude_manifest(tmp_path),
|
|
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
|
|
)
|
|
assert config_path.is_file()
|
|
|
|
# Now resolve to empty (--events false): prior hooks must be removed.
|
|
install_integration_events(integration, tmp_path, _claude_manifest(tmp_path), {})
|
|
|
|
# The dispatcher is shared and left in place (#10); only native hooks
|
|
# are stripped. The settings file had no user content → deleted (#14).
|
|
assert not config_path.exists() or "hooks" not in json.loads(config_path.read_text())
|
|
|
|
|
|
class TestTeardownDataSafety:
|
|
"""#14/#22/#23: preserve user content, delete Spec-Kit-created empties."""
|
|
|
|
def test_remove_deletes_spec_kit_created_config(self, tmp_path):
|
|
"""#14: a config Spec Kit created from scratch is deleted (not left as
|
|
``{}``) so manifest.uninstall() doesn't preserve an empty stub."""
|
|
integration = ClaudeIntegration()
|
|
manifest = _claude_manifest(tmp_path)
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
|
|
)
|
|
config_path = tmp_path / ".claude/settings.json"
|
|
assert config_path.is_file()
|
|
|
|
remove_integration_events(integration, tmp_path, manifest)
|
|
assert not config_path.exists()
|
|
|
|
def test_remove_preserves_user_content_in_config(self, tmp_path):
|
|
"""#14: a pre-existing config with user content is kept (user hooks
|
|
survive teardown)."""
|
|
integration = ClaudeIntegration()
|
|
config_path = tmp_path / ".claude/settings.json"
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
config_path.write_text(json.dumps({
|
|
"hooks": {
|
|
"PreToolUse": [{
|
|
"matcher": "Bash",
|
|
"hooks": [{"type": "command", "command": "user-check"}],
|
|
}]
|
|
},
|
|
"userSetting": True,
|
|
}))
|
|
|
|
manifest = _claude_manifest(tmp_path)
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"stop": [{"command": "speckit.end"}]},
|
|
)
|
|
remove_integration_events(integration, tmp_path, manifest)
|
|
|
|
data = json.loads(config_path.read_text())
|
|
# User hook and setting preserved; Specify hook gone.
|
|
assert data["userSetting"] is True
|
|
assert "Stop" not in data.get("hooks", {})
|
|
assert data["hooks"]["PreToolUse"][0]["matcher"] == "Bash"
|
|
|
|
def test_forced_full_teardown_preserves_user_config(self, tmp_path):
|
|
"""S9: a full teardown(force=True) — which runs manifest.uninstall(
|
|
force=True) after remove_events — must not delete a pre-existing user
|
|
settings file whose owned entries were cleaned but user content kept.
|
|
Uses a real manifest to exercise the uninstall path."""
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
integration = ClaudeIntegration()
|
|
config_path = tmp_path / ".claude/settings.json"
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
config_path.write_text(json.dumps({
|
|
"hooks": {
|
|
"PreToolUse": [{
|
|
"matcher": "Bash",
|
|
"hooks": [{"type": "command", "command": "user-check"}],
|
|
}]
|
|
},
|
|
"userSetting": True,
|
|
}))
|
|
|
|
manifest = IntegrationManifest(integration.key, tmp_path, version="test")
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"stop": [{"command": "speckit.end"}]},
|
|
)
|
|
manifest.save()
|
|
|
|
# Full teardown: remove_events + manifest.uninstall(force=True).
|
|
integration.teardown(tmp_path, manifest, force=True)
|
|
|
|
assert config_path.exists(), (
|
|
"Forced teardown deleted the user's settings file (S9)."
|
|
)
|
|
data = json.loads(config_path.read_text())
|
|
assert data["userSetting"] is True
|
|
assert data["hooks"]["PreToolUse"][0]["matcher"] == "Bash"
|
|
|
|
def test_jsonc_config_not_reset_on_merge(self, tmp_path):
|
|
"""#22: a JSONC/unparseable native config is left untouched on merge."""
|
|
integration = ClaudeIntegration()
|
|
config_path = tmp_path / ".claude/settings.json"
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
jsonc = '{\n // my comment\n "hooks": {}\n}\n'
|
|
config_path.write_text(jsonc)
|
|
|
|
install_integration_events(
|
|
integration, tmp_path, _claude_manifest(tmp_path),
|
|
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
|
|
)
|
|
# User content preserved verbatim — not reset to {}.
|
|
assert config_path.read_text() == jsonc
|
|
|
|
def test_unreadable_config_not_overwritten_on_merge(self, tmp_path):
|
|
"""An unreadable user config aborts the merge instead of crashing."""
|
|
integration = ClaudeIntegration()
|
|
config_path = tmp_path / ".claude/settings.json"
|
|
config_path.mkdir(parents=True)
|
|
|
|
install_integration_events(
|
|
integration, tmp_path, _claude_manifest(tmp_path),
|
|
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
|
|
)
|
|
|
|
assert config_path.is_dir()
|
|
|
|
def test_jsonc_opencode_config_not_reset(self, tmp_path):
|
|
"""#23: a malformed opencode.json is preserved, not reset to {}."""
|
|
integration = OpencodeIntegration()
|
|
config_path = tmp_path / "opencode.json"
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
malformed = "{ not valid json"
|
|
config_path.write_text(malformed)
|
|
|
|
install_integration_events(
|
|
integration, tmp_path, _claude_manifest(tmp_path),
|
|
{"session_start": [{"command": "speckit.boot"}]},
|
|
)
|
|
assert config_path.read_text() == malformed
|
|
|
|
|
|
class TestCopilotMergeTeardown:
|
|
"""#8: Copilot dedicated hooks JSON merges owned entries / teardown
|
|
removes only owned entries."""
|
|
|
|
def test_copilot_merge_preserves_user_hooks(self, tmp_path):
|
|
integration = CopilotIntegration()
|
|
config_path = tmp_path / ".github/hooks/speckit.json"
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
config_path.write_text(json.dumps({
|
|
"version": 1,
|
|
"hooks": {
|
|
"sessionStart": [{"type": "command", "bash": "user-hook"}],
|
|
},
|
|
}))
|
|
|
|
install_integration_events(
|
|
integration, tmp_path, _claude_manifest(tmp_path),
|
|
{"session_start": [{"command": "speckit.boot"}]},
|
|
)
|
|
data = json.loads(config_path.read_text())
|
|
entries = data["hooks"]["sessionStart"]
|
|
bash_cmds = [e.get("bash") for e in entries]
|
|
assert "user-hook" in bash_cmds
|
|
assert any("speckit.boot" in c for c in bash_cmds)
|
|
|
|
def test_copilot_teardown_removes_only_owned_entries(self, tmp_path):
|
|
integration = CopilotIntegration()
|
|
config_path = tmp_path / ".github/hooks/speckit.json"
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
config_path.write_text(json.dumps({
|
|
"version": 1,
|
|
"hooks": {
|
|
"sessionStart": [{"type": "command", "bash": "user-hook"}],
|
|
},
|
|
}))
|
|
|
|
manifest = _claude_manifest(tmp_path)
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"session_start": [{"command": "speckit.boot"}]},
|
|
)
|
|
remove_integration_events(integration, tmp_path, manifest)
|
|
|
|
data = json.loads(config_path.read_text())
|
|
# User hook preserved; Spec-Kit entry gone.
|
|
assert data["hooks"]["sessionStart"][0]["bash"] == "user-hook"
|
|
|
|
def test_copilot_teardown_deletes_spec_kit_only_file(self, tmp_path):
|
|
"""#8/#14: when the file held only Spec-Kit entries, teardown deletes it."""
|
|
integration = CopilotIntegration()
|
|
manifest = _claude_manifest(tmp_path)
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"session_start": [{"command": "speckit.boot"}]},
|
|
)
|
|
config_path = tmp_path / ".github/hooks/speckit.json"
|
|
assert config_path.is_file()
|
|
remove_integration_events(integration, tmp_path, manifest)
|
|
assert not config_path.exists()
|
|
|
|
|
|
class TestSharedDispatcherRefcount:
|
|
"""#10: the shared .specify/events.py dispatcher is not deleted while
|
|
another installed event-capable integration still references it."""
|
|
|
|
def test_dispatcher_kept_when_other_integration_references_it(self, tmp_path):
|
|
# Simulate two event-capable integrations installed: claude (the one
|
|
# being uninstalled) and codex (still installed). The codex manifest
|
|
# lists the dispatcher, so removing claude must not delete it.
|
|
from specify_cli.integrations.codex import CodexIntegration
|
|
|
|
claude = ClaudeIntegration()
|
|
codex = CodexIntegration()
|
|
|
|
# Install claude's events (writes dispatcher + claude config).
|
|
claude_manifest = _claude_manifest(tmp_path)
|
|
install_integration_events(
|
|
claude, tmp_path, claude_manifest,
|
|
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
|
|
)
|
|
# Install codex's events (re-writes shared dispatcher + codex config).
|
|
codex_manifest = MagicMock(spec=IntegrationManifest)
|
|
codex_manifest.files = {}
|
|
codex_manifest.record_file = MagicMock()
|
|
codex_manifest.record_existing = MagicMock()
|
|
codex_manifest.remove = MagicMock()
|
|
install_integration_events(
|
|
codex, tmp_path, codex_manifest,
|
|
{"pre_tool_use": [{"command": "speckit.codex.check"}]},
|
|
)
|
|
|
|
# Persist a codex manifest on disk so the refcount check finds it.
|
|
codex_disk = IntegrationManifest(codex.key, tmp_path, version="test")
|
|
codex_disk._files = {EVENTS_DISPATCHER_REL: "x"}
|
|
codex_disk.save()
|
|
|
|
# Write the integration-state JSON so installed_integration_keys sees codex.
|
|
import json as _json
|
|
state_path = tmp_path / ".specify" / "integration.json"
|
|
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
state_path.write_text(_json.dumps({
|
|
"default_integration": "claude",
|
|
"installed_integrations": ["claude", "codex"],
|
|
}))
|
|
|
|
dispatcher_path = tmp_path / EVENTS_DISPATCHER_REL
|
|
assert dispatcher_path.exists()
|
|
|
|
# Removing claude should leave the dispatcher (codex still uses it).
|
|
remove_integration_events(claude, tmp_path, claude_manifest)
|
|
assert dispatcher_path.exists()
|
|
|
|
|
|
class TestSafeWriteDestination:
|
|
"""#12: write targets are validated before any bytes are written."""
|
|
|
|
def test_symlinked_config_dir_rejected(self, tmp_path):
|
|
integration = ClaudeIntegration()
|
|
# Create a symlinked .claude directory pointing outside the project.
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
linked = tmp_path / ".claude"
|
|
os.symlink(outside, linked)
|
|
|
|
with pytest.raises(ValueError, match="(?i)symlink|escapes|outside"):
|
|
install_integration_events(
|
|
integration, tmp_path, _claude_manifest(tmp_path),
|
|
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
|
|
)
|
|
# No content written through the symlink.
|
|
assert not (outside / "settings.json").exists()
|
|
|
|
def test_toml_teardown_rejects_symlinked_config(self, tmp_path):
|
|
"""R3: TOML teardown validates the destination before read/write, so a
|
|
symlink swap after install can't make uninstall overwrite an external
|
|
file."""
|
|
from specify_cli.integrations.codex import CodexIntegration
|
|
|
|
integration = CodexIntegration()
|
|
manifest = _claude_manifest(tmp_path)
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
|
|
)
|
|
config_path = tmp_path / ".codex" / "config.toml"
|
|
assert config_path.is_file()
|
|
|
|
# Swap the config for a symlink pointing outside the project.
|
|
outside = tmp_path / "outside.toml"
|
|
outside.write_text("external = true\n")
|
|
config_path.unlink()
|
|
os.symlink(outside, config_path)
|
|
|
|
with pytest.raises(ValueError, match="(?i)symlink|escapes|outside"):
|
|
remove_integration_events(integration, tmp_path, manifest)
|
|
# External file untouched.
|
|
assert outside.read_text() == "external = true\n"
|
|
|
|
def test_json_remover_rejects_symlinked_config(self, tmp_path):
|
|
"""Removers validate destination before reading or unlinking."""
|
|
integration = ClaudeIntegration()
|
|
manifest = _claude_manifest(tmp_path)
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
|
|
)
|
|
config_path = tmp_path / ".claude" / "settings.json"
|
|
assert config_path.is_file()
|
|
|
|
outside = tmp_path / "outside.json"
|
|
outside.write_text('{"external": true}')
|
|
config_path.unlink()
|
|
os.symlink(outside, config_path)
|
|
|
|
with pytest.raises(ValueError, match="(?i)symlink|escapes|outside"):
|
|
remove_integration_events(integration, tmp_path, manifest)
|
|
assert outside.read_text() == '{"external": true}'
|
|
|
|
def test_plugin_remover_rejects_symlinked_plugin(self, tmp_path):
|
|
"""OpenCode plugin cleanup validates destination before unlinking."""
|
|
integration = OpencodeIntegration()
|
|
manifest = _claude_manifest(tmp_path)
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"session_start": [{"command": "speckit.boot"}]},
|
|
)
|
|
plugin_path = tmp_path / ".opencode" / "plugin" / "speckit-events.ts"
|
|
assert plugin_path.is_file()
|
|
|
|
outside = tmp_path / "outside.ts"
|
|
outside.write_text("// external")
|
|
plugin_path.unlink()
|
|
os.symlink(outside, plugin_path)
|
|
|
|
manifest.files[".opencode/plugin/speckit-events.ts"] = "hash"
|
|
with pytest.raises(ValueError, match="(?i)symlink|escapes|outside"):
|
|
remove_integration_events(integration, tmp_path, manifest)
|
|
assert outside.read_text() == "// external"
|
|
|
|
|
|
# -- Validation & lifecycle (Tier 4) -----------------------------------------
|
|
|
|
class TestValidateEventsCommandType:
|
|
"""#17: command must be a non-empty string, not just truthy."""
|
|
|
|
def test_non_string_command_rejected(self):
|
|
from specify_cli.extensions import ValidationError
|
|
data = {"events": {"pre_tool_use": {"command": ["speckit.tdd.validate"]}}}
|
|
with pytest.raises(ValidationError, match="(?i)command.*string"):
|
|
validate_events(data)
|
|
|
|
def test_empty_string_command_rejected(self):
|
|
from specify_cli.extensions import ValidationError
|
|
data = {"events": {"pre_tool_use": {"command": " "}}}
|
|
with pytest.raises(ValidationError, match="(?i)command.*string"):
|
|
validate_events(data)
|
|
|
|
|
|
class TestCollectExtensionEventsEnabledFlag:
|
|
"""#1: collect_extension_events honors the registry's enabled flag."""
|
|
|
|
def test_disabled_extension_events_skipped(self, tmp_path):
|
|
from specify_cli.extensions import ExtensionRegistry
|
|
|
|
ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
|
|
ext_dir.mkdir(parents=True)
|
|
(ext_dir / "extension.yml").write_text(
|
|
"extension:\n id: my-ext\n name: My Ext\n version: 1.0.0\n"
|
|
" description: test\n"
|
|
"schema_version: '1.0'\n"
|
|
"requires:\n speckit_version: '>=0.1'\n"
|
|
"provides:\n commands: []\n"
|
|
"events:\n session_start:\n command: speckit.my-ext.boot\n",
|
|
encoding="utf-8",
|
|
)
|
|
registry = ExtensionRegistry(tmp_path / ".specify" / "extensions")
|
|
registry.add("my-ext", {"enabled": False})
|
|
|
|
result = collect_extension_events(tmp_path)
|
|
assert result == {}
|
|
|
|
def test_enabled_extension_events_collected(self, tmp_path):
|
|
from specify_cli.extensions import ExtensionRegistry
|
|
|
|
ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
|
|
ext_dir.mkdir(parents=True)
|
|
(ext_dir / "extension.yml").write_text(
|
|
"extension:\n id: my-ext\n name: My Ext\n version: 1.0.0\n"
|
|
" description: test\n"
|
|
"schema_version: '1.0'\n"
|
|
"requires:\n speckit_version: '>=0.1'\n"
|
|
"provides:\n commands: []\n"
|
|
"events:\n session_start:\n command: speckit.my-ext.boot\n",
|
|
encoding="utf-8",
|
|
)
|
|
registry = ExtensionRegistry(tmp_path / ".specify" / "extensions")
|
|
registry.add("my-ext", {"enabled": True})
|
|
|
|
result = collect_extension_events(tmp_path)
|
|
assert result == {"session_start": [{"command": "speckit.my-ext.boot"}]}
|
|
|
|
|
|
class TestRefreshIntegrationEvents:
|
|
"""#1: refresh_integration_events regenerates native config after
|
|
extension state changes."""
|
|
|
|
def test_refresh_strips_removed_extension_events(self, tmp_path):
|
|
from specify_cli.events import refresh_integration_events
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
# Install claude with an event sourced from a (simulated) extension.
|
|
integration = ClaudeIntegration()
|
|
manifest = IntegrationManifest(integration.key, tmp_path, version="test")
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"pre_tool_use": [{"command": "speckit.my-ext.check"}]},
|
|
)
|
|
manifest.save()
|
|
config_path = tmp_path / ".claude/settings.json"
|
|
assert config_path.is_file()
|
|
|
|
# Record claude as installed so refresh finds it.
|
|
state_path = tmp_path / ".specify" / "integration.json"
|
|
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
state_path.write_text(json.dumps({
|
|
"default_integration": "claude",
|
|
"installed_integrations": ["claude"],
|
|
}))
|
|
# No extension declares events now → refresh should strip the prior hook
|
|
# (the config is deleted when no user content remains, #14).
|
|
refresh_integration_events(tmp_path)
|
|
|
|
if config_path.exists():
|
|
data = json.loads(config_path.read_text())
|
|
assert "PreToolUse" not in data.get("hooks", {})
|
|
# If the file is gone, the hooks were stripped (and the empty config
|
|
# deleted) — also correct.
|
|
|
|
def test_refresh_emits_newly_declared_extension_events(self, tmp_path):
|
|
from specify_cli.events import refresh_integration_events
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
integration = ClaudeIntegration()
|
|
manifest = IntegrationManifest(integration.key, tmp_path, version="test")
|
|
# Initially no events.
|
|
manifest.save()
|
|
config_path = tmp_path / ".claude/settings.json"
|
|
|
|
# Declare an extension event on disk.
|
|
ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
|
|
ext_dir.mkdir(parents=True)
|
|
(ext_dir / "extension.yml").write_text(
|
|
"events:\n session_start:\n command: speckit.my-ext.boot\n",
|
|
encoding="utf-8",
|
|
)
|
|
state_path = tmp_path / ".specify" / "integration.json"
|
|
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
state_path.write_text(json.dumps({
|
|
"default_integration": "claude",
|
|
"installed_integrations": ["claude"],
|
|
}))
|
|
|
|
refresh_integration_events(tmp_path)
|
|
|
|
data = json.loads(config_path.read_text())
|
|
assert "SessionStart" in data["hooks"]
|
|
|
|
def test_refresh_honors_stored_events_false(self, tmp_path):
|
|
"""S7: a stored --events false must be honored across extension
|
|
lifecycle refresh; passing None would re-enable events."""
|
|
from specify_cli.events import refresh_integration_events
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
integration = ClaudeIntegration()
|
|
manifest = IntegrationManifest(integration.key, tmp_path, version="test")
|
|
manifest.save()
|
|
config_path = tmp_path / ".claude/settings.json"
|
|
|
|
# Declare an extension event on disk.
|
|
ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
|
|
ext_dir.mkdir(parents=True)
|
|
(ext_dir / "extension.yml").write_text(
|
|
"events:\n session_start:\n command: speckit.my-ext.boot\n",
|
|
encoding="utf-8",
|
|
)
|
|
# Store the integration with --events false in parsed_options.
|
|
state_path = tmp_path / ".specify" / "integration.json"
|
|
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
state_path.write_text(json.dumps({
|
|
"default_integration": "claude",
|
|
"installed_integrations": ["claude"],
|
|
"integration_settings": {
|
|
"claude": {"parsed_options": {"events": "false"}},
|
|
},
|
|
}))
|
|
|
|
refresh_integration_events(tmp_path)
|
|
|
|
# No hooks should have been re-created (CLI gate honored).
|
|
if config_path.exists():
|
|
assert "hooks" not in json.loads(config_path.read_text())
|
|
|
|
|
|
# -- Override preserve-layers (#10) ------------------------------------------
|
|
|
|
class TestOverridePreserveLayers:
|
|
"""#10: an invalid override entry abandons the whole override and keeps
|
|
the accumulated built-in + extension layers, instead of disabling all
|
|
hooks."""
|
|
|
|
def test_invalid_override_entry_keeps_prior_layers(self, tmp_path):
|
|
override_file = tmp_path / ".specify" / "integration-events.yml"
|
|
override_file.parent.mkdir(parents=True, exist_ok=True)
|
|
# One valid entry, one invalid (non-string command) — the whole
|
|
# override is ignored, built-in defaults survive.
|
|
override_file.write_text(
|
|
"integrations:\n"
|
|
" claude:\n"
|
|
" events:\n"
|
|
" stop:\n"
|
|
" command: speckit.valid.stop\n"
|
|
" pre_tool_use:\n"
|
|
" command: [not-a-string]\n",
|
|
encoding="utf-8",
|
|
)
|
|
result = resolve_events(
|
|
"claude",
|
|
{"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
|
|
tmp_path,
|
|
None,
|
|
)
|
|
# Built-in default survived (override was abandoned on the invalid entry).
|
|
assert "post_tool_use" in result
|
|
assert result["post_tool_use"] == [{"command": "speckit.tdd.validate"}]
|
|
|
|
def test_explicit_empty_override_disables(self, tmp_path):
|
|
"""A fully-valid explicit `events: {}` override still disables."""
|
|
override_file = tmp_path / ".specify" / "integration-events.yml"
|
|
override_file.parent.mkdir(parents=True, exist_ok=True)
|
|
override_file.write_text(
|
|
"integrations:\n"
|
|
" claude:\n"
|
|
" events: {}\n",
|
|
encoding="utf-8",
|
|
)
|
|
result = resolve_events(
|
|
"claude",
|
|
{"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
|
|
tmp_path,
|
|
None,
|
|
)
|
|
assert result == {}
|
|
|
|
def test_empty_handler_override_abandons_override(self, tmp_path):
|
|
"""C4: a malformed handler (`stop: []` or `stop: bad-value`) abandons
|
|
the whole override and keeps prior layers, rather than disabling."""
|
|
override_file = tmp_path / ".specify" / "integration-events.yml"
|
|
override_file.parent.mkdir(parents=True, exist_ok=True)
|
|
override_file.write_text(
|
|
"integrations:\n"
|
|
" claude:\n"
|
|
" events:\n"
|
|
" stop: []\n",
|
|
encoding="utf-8",
|
|
)
|
|
result = resolve_events(
|
|
"claude",
|
|
{"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
|
|
tmp_path,
|
|
None,
|
|
)
|
|
# Built-in default survived (override abandoned on the empty handler).
|
|
assert "post_tool_use" in result
|
|
|
|
def test_non_mapping_integration_entry_abandons_override(self, tmp_path):
|
|
"""C6: a non-mapping integration entry (`claude: bad`) is ignored as
|
|
malformed, not treated as a valid explicit disable."""
|
|
override_file = tmp_path / ".specify" / "integration-events.yml"
|
|
override_file.parent.mkdir(parents=True, exist_ok=True)
|
|
override_file.write_text(
|
|
"integrations:\n"
|
|
" claude: bad\n",
|
|
encoding="utf-8",
|
|
)
|
|
result = resolve_events(
|
|
"claude",
|
|
{"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
|
|
tmp_path,
|
|
None,
|
|
)
|
|
# Built-in default survived (override ignored as malformed).
|
|
assert "post_tool_use" in result
|
|
|
|
|
|
# -- Matcher string validation (C10) -----------------------------------------
|
|
|
|
class TestMatcherValidation:
|
|
"""C10: matcher must be a string; a non-string matcher is rejected at
|
|
validation time so it can't crash by_matcher.setdefault later."""
|
|
|
|
def test_non_string_matcher_rejected_in_manifest(self):
|
|
from specify_cli.extensions import ValidationError
|
|
data = {"events": {"pre_tool_use": {"command": "speckit.x.y", "matcher": []}}}
|
|
with pytest.raises(ValidationError, match="(?i)matcher.*string"):
|
|
validate_events(data)
|
|
|
|
def test_non_string_matcher_rejected_in_override(self, tmp_path):
|
|
override_file = tmp_path / ".specify" / "integration-events.yml"
|
|
override_file.parent.mkdir(parents=True, exist_ok=True)
|
|
# A matcher of [] would crash by_matcher.setdefault; the override must
|
|
# be abandoned (built-in defaults survive) rather than crash.
|
|
override_file.write_text(
|
|
"integrations:\n"
|
|
" claude:\n"
|
|
" events:\n"
|
|
" pre_tool_use:\n"
|
|
" command: speckit.x.y\n"
|
|
" matcher: []\n",
|
|
encoding="utf-8",
|
|
)
|
|
result = resolve_events(
|
|
"claude",
|
|
{"events": {"stop": {"command": "speckit.end"}}},
|
|
tmp_path,
|
|
None,
|
|
)
|
|
# Built-in default survived (override abandoned on the bad matcher).
|
|
assert "stop" in result
|
|
|
|
|
|
class TestTimeoutValidation:
|
|
"""Validate that non-integer, boolean, or non-positive timeouts are rejected."""
|
|
|
|
def test_non_int_timeout_rejected_in_manifest(self):
|
|
from specify_cli.extensions import ValidationError
|
|
data = {"events": {"pre_tool_use": {"command": "speckit.x.y", "timeout": "60"}}}
|
|
with pytest.raises(ValidationError, match="(?i)timeout.*positive integer"):
|
|
validate_events(data)
|
|
|
|
def test_boolean_timeout_rejected_in_manifest(self):
|
|
from specify_cli.extensions import ValidationError
|
|
data = {"events": {"pre_tool_use": {"command": "speckit.x.y", "timeout": True}}}
|
|
with pytest.raises(ValidationError, match="(?i)timeout.*positive integer"):
|
|
validate_events(data)
|
|
|
|
def test_zero_or_negative_timeout_rejected_in_manifest(self):
|
|
from specify_cli.extensions import ValidationError
|
|
data = {"events": {"pre_tool_use": {"command": "speckit.x.y", "timeout": 0}}}
|
|
with pytest.raises(ValidationError, match="(?i)timeout.*positive integer"):
|
|
validate_events(data)
|
|
|
|
|
|
# -- Event command-ref canonicalization (C11) --------------------------------
|
|
|
|
class TestEventCommandRefCanonicalization:
|
|
"""C11: an event referencing a command that was auto-corrected is itself
|
|
rewritten to the canonical name (mirrors hook reference rewriting)."""
|
|
|
|
def test_event_command_ref_lifted_to_canonical(self, tmp_path):
|
|
from specify_cli.extensions import ExtensionManifest
|
|
import yaml as _yaml
|
|
|
|
manifest_path = tmp_path / "extension.yml"
|
|
manifest_path.write_text(
|
|
_yaml.dump({
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": "my-ext",
|
|
"name": "My Ext",
|
|
"version": "1.0.0",
|
|
"description": "test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1"},
|
|
"provides": {
|
|
"commands": [
|
|
{"name": "speckit.my-ext.boot", "file": "commands/boot.md"}
|
|
]
|
|
},
|
|
"events": {
|
|
"session_start": {"command": "my-ext.boot"},
|
|
},
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
manifest = ExtensionManifest(manifest_path)
|
|
assert manifest.data["events"]["session_start"]["command"] == "speckit.my-ext.boot"
|
|
assert any(
|
|
"Event 'session_start' referenced command 'my-ext.boot'" in w
|
|
for w in manifest.warnings
|
|
)
|
|
|
|
|
|
# -- Skipped-merge not tracked (S5) ------------------------------------------
|
|
|
|
class TestSkippedMergeNotTracked:
|
|
"""S5: when a merge is skipped on parse failure, the untouched file is
|
|
not recorded in the manifest, so uninstall() won't later delete it."""
|
|
|
|
def test_jsonc_native_config_not_tracked(self, tmp_path):
|
|
integration = ClaudeIntegration()
|
|
config_path = tmp_path / ".claude/settings.json"
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
jsonc = '{\n // my comment\n "hooks": {}\n}\n'
|
|
config_path.write_text(jsonc)
|
|
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
manifest.remove = MagicMock()
|
|
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
|
|
)
|
|
# The JSONC file must NOT have been recorded (would cause uninstall()
|
|
# to delete it later). Only the dispatcher (which we wrote) is tracked.
|
|
recorded_rels = [c.args[0] for c in manifest.record_existing.call_args_list]
|
|
assert str(config_path.relative_to(tmp_path)) not in recorded_rels
|
|
# User content preserved verbatim.
|
|
assert config_path.read_text() == jsonc
|
|
|
|
|
|
# -- Dispatcher manifest claim dropped on retain (S1) ------------------------
|
|
|
|
class TestDispatcherManifestClaimDroppedOnRetain:
|
|
"""S1: when the dispatcher is retained (another integration references
|
|
it), this integration's manifest still drops its claim so the subsequent
|
|
manifest.uninstall() in teardown() doesn't delete the shared file."""
|
|
|
|
def test_full_teardown_keeps_dispatcher_when_other_references_it(self, tmp_path):
|
|
from specify_cli.integrations.codex import CodexIntegration
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
claude = ClaudeIntegration()
|
|
codex = CodexIntegration()
|
|
|
|
# Install claude's events (writes dispatcher + claude config).
|
|
claude_manifest = IntegrationManifest(claude.key, tmp_path, version="test")
|
|
install_integration_events(
|
|
claude, tmp_path, claude_manifest,
|
|
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
|
|
)
|
|
claude_manifest.save()
|
|
|
|
# Install codex's events (re-writes shared dispatcher + codex config).
|
|
codex_manifest = IntegrationManifest(codex.key, tmp_path, version="test")
|
|
install_integration_events(
|
|
codex, tmp_path, codex_manifest,
|
|
{"pre_tool_use": [{"command": "speckit.codex.check"}]},
|
|
)
|
|
codex_manifest.save()
|
|
|
|
# integration.json: both installed, codex is default.
|
|
state_path = tmp_path / ".specify" / "integration.json"
|
|
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
state_path.write_text(json.dumps({
|
|
"default_integration": "codex",
|
|
"installed_integrations": ["claude", "codex"],
|
|
}))
|
|
|
|
dispatcher_path = tmp_path / EVENTS_DISPATCHER_REL
|
|
assert dispatcher_path.exists()
|
|
|
|
# Full teardown of claude (remove + manifest.uninstall): the dispatcher
|
|
# must survive because codex's manifest still references it.
|
|
remove_integration_events(claude, tmp_path, claude_manifest)
|
|
claude_manifest.uninstall(tmp_path, force=True)
|
|
|
|
assert dispatcher_path.exists(), (
|
|
"Shared dispatcher was deleted by teardown() despite another "
|
|
"integration referencing it (S1)."
|
|
)
|
|
|
|
def test_empty_map_upgrade_deletes_dispatcher_for_last_integration(self, tmp_path):
|
|
"""S3: an --events false upgrade (empty resolved map) of the last
|
|
event-capable integration deletes the shared dispatcher instead of
|
|
orphaning it (the new manifest wouldn't claim it and stale cleanup
|
|
excludes it)."""
|
|
from specify_cli.integrations.codex import CodexIntegration
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
claude = ClaudeIntegration()
|
|
codex = CodexIntegration()
|
|
# Install both so the dispatcher is shared.
|
|
cm = IntegrationManifest(claude.key, tmp_path, version="test")
|
|
install_integration_events(claude, tmp_path, cm, {"pre_tool_use": [{"command": "speckit.x"}]})
|
|
cm.save()
|
|
xm = IntegrationManifest(codex.key, tmp_path, version="test")
|
|
install_integration_events(codex, tmp_path, xm, {"pre_tool_use": [{"command": "speckit.y"}]})
|
|
xm.save()
|
|
state_path = tmp_path / ".specify" / "integration.json"
|
|
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
state_path.write_text(json.dumps({
|
|
"default_integration": "codex",
|
|
"installed_integrations": ["claude", "codex"],
|
|
}))
|
|
dispatcher_path = tmp_path / EVENTS_DISPATCHER_REL
|
|
assert dispatcher_path.exists()
|
|
|
|
# Codex upgrades to --events false (empty map): claude still references
|
|
# the dispatcher, so it must be retained.
|
|
install_integration_events(codex, tmp_path, xm, {})
|
|
xm.save()
|
|
assert dispatcher_path.exists(), "Dispatcher deleted while claude still references it."
|
|
|
|
# Now claude also goes --events false: no integration references the
|
|
# dispatcher, so it must be deleted (not orphaned).
|
|
install_integration_events(claude, tmp_path, cm, {})
|
|
cm.save()
|
|
assert not dispatcher_path.exists(), (
|
|
"Dispatcher orphaned after the last event integration disabled events (S3)."
|
|
)
|
|
|
|
def test_fresh_manifest_upgrade_deletes_dispatcher_when_last(self, tmp_path):
|
|
"""S2: an upgrade passing a *fresh* manifest (that never recorded the
|
|
dispatcher) still deletes the shared dispatcher when no other
|
|
integration references it — the deletion isn't gated on the new
|
|
manifest's claim."""
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
claude = ClaudeIntegration()
|
|
# Install claude with an old manifest that records the dispatcher.
|
|
old = IntegrationManifest(claude.key, tmp_path, version="test")
|
|
install_integration_events(claude, tmp_path, old, {"pre_tool_use": [{"command": "speckit.x"}]})
|
|
old.save()
|
|
state_path = tmp_path / ".specify" / "integration.json"
|
|
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
state_path.write_text(json.dumps({
|
|
"default_integration": "claude",
|
|
"installed_integrations": ["claude"],
|
|
}))
|
|
dispatcher_path = tmp_path / EVENTS_DISPATCHER_REL
|
|
assert dispatcher_path.exists()
|
|
|
|
# Simulate the upgrade path: a fresh manifest (like
|
|
# IntegrationManifest(key, project_root, version=...) in
|
|
# _migrate_commands) that never recorded the dispatcher.
|
|
fresh = IntegrationManifest(claude.key, tmp_path, version="test")
|
|
assert EVENTS_DISPATCHER_REL not in fresh.files
|
|
install_integration_events(claude, tmp_path, fresh, {})
|
|
|
|
# The dispatcher must be deleted (no other integration references it),
|
|
# not orphaned just because the fresh manifest didn't claim it.
|
|
assert not dispatcher_path.exists(), (
|
|
"Dispatcher orphaned after upgrade with a fresh manifest (S2)."
|
|
)
|
|
|
|
|
|
# -- Dispatcher stale-cleanup exclusion (C3) ---------------------------------
|
|
|
|
class TestDispatcherStaleExclusion:
|
|
"""C3: the shared dispatcher is excluded from the generic upgrade stale
|
|
pass so an --events false upgrade doesn't delete it and break other
|
|
installed event-capable integrations."""
|
|
|
|
def test_dispatcher_in_stale_exclusions(self):
|
|
from specify_cli.events import events_stale_exclusions
|
|
exclusions = events_stale_exclusions("claude")
|
|
assert EVENTS_DISPATCHER_REL in exclusions
|
|
|
|
|
|
# -- Cursor version-only stub deletion (C5) ----------------------------------
|
|
|
|
class TestCursorVersionOnlyStubDeletion:
|
|
"""C5: a Spec-Kit-created Cursor file retaining only {"version": 1} after
|
|
all owned hooks are removed is deleted, not left as a generated stub."""
|
|
|
|
def test_version_only_cursor_file_deleted_on_teardown(self, tmp_path):
|
|
integration = CursorAgentIntegration()
|
|
manifest = MagicMock(spec=IntegrationManifest)
|
|
manifest.files = {}
|
|
manifest.record_file = MagicMock()
|
|
manifest.record_existing = MagicMock()
|
|
manifest.remove = MagicMock()
|
|
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"session_start": [{"command": "speckit.boot"}]},
|
|
)
|
|
config_path = tmp_path / ".cursor/hooks.json"
|
|
assert config_path.is_file()
|
|
assert json.loads(config_path.read_text()).get("version") == 1
|
|
|
|
remove_integration_events(integration, tmp_path, manifest)
|
|
# No user content remained (only the Spec-Kit-managed version field) →
|
|
# the file is deleted for a clean teardown, not left as a stub.
|
|
assert not config_path.exists()
|
|
|
|
|
|
# -- Non-destructive refresh (C12) -------------------------------------------
|
|
|
|
class TestNonDestructiveRefresh:
|
|
"""C12: refresh resolves first then installs once; a failure during install
|
|
no longer destroys the working native config before the new one is written."""
|
|
|
|
def test_refresh_failure_preserves_existing_config(self, tmp_path):
|
|
from specify_cli.events import refresh_integration_events
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
integration = ClaudeIntegration()
|
|
manifest = IntegrationManifest(integration.key, tmp_path, version="test")
|
|
install_integration_events(
|
|
integration, tmp_path, manifest,
|
|
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
|
|
)
|
|
manifest.save()
|
|
config_path = tmp_path / ".claude/settings.json"
|
|
original = config_path.read_text()
|
|
|
|
# Declare an extension event so refresh would try to re-emit.
|
|
ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
|
|
ext_dir.mkdir(parents=True)
|
|
(ext_dir / "extension.yml").write_text(
|
|
"events:\n session_start:\n command: speckit.my-ext.boot\n",
|
|
encoding="utf-8",
|
|
)
|
|
state_path = tmp_path / ".specify" / "integration.json"
|
|
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
state_path.write_text(json.dumps({
|
|
"default_integration": "claude",
|
|
"installed_integrations": ["claude"],
|
|
}))
|
|
|
|
# Force install_integration_events to fail mid-refresh. R3: the
|
|
# failure is now surfaced as EventRefreshError (aggregated) rather
|
|
# than silently swallowed.
|
|
from specify_cli.events import EventRefreshError
|
|
with patch(
|
|
"specify_cli.events.install_integration_events",
|
|
side_effect=RuntimeError("simulated write failure"),
|
|
):
|
|
with pytest.raises(EventRefreshError, match="simulated write failure"):
|
|
refresh_integration_events(tmp_path)
|
|
|
|
# The pre-existing config was NOT destroyed before the failure
|
|
# (install handles cleanup atomically; refresh no longer pre-strips).
|
|
assert config_path.read_text() == original
|