mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
* Fix bundle-update-force-mislead: add refresh() to DefaultPrimitiveInstaller Apply the remediation from the bug assessment on issue #3424. DefaultPrimitiveInstaller lacked a refresh() method, causing _refresh_component() to fall back to install(), which calls ExtensionManager.install_from_directory() with force=False. This raised ExtensionError with a leaked --force hint that bundle update does not support, leaving users with no valid recovery path. Fix: add refresh() to each kind manager (ExtensionKindManager and PresetKindManager delegate to _do_install(force=True); WorkflowKindManager and StepKindManager delegate to install() as their callables are idempotent). DefaultPrimitiveInstaller.refresh() dispatches to the kind manager's refresh(). PresetManager.install_from_directory() and install_from_zip() gain a force parameter that removes the existing preset before reinstalling, mirroring ExtensionManager's force semantics. Refs #3424 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback on primitives.py and test_bundler_primitives.py - Replace ... with pass in _KindManager Protocol method stubs - Conditionally pass force= keyword only when force=True in _PresetKindManager - Fix _StepKindManager.refresh() to remove step before re-installing - Rename test to reflect actual assertion (refresh succeeds + force=True) - Remove duplicate install_bundle import Assisted-by: GitHub Copilot (model: claude-sonnet-4.5, autonomous) * fix: add missing role/effective_integration to InstallPlan in _plan() and remove redundant import - Remove duplicate `DefaultPrimitiveInstaller` import inside test body (already imported at module scope on line 15) - Add required `role` and `effective_integration` fields to `InstallPlan` constructor in `_plan()` helper to prevent TypeError at runtime Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * fix: address latest PR review comments Assisted-by: GitHub Copilot (model: gpt-5.6-terra, autonomous) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
337 lines
14 KiB
Python
337 lines
14 KiB
Python
"""Unit tests for the primitive-dispatch bridge (T044).
|
|
|
|
Covers routing, offline gating, and the network-aware ``DefaultPrimitiveInstaller``
|
|
seam — without touching real catalogs or the network (Constitution Principle II,
|
|
offline-first).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from specify_cli.bundler import BundlerError
|
|
from specify_cli.bundler.models.manifest import ComponentRef
|
|
from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller
|
|
from specify_cli.bundler.services.primitives import (
|
|
_ExtensionKindManager,
|
|
_PresetKindManager,
|
|
_StepKindManager,
|
|
_WorkflowKindManager,
|
|
primitive_manager,
|
|
)
|
|
from tests.bundler_helpers import valid_manifest_dict
|
|
|
|
|
|
def _component(kind: str, cid: str = "x") -> ComponentRef:
|
|
return ComponentRef(kind=kind, id=cid)
|
|
|
|
|
|
def test_primitive_manager_routes_each_kind(tmp_path: Path):
|
|
assert isinstance(primitive_manager("presets", tmp_path), _PresetKindManager)
|
|
assert isinstance(primitive_manager("extensions", tmp_path), _ExtensionKindManager)
|
|
assert isinstance(primitive_manager("workflows", tmp_path), _WorkflowKindManager)
|
|
assert isinstance(primitive_manager("steps", tmp_path), _StepKindManager)
|
|
|
|
|
|
def test_primitive_manager_rejects_unknown_kind(tmp_path: Path):
|
|
with pytest.raises(BundlerError, match="Unknown component kind"):
|
|
primitive_manager("bogus", tmp_path)
|
|
|
|
|
|
def test_offline_preset_not_bundled_refuses(tmp_path: Path):
|
|
manager = primitive_manager("presets", tmp_path, allow_network=False)
|
|
with pytest.raises(BundlerError, match="network access is disabled"):
|
|
manager.install(_component("presets", "definitely-not-bundled"))
|
|
|
|
|
|
def test_offline_extension_not_bundled_refuses(tmp_path: Path):
|
|
manager = primitive_manager("extensions", tmp_path, allow_network=False)
|
|
with pytest.raises(BundlerError, match="network access is disabled"):
|
|
manager.install(_component("extensions", "definitely-not-bundled"))
|
|
|
|
|
|
def test_offline_workflow_refuses_without_network(tmp_path: Path):
|
|
manager = primitive_manager("workflows", tmp_path, allow_network=False)
|
|
with pytest.raises(BundlerError, match="network access is disabled"):
|
|
manager.install(_component("workflows"))
|
|
|
|
|
|
def test_offline_step_refuses_without_network(tmp_path: Path):
|
|
manager = primitive_manager("steps", tmp_path, allow_network=False)
|
|
with pytest.raises(BundlerError, match="network access is disabled"):
|
|
manager.install(_component("steps"))
|
|
|
|
|
|
def test_default_installer_threads_allow_network(tmp_path: Path):
|
|
installer = DefaultPrimitiveInstaller(allow_network=False)
|
|
with pytest.raises(BundlerError, match="network access is disabled"):
|
|
installer.install(tmp_path, _component("workflows"))
|
|
|
|
|
|
def test_offline_workflow_allows_bundled(tmp_path: Path, monkeypatch):
|
|
# A workflow that ships with Spec Kit must install even with --offline.
|
|
import specify_cli
|
|
import specify_cli._assets as assets
|
|
|
|
monkeypatch.setattr(
|
|
assets, "_locate_bundled_workflow", lambda wid: tmp_path / "wf"
|
|
)
|
|
calls: list[str] = []
|
|
monkeypatch.setattr(specify_cli, "workflow_add", lambda wid: calls.append(wid))
|
|
|
|
manager = primitive_manager("workflows", tmp_path, allow_network=False)
|
|
manager.install(_component("workflows", "bundled-wf"))
|
|
|
|
assert calls == ["bundled-wf"]
|
|
|
|
|
|
def test_assert_pinned_version_matches_passes():
|
|
from specify_cli.bundler.services.primitives import _assert_pinned_version
|
|
|
|
# Equal (including v-prefix/normalization) is accepted; no version pins are no-ops.
|
|
_assert_pinned_version("Preset", "p", "2.0.0", "2.0.0")
|
|
_assert_pinned_version("Preset", "p", "2.0.0", "v2.0.0")
|
|
_assert_pinned_version("Preset", "p", None, "9.9.9")
|
|
_assert_pinned_version("Preset", "p", "2.0.0", None)
|
|
|
|
|
|
def test_assert_pinned_version_mismatch_raises():
|
|
from specify_cli.bundler.services.primitives import _assert_pinned_version
|
|
|
|
with pytest.raises(BundlerError, match="pinned to version 2.0.0"):
|
|
_assert_pinned_version("Preset", "preset-a", "2.0.0", "3.1.0")
|
|
|
|
|
|
def test_workflow_version_mismatch_refuses(tmp_path: Path, monkeypatch):
|
|
from specify_cli.workflows.catalog import WorkflowCatalog
|
|
|
|
monkeypatch.setattr(
|
|
WorkflowCatalog, "get_workflow_info", lambda self, wid: {"version": "9.9.9"}
|
|
)
|
|
manager = primitive_manager("workflows", tmp_path, allow_network=True)
|
|
component = ComponentRef(kind="workflows", id="wf-a", version="0.3.0")
|
|
with pytest.raises(BundlerError, match="pinned to version 0.3.0"):
|
|
manager.install(component)
|
|
|
|
|
|
def test_preset_install_preserves_explicit_zero_priority(tmp_path: Path, monkeypatch):
|
|
import specify_cli._assets as assets
|
|
|
|
calls = {}
|
|
|
|
class _FakeManager:
|
|
def install_from_directory(self, directory, speckit_version, priority):
|
|
calls["priority"] = priority
|
|
|
|
monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: tmp_path)
|
|
|
|
manager = primitive_manager("presets", tmp_path, allow_network=False)
|
|
manager._manager = _FakeManager()
|
|
manager.install(ComponentRef(kind="presets", id="p", priority=0))
|
|
|
|
# An explicit priority of 0 must be passed through, not replaced by default.
|
|
assert calls["priority"] == 0
|
|
|
|
|
|
def _write_manifest(path: Path, root_key: str, version: str) -> Path:
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
(path / f"{root_key}.yml").write_text(
|
|
f"{root_key}:\n id: x\n version: {version}\n", encoding="utf-8"
|
|
)
|
|
return path
|
|
|
|
|
|
def test_bundled_extension_pin_mismatch_refuses(tmp_path: Path, monkeypatch):
|
|
"""A bundled extension whose version != the manifest pin must be refused
|
|
(the bundled path previously skipped the pin the catalog path enforces)."""
|
|
import specify_cli._assets as assets
|
|
from specify_cli.extensions import ExtensionManager
|
|
|
|
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
|
|
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
|
|
called: list = []
|
|
monkeypatch.setattr(
|
|
ExtensionManager, "install_from_directory",
|
|
lambda self, *a, **k: called.append(a),
|
|
)
|
|
|
|
manager = primitive_manager("extensions", tmp_path, allow_network=False)
|
|
with pytest.raises(BundlerError, match="pinned to version 2.0.0"):
|
|
manager.install(ComponentRef(kind="extensions", id="my-ext", version="2.0.0"))
|
|
assert called == [] # install must not proceed
|
|
|
|
|
|
def test_bundled_extension_pin_match_installs(tmp_path: Path, monkeypatch):
|
|
import specify_cli._assets as assets
|
|
from specify_cli.extensions import ExtensionManager
|
|
|
|
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
|
|
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
|
|
called: list = []
|
|
monkeypatch.setattr(
|
|
ExtensionManager, "install_from_directory",
|
|
lambda self, *a, **k: called.append(a),
|
|
)
|
|
|
|
manager = primitive_manager("extensions", tmp_path, allow_network=False)
|
|
# matching pin, and unpinned, both install cleanly
|
|
manager.install(ComponentRef(kind="extensions", id="my-ext", version="1.0.0"))
|
|
manager.install(ComponentRef(kind="extensions", id="my-ext", version=None))
|
|
assert len(called) == 2
|
|
|
|
|
|
def test_bundled_preset_pin_mismatch_refuses(tmp_path: Path, monkeypatch):
|
|
import specify_cli._assets as assets
|
|
from specify_cli.presets import PresetManager
|
|
|
|
bundled = _write_manifest(tmp_path / "preset", "preset", "1.0.0")
|
|
monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: bundled)
|
|
called: list = []
|
|
monkeypatch.setattr(
|
|
PresetManager, "install_from_directory",
|
|
lambda self, *a, **k: called.append(a),
|
|
)
|
|
|
|
manager = primitive_manager("presets", tmp_path, allow_network=False)
|
|
with pytest.raises(BundlerError, match="pinned to version 2.0.0"):
|
|
manager.install(ComponentRef(kind="presets", id="my-preset", version="2.0.0"))
|
|
assert called == []
|
|
|
|
|
|
def test_bundled_preset_pin_match_installs(tmp_path: Path, monkeypatch):
|
|
import specify_cli._assets as assets
|
|
from specify_cli.presets import PresetManager
|
|
|
|
bundled = _write_manifest(tmp_path / "preset", "preset", "1.0.0")
|
|
monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: bundled)
|
|
called: list = []
|
|
monkeypatch.setattr(
|
|
PresetManager, "install_from_directory",
|
|
lambda self, *a, **k: called.append(a),
|
|
)
|
|
|
|
manager = primitive_manager("presets", tmp_path, allow_network=False)
|
|
# matching pin, and unpinned, both proceed to install
|
|
manager.install(ComponentRef(kind="presets", id="my-preset", version="1.0.0"))
|
|
manager.install(ComponentRef(kind="presets", id="my-preset", version=None))
|
|
assert len(called) == 2
|
|
|
|
|
|
def test_extension_refresh_calls_install_with_force(tmp_path: Path, monkeypatch):
|
|
"""_ExtensionKindManager.refresh() must pass force=True to install_from_directory
|
|
so an already-installed extension is overwritten instead of raising an error."""
|
|
import specify_cli._assets as assets
|
|
from specify_cli.extensions import ExtensionManager
|
|
|
|
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
|
|
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
|
|
force_values: list = []
|
|
monkeypatch.setattr(
|
|
ExtensionManager, "install_from_directory",
|
|
lambda self, *a, **k: force_values.append(k.get("force", False)),
|
|
)
|
|
|
|
manager = primitive_manager("extensions", tmp_path, allow_network=False)
|
|
manager.refresh(ComponentRef(kind="extensions", id="my-ext"))
|
|
assert force_values == [True], "refresh() must pass force=True"
|
|
|
|
|
|
def test_preset_refresh_calls_install_with_force(tmp_path: Path, monkeypatch):
|
|
"""_PresetKindManager.refresh() must pass force=True to install_from_directory
|
|
so an already-installed preset is overwritten instead of raising an error."""
|
|
import specify_cli._assets as assets
|
|
from specify_cli.presets import PresetManager
|
|
|
|
bundled = _write_manifest(tmp_path / "preset", "preset", "1.0.0")
|
|
monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: bundled)
|
|
force_values: list = []
|
|
monkeypatch.setattr(
|
|
PresetManager, "install_from_directory",
|
|
lambda self, *a, **k: force_values.append(k.get("force", False)),
|
|
)
|
|
|
|
manager = primitive_manager("presets", tmp_path, allow_network=False)
|
|
manager.refresh(ComponentRef(kind="presets", id="my-preset"))
|
|
assert force_values == [True], "refresh() must pass force=True"
|
|
|
|
|
|
def test_default_installer_refresh_dispatches_to_kind_manager(tmp_path: Path, monkeypatch):
|
|
"""DefaultPrimitiveInstaller.refresh() must call the kind manager's refresh(),
|
|
which is the hook _refresh_component() will find — fixing the --force leak."""
|
|
import specify_cli._assets as assets
|
|
from specify_cli.extensions import ExtensionManager
|
|
|
|
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
|
|
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
|
|
force_values: list = []
|
|
monkeypatch.setattr(
|
|
ExtensionManager, "install_from_directory",
|
|
lambda self, *a, **k: force_values.append(k.get("force", False)),
|
|
)
|
|
|
|
installer = DefaultPrimitiveInstaller(allow_network=False)
|
|
installer.refresh(tmp_path, _component("extensions", "my-ext"))
|
|
assert force_values == [True], "DefaultPrimitiveInstaller.refresh() must use force=True"
|
|
|
|
|
|
def test_refresh_succeeds_and_passes_force_true(tmp_path: Path, monkeypatch):
|
|
"""Regression: bundle update (refresh=True) of an already-installed extension
|
|
must succeed and pass force=True to install_from_directory."""
|
|
from specify_cli.bundler.services.installer import install_bundle
|
|
from specify_cli.bundler.models.manifest import BundleManifest
|
|
import specify_cli._assets as assets
|
|
from specify_cli.extensions import ExtensionManager
|
|
|
|
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
|
|
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
|
|
# Simulate refresh succeeding (force=True removes the duplicate-install guard)
|
|
force_seen: list = []
|
|
def _fake_install_from_directory(self, *a, **k):
|
|
force_seen.append(k.get("force", False))
|
|
self.registry.add("my-ext", {"version": "1.0.0"})
|
|
|
|
monkeypatch.setattr(
|
|
ExtensionManager, "install_from_directory", _fake_install_from_directory
|
|
)
|
|
|
|
raw = valid_manifest_dict(
|
|
bundle={
|
|
"id": "test-bundle",
|
|
"name": "Test",
|
|
"version": "1.0.0",
|
|
"role": "developer",
|
|
"description": "Test bundle",
|
|
"author": "Spec Kit",
|
|
"license": "MIT",
|
|
},
|
|
provides={
|
|
"extensions": [{"id": "my-ext", "version": "1.0.0"}],
|
|
"presets": [],
|
|
"steps": [],
|
|
"workflows": [],
|
|
},
|
|
)
|
|
manifest = BundleManifest.from_dict(raw)
|
|
installer = DefaultPrimitiveInstaller(allow_network=False)
|
|
# First install
|
|
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
|
# Refresh (bundle update) — must not raise with --force hint
|
|
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True)
|
|
# force=True must have been passed during the refresh call
|
|
assert True in force_seen, "refresh path should have called install_from_directory with force=True"
|
|
|
|
|
|
def _plan(manifest):
|
|
from specify_cli.bundler.services.installer import InstallPlan
|
|
from specify_cli.bundler.models.manifest import ComponentRef as CR
|
|
|
|
components = [CR(kind=c.kind, id=c.id) for c in manifest.components]
|
|
return InstallPlan(
|
|
bundle_id=manifest.bundle.id,
|
|
version=manifest.bundle.version,
|
|
role=manifest.bundle.role,
|
|
effective_integration=None,
|
|
components=components,
|
|
)
|