mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 14:39:00 +08:00
test: reduce registry manifest test repetition (#3146)
* test: isolate integration test home Assisted-by: Codex (model: GPT-5, autonomous) * test: reduce registry manifest test repetition Assisted-by: Codex (model: GPT-5, autonomous) * test: clarify disjoint-manifest order rationale and guard safe set Add a >=2 precondition, explain why two install orders are tested (manifests are order-independent; the orders only vary the init path), and build the manifest map with a comprehension. * test: rotate init coverage for manifest isolation Assisted-by: Codex (model: GPT-5, autonomous) * test: assert integration home isolation Assisted-by: Codex (model: GPT-5, autonomous) * test: guard multi-install manifest rotations Assisted-by: Codex (model: GPT-5, autonomous)
This commit is contained in:
@@ -1,8 +1,24 @@
|
||||
"""Shared test helpers for integration tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import MarkdownIntegration
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||
"""Keep integration tests from reading or writing the real user home."""
|
||||
home = tmp_path / "home"
|
||||
for path in (home, home / ".cache", home / ".config", home / ".local" / "share"):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("USERPROFILE", str(home))
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(home / ".cache"))
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config"))
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share"))
|
||||
|
||||
|
||||
class StubIntegration(MarkdownIntegration):
|
||||
"""Minimal concrete integration for testing."""
|
||||
|
||||
|
||||
21
tests/integrations/test_home_isolation.py
Normal file
21
tests/integrations/test_home_isolation.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Regression tests for integration-test environment isolation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_integration_tests_use_tmp_home(tmp_path: Path) -> None:
|
||||
home = tmp_path / "home"
|
||||
|
||||
assert Path(os.environ["HOME"]) == home
|
||||
assert Path(os.environ["USERPROFILE"]) == home
|
||||
assert Path(os.environ["XDG_CACHE_HOME"]) == home / ".cache"
|
||||
assert Path(os.environ["XDG_CONFIG_HOME"]) == home / ".config"
|
||||
assert Path(os.environ["XDG_DATA_HOME"]) == home / ".local" / "share"
|
||||
|
||||
assert home.is_dir()
|
||||
assert (home / ".cache").is_dir()
|
||||
assert (home / ".config").is_dir()
|
||||
assert (home / ".local" / "share").is_dir()
|
||||
@@ -48,6 +48,19 @@ def _multi_install_safe_pairs() -> list[tuple[str, str]]:
|
||||
]
|
||||
|
||||
|
||||
def _multi_install_safe_orders() -> list[list[str]]:
|
||||
safe_keys = _multi_install_safe_keys()
|
||||
if len(safe_keys) < 2:
|
||||
return [safe_keys]
|
||||
return [safe_keys[index:] + safe_keys[:index] for index in range(len(safe_keys))]
|
||||
|
||||
|
||||
def _multi_install_safe_order_id(ordered_keys: list[str]) -> str:
|
||||
if not ordered_keys:
|
||||
return "no-safe-integrations"
|
||||
return f"init-{ordered_keys[0]}"
|
||||
|
||||
|
||||
def _posix_path(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
@@ -87,16 +100,6 @@ def _paths_overlap(first: str | None, second: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _path_is_inside(path: str | None, directory: str | None) -> bool:
|
||||
if not path or not directory:
|
||||
return False
|
||||
try:
|
||||
PurePosixPath(path).relative_to(PurePosixPath(directory))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
def test_registry_is_dict(self):
|
||||
assert isinstance(INTEGRATION_REGISTRY, dict)
|
||||
@@ -162,6 +165,15 @@ class TestRegistrarKeyAlignment:
|
||||
class TestMultiInstallSafeContracts:
|
||||
"""Declared safe integrations must stay isolated from each other."""
|
||||
|
||||
def test_safe_install_orders_rotate_each_integration_through_init(self):
|
||||
safe_keys = _multi_install_safe_keys()
|
||||
orders = _multi_install_safe_orders()
|
||||
|
||||
assert len(safe_keys) >= 2
|
||||
assert [order[0] for order in orders] == safe_keys
|
||||
assert len({tuple(order) for order in orders}) == len(safe_keys)
|
||||
assert all(sorted(order) == safe_keys for order in orders)
|
||||
|
||||
@pytest.mark.parametrize("key", _multi_install_safe_keys())
|
||||
def test_safe_integrations_have_static_isolated_paths(self, key):
|
||||
assert _integration_root_dir(key), (
|
||||
@@ -187,62 +199,77 @@ class TestMultiInstallSafeContracts:
|
||||
f"{_integration_commands_dir(second)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
@pytest.mark.parametrize(
|
||||
"ordered_keys",
|
||||
_multi_install_safe_orders(),
|
||||
ids=_multi_install_safe_order_id,
|
||||
)
|
||||
def test_safe_integrations_have_disjoint_manifests(
|
||||
self,
|
||||
tmp_path,
|
||||
first,
|
||||
second,
|
||||
ordered_keys,
|
||||
):
|
||||
for initial, additional in ((first, second), (second, first)):
|
||||
project_root = tmp_path / f"project-{initial}-{additional}"
|
||||
project_root.mkdir()
|
||||
runner = CliRunner()
|
||||
# The pairwise disjointness contract is only meaningful with at least
|
||||
# two safe integrations. Guard so a shrunken registry fails loudly here
|
||||
# rather than passing vacuously (or tripping over ordered_keys[0] below).
|
||||
assert len(ordered_keys) >= 2, (
|
||||
f"expected at least two multi-install-safe integrations, got {ordered_keys}"
|
||||
)
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project_root)
|
||||
init_result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
initial,
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert init_result.exit_code == 0, init_result.output
|
||||
project_root = tmp_path / "project"
|
||||
project_root.mkdir()
|
||||
runner = CliRunner()
|
||||
|
||||
# Install every safe integration once into a single project, then assert
|
||||
# pairwise manifest isolation. Each safe integration writes only to its
|
||||
# own (disjoint) directories and always records what it writes, so a
|
||||
# manifest's contents are independent of install order and of which other
|
||||
# integrations are co-installed. The parametrized rotations keep the
|
||||
# aggregate setup while placing each safe integration first once, so each
|
||||
# one still exercises the `specify init --integration ...` path.
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project_root)
|
||||
init_result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
ordered_keys[0],
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert init_result.exit_code == 0, init_result.output
|
||||
|
||||
for key in ordered_keys[1:]:
|
||||
install_result = runner.invoke(
|
||||
app,
|
||||
["integration", "install", additional, "--script", "sh"],
|
||||
["integration", "install", key, "--script", "sh"],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert install_result.exit_code == 0, install_result.output
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
initial_manifest = json.loads(
|
||||
(
|
||||
project_root / ".specify" / "integrations" / f"{initial}.manifest.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
additional_manifest = json.loads(
|
||||
(
|
||||
project_root / ".specify" / "integrations" / f"{additional}.manifest.json"
|
||||
).read_text(encoding="utf-8")
|
||||
integrations_dir = project_root / ".specify" / "integrations"
|
||||
manifests = {}
|
||||
for key in ordered_keys:
|
||||
manifest = json.loads(
|
||||
(integrations_dir / f"{key}.manifest.json").read_text(encoding="utf-8")
|
||||
)
|
||||
files = manifest.get("files", {})
|
||||
assert isinstance(files, dict), f"{key} manifest files must be an object"
|
||||
manifests[key] = set(files.keys())
|
||||
|
||||
initial_files = set(initial_manifest.get("files", {}))
|
||||
additional_files = set(additional_manifest.get("files", {}))
|
||||
|
||||
assert initial_files.isdisjoint(additional_files), (
|
||||
f"{initial} and {additional} are declared multi-install safe but both manage "
|
||||
f"these files: {sorted(initial_files & additional_files)}"
|
||||
for first, second in _multi_install_safe_pairs():
|
||||
overlap = manifests[first] & manifests[second]
|
||||
assert not overlap, (
|
||||
f"{first} and {second} are declared multi-install safe but both manage "
|
||||
f"these files: {sorted(overlap)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user