Files
spec-kit/tests/integrations/test_base.py
Manfred Riem 3899dcc0d4 Stage 2: Copilot integration — proof of concept with shared template primitives (#2035)
* feat: Stage 2a — CopilotIntegration with shared template primitives

- base.py: added granular primitives (shared_commands_dir,
  shared_templates_dir, list_command_templates, command_filename,
  commands_dest, copy_command_to_directory, record_file_in_manifest,
  write_file_and_record, process_template)
- CopilotIntegration: uses primitives to produce .agent.md commands,
  companion .prompt.md files, and .vscode/settings.json
- Verified byte-for-byte parity with old release script output
- Copilot auto-registered in INTEGRATION_REGISTRY
- 70 tests (22 new: base primitives + copilot integration)

Part of #1924

* feat: Stage 2b — --integration flag, routing, agent.json, shared infra

- Added --integration flag to init() (mutually exclusive with --ai)
- --ai copilot auto-promotes to integration path with migration nudge
- Integration setup writes .specify/agent.json with integration key
- _install_shared_infra() copies scripts and templates to .specify/
- init-options.json records 'integration' key when used
- 4 new CLI tests: mutual exclusivity, unknown rejection, copilot
  end-to-end, auto-promote (74 total integration tests)

Part of #1924

* feat: Stage 2 completion — integration scripts, integration.json, shared manifest

- Added copilot/scripts/update-context.sh and .ps1 (thin wrappers
  that delegate to the shared update-agent-context script)
- CopilotIntegration.setup() installs integration scripts to
  .specify/integrations/copilot/scripts/
- Renamed agent.json → integration.json with script paths
- _install_shared_infra() now tracks files in
  integration-shared.manifest.json
- Updated tests: scripts installed, integration.json has script paths,
  shared manifest recorded (74 tests)

Part of #1924

* refactor: rename shared manifest to speckit.manifest.json

Cleaner naming — the shared infrastructure (scripts, templates)
belongs to spec-kit itself, not to any specific integration.

* fix: copilot update-context scripts reflect target architecture

Scripts now source shared functions (via SPECKIT_SOURCE_ONLY=1) and
call update_agent_file directly with .github/copilot-instructions.md,
rather than delegating back to the shared case statement.

* fix: simplify copilot scripts — dispatcher sources common functions

Integration scripts now contain only copilot-specific logic (target
path + agent name). The dispatcher is responsible for sourcing shared
functions before calling the integration script.

* fix: copilot update-context scripts are self-contained implementations

These scripts ARE the implementation — the dispatcher calls them.
They source common.sh + update-agent-context functions, gather
feature/plan data, then call update_agent_file with the copilot
target path (.github/copilot-instructions.md).

* docs: add Stage 7 activation note to copilot update-context scripts

* test: add complete file inventory test for copilot integration

Validates every single file (37 total) produced by
specify init --integration copilot --script sh --no-git.

* test: add PowerShell file inventory test for copilot integration

Validates all 37 files produced by --script ps variant, including
.specify/scripts/powershell/ instead of bash.

* refactor: split test_integrations.py into tests/integrations/ directory

- test_base.py: IntegrationOption, IntegrationBase, MarkdownIntegration, primitives
- test_manifest.py: IntegrationManifest, path traversal, persistence, validation
- test_registry.py: INTEGRATION_REGISTRY
- test_copilot.py: CopilotIntegration unit tests
- test_cli.py: --integration flag, auto-promote, file inventories (sh + ps)
- conftest.py: shared StubIntegration helper

76 integration tests + 48 consistency tests = 124 total, all passing.

* refactor: move file inventory tests from test_cli to test_copilot

File inventories are copilot-specific. test_cli.py now only tests
CLI flag mechanics (mutual exclusivity, unknown rejection, auto-promote).

* fix: skip JSONC merge to preserve user settings, fix docstring

- _merge_vscode_settings() now returns early (skips merge) when
  existing settings.json can't be parsed (e.g. JSONC with comments),
  instead of overwriting with empty settings
- Updated _install_shared_infra() docstring to match implementation
  (scripts + templates, speckit.manifest.json)

* fix: warn user when JSONC settings merge is skipped

* fix: show template content when JSONC merge is skipped

User now sees the exact settings they should add manually.

* fix: document process_template requirement, merge scripts without rmtree

- base.py setup() docstring now explicitly states raw copy behavior
  and directs to CopilotIntegration for process_template example
- _install_shared_infra() uses merge/overwrite instead of rmtree to
  preserve user-added files under .specify/scripts/

* fix: don't overwrite pre-existing shared scripts or templates

Only write files that don't already exist — preserves any user
modifications to shared scripts (common.sh etc.) and templates.

* fix: warn user about skipped pre-existing shared files

Lists all shared scripts and templates that were not copied because
they already existed in the project.

* test: add test for shared infra skip behavior on pre-existing files

Verifies that _install_shared_infra() preserves user-modified scripts
and templates while still installing missing ones.

* fix: address review — containment check, deterministic prompts, manifest accuracy

- CopilotIntegration.setup() adds dest containment check (relative_to)
- Companion prompts generated from templates list, not directory glob
- _install_shared_infra() only records files actually copied (not pre-existing)
- VS Code settings tests made unconditional (assert template exists)
- Inventory tests use .as_posix() for cross-platform paths

* fix: correct PS1 function names, document SPECKIT_SOURCE_ONLY prerequisite

- Fixed Get-FeaturePaths → Get-FeaturePathsEnv, Read-PlanData → Parse-PlanData
- Documented that shared scripts must guard Main with SPECKIT_SOURCE_ONLY
  before these integration scripts can be activated (Stage 7)

* fix: add dict type check for settings merge, simplify PS1 to subprocess

- _merge_vscode_settings() skips merge with warning if parsed JSON
  is not a dict (array, null, etc.)
- PS1 update-context.ps1 uses & invocation instead of dot-sourcing
  since the shared script runs Main unconditionally

* fix: skip-write on no-op merge, bash subprocess, dynamic integration list

- _merge_vscode_settings() only writes when keys were actually added
- update-context.sh uses exec subprocess like PS1 version
- Unknown integration error lists available integrations dynamically

* fix: align path rewriting with release script, add .specify/.specify/ fix

Path rewrite regex matches the release script's rewrite_paths()
exactly (verified byte-identical output). Added .specify/.specify/
double-prefix fix for additional safety.
2026-03-31 17:40:32 -05:00

170 lines
6.1 KiB
Python

"""Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives."""
import pytest
from specify_cli.integrations.base import (
IntegrationBase,
IntegrationOption,
MarkdownIntegration,
)
from specify_cli.integrations.manifest import IntegrationManifest
from .conftest import StubIntegration
class TestIntegrationOption:
def test_defaults(self):
opt = IntegrationOption(name="--flag")
assert opt.name == "--flag"
assert opt.is_flag is False
assert opt.required is False
assert opt.default is None
assert opt.help == ""
def test_flag_option(self):
opt = IntegrationOption(name="--skills", is_flag=True, default=True, help="Enable skills")
assert opt.is_flag is True
assert opt.default is True
assert opt.help == "Enable skills"
def test_required_option(self):
opt = IntegrationOption(name="--commands-dir", required=True, help="Dir path")
assert opt.required is True
def test_frozen(self):
opt = IntegrationOption(name="--x")
with pytest.raises(AttributeError):
opt.name = "--y" # type: ignore[misc]
class TestIntegrationBase:
def test_key_and_config(self):
i = StubIntegration()
assert i.key == "stub"
assert i.config["name"] == "Stub Agent"
assert i.registrar_config["format"] == "markdown"
assert i.context_file == "STUB.md"
def test_options_default_empty(self):
assert StubIntegration.options() == []
def test_shared_commands_dir(self):
i = StubIntegration()
cmd_dir = i.shared_commands_dir()
assert cmd_dir is not None
assert cmd_dir.is_dir()
def test_setup_uses_shared_templates(self, tmp_path):
i = StubIntegration()
manifest = IntegrationManifest("stub", tmp_path)
created = i.setup(tmp_path, manifest)
assert len(created) > 0
for f in created:
assert f.parent == tmp_path / ".stub" / "commands"
assert f.name.startswith("speckit.")
assert f.name.endswith(".md")
def test_setup_copies_templates(self, tmp_path, monkeypatch):
tpl = tmp_path / "_templates"
tpl.mkdir()
(tpl / "plan.md").write_text("plan content", encoding="utf-8")
(tpl / "specify.md").write_text("spec content", encoding="utf-8")
i = StubIntegration()
monkeypatch.setattr(type(i), "list_command_templates", lambda self: sorted(tpl.glob("*.md")))
project = tmp_path / "project"
project.mkdir()
created = i.setup(project, IntegrationManifest("stub", project))
assert len(created) == 2
assert (project / ".stub" / "commands" / "speckit.plan.md").exists()
assert (project / ".stub" / "commands" / "speckit.specify.md").exists()
def test_install_delegates_to_setup(self, tmp_path):
i = StubIntegration()
manifest = IntegrationManifest("stub", tmp_path)
result = i.install(tmp_path, manifest)
assert len(result) > 0
def test_uninstall_delegates_to_teardown(self, tmp_path):
i = StubIntegration()
manifest = IntegrationManifest("stub", tmp_path)
removed, skipped = i.uninstall(tmp_path, manifest)
assert removed == []
assert skipped == []
class TestMarkdownIntegration:
def test_is_subclass_of_base(self):
assert issubclass(MarkdownIntegration, IntegrationBase)
def test_stub_is_markdown(self):
assert isinstance(StubIntegration(), MarkdownIntegration)
class TestBasePrimitives:
def test_shared_commands_dir_returns_path(self):
i = StubIntegration()
cmd_dir = i.shared_commands_dir()
assert cmd_dir is not None
assert cmd_dir.is_dir()
def test_shared_templates_dir_returns_path(self):
i = StubIntegration()
tpl_dir = i.shared_templates_dir()
assert tpl_dir is not None
assert tpl_dir.is_dir()
def test_list_command_templates_returns_md_files(self):
i = StubIntegration()
templates = i.list_command_templates()
assert len(templates) > 0
assert all(t.suffix == ".md" for t in templates)
def test_command_filename_default(self):
i = StubIntegration()
assert i.command_filename("plan") == "speckit.plan.md"
def test_commands_dest(self, tmp_path):
i = StubIntegration()
dest = i.commands_dest(tmp_path)
assert dest == tmp_path / ".stub" / "commands"
def test_commands_dest_no_config_raises(self, tmp_path):
class NoConfig(MarkdownIntegration):
key = "noconfig"
with pytest.raises(ValueError, match="config is not set"):
NoConfig().commands_dest(tmp_path)
def test_copy_command_to_directory(self, tmp_path):
src = tmp_path / "source.md"
src.write_text("content", encoding="utf-8")
dest_dir = tmp_path / "output"
result = IntegrationBase.copy_command_to_directory(src, dest_dir, "speckit.plan.md")
assert result == dest_dir / "speckit.plan.md"
assert result.read_text(encoding="utf-8") == "content"
def test_record_file_in_manifest(self, tmp_path):
f = tmp_path / "f.txt"
f.write_text("hello", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
IntegrationBase.record_file_in_manifest(f, tmp_path, m)
assert "f.txt" in m.files
def test_write_file_and_record(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
dest = tmp_path / "sub" / "f.txt"
result = IntegrationBase.write_file_and_record("content", dest, tmp_path, m)
assert result == dest
assert dest.read_text(encoding="utf-8") == "content"
assert "sub/f.txt" in m.files
def test_setup_copies_shared_templates(self, tmp_path):
i = StubIntegration()
m = IntegrationManifest("stub", tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
for f in created:
assert f.parent.name == "commands"
assert f.name.startswith("speckit.")
assert f.name.endswith(".md")