|
| 1 | +"""Integration-with-fixtures tests for the upstream build pipeline. |
| 2 | +
|
| 3 | +These tests exercise the full MarketplaceBuilder end-to-end with a |
| 4 | +pre-populated UpstreamCache (no network calls) to validate: |
| 5 | +
|
| 6 | +1. A mixed direct+upstream apm.yml produces the expected |
| 7 | + marketplace.json and apm.lock.yaml entries. |
| 8 | +2. Rebuilding with BuildOptions(offline=True) from the populated lockfile |
| 9 | + produces byte-identical output (reproducibility invariant). |
| 10 | +
|
| 11 | +No real git ls-remote or HTTP calls are made. Both the direct package |
| 12 | +and the upstream plugin use pinned SHA refs (no tag resolution needed); |
| 13 | +the upstream manifest is served from a fixture dict injected into |
| 14 | +UpstreamCache before the build. |
| 15 | +""" |
| 16 | + |
| 17 | +from __future__ import annotations |
| 18 | + |
| 19 | +import json |
| 20 | +from pathlib import Path |
| 21 | + |
| 22 | +import yaml |
| 23 | + |
| 24 | +from apm_cli.marketplace.builder import BuildOptions, MarketplaceBuilder |
| 25 | +from apm_cli.marketplace.upstream_cache import UpstreamCache, UpstreamCacheKey, compute_cache_key |
| 26 | + |
| 27 | +# --------------------------------------------------------------------------- |
| 28 | +# Fixtures |
| 29 | +# --------------------------------------------------------------------------- |
| 30 | + |
| 31 | +_SHA_MANIFEST = "a" * 40 |
| 32 | +_SHA_PLUGIN = "b" * 40 |
| 33 | +_SHA_DIRECT = "c" * 40 |
| 34 | + |
| 35 | +_UPSTREAM_MANIFEST: dict = { |
| 36 | + "name": "fixture-upstream", |
| 37 | + "owner": {"name": "fixture-org"}, |
| 38 | + "plugins": [ |
| 39 | + { |
| 40 | + "name": "upstream-plugin", |
| 41 | + "description": "A plugin from the upstream fixture.", |
| 42 | + "source": { |
| 43 | + "type": "github", |
| 44 | + "repo": "fixture-org/upstream-plugin", |
| 45 | + "ref": "main", |
| 46 | + "sha": _SHA_PLUGIN, |
| 47 | + }, |
| 48 | + } |
| 49 | + ], |
| 50 | +} |
| 51 | + |
| 52 | +_MIXED_YML = f"""\ |
| 53 | +name: fixture-marketplace |
| 54 | +description: Integration fixture with direct + upstream packages |
| 55 | +version: 1.0.0 |
| 56 | +marketplace: |
| 57 | + owner: |
| 58 | + name: Fixture Org |
| 59 | + email: fixture@example.com |
| 60 | + url: https://example.com |
| 61 | + metadata: |
| 62 | + pluginRoot: plugins |
| 63 | + category: testing |
| 64 | + upstreams: |
| 65 | + - alias: fixture-upstream |
| 66 | + repo: fixture-org/fixture-upstream |
| 67 | + path: .apm/marketplace.json |
| 68 | + ref: {_SHA_MANIFEST} |
| 69 | + packages: |
| 70 | + - name: direct-pkg |
| 71 | + description: A direct package. |
| 72 | + source: fixture-org/direct-pkg |
| 73 | + ref: {_SHA_DIRECT} |
| 74 | + - name: upstream-pkg |
| 75 | + description: Curated from upstream. |
| 76 | + upstream: fixture-upstream |
| 77 | + plugin: upstream-plugin |
| 78 | +""" |
| 79 | + |
| 80 | + |
| 81 | +def _write_yml(tmp_path: Path, content: str) -> Path: |
| 82 | + p = tmp_path / "apm.yml" |
| 83 | + p.write_text(content, encoding="utf-8") |
| 84 | + return p |
| 85 | + |
| 86 | + |
| 87 | +def _pre_populate_cache(cache_dir: Path, manifest: dict) -> None: |
| 88 | + """Write the fixture manifest into UpstreamCache so no network fetch |
| 89 | + is needed during the build.""" |
| 90 | + cache = UpstreamCache(base_dir=cache_dir, fetch_callback=lambda k, a: manifest) |
| 91 | + key: UpstreamCacheKey = compute_cache_key( |
| 92 | + host="github.com", |
| 93 | + owner="fixture-org", |
| 94 | + repo="fixture-upstream", |
| 95 | + sha=_SHA_MANIFEST, |
| 96 | + path=".apm/marketplace.json", |
| 97 | + ) |
| 98 | + cache.put(key, manifest) |
| 99 | + |
| 100 | + |
| 101 | +def _make_builder(yml_path: Path, cache_dir: Path, *, offline: bool = False) -> MarketplaceBuilder: |
| 102 | + """Build a MarketplaceBuilder with the upstream cache injected.""" |
| 103 | + opts = BuildOptions(dry_run=False, offline=offline) |
| 104 | + builder = MarketplaceBuilder(yml_path, options=opts) |
| 105 | + # Inject the pre-populated cache so upstream resolution never hits the |
| 106 | + # network. We replace the factory's ``build()`` return value. |
| 107 | + upstream_manifest = _UPSTREAM_MANIFEST |
| 108 | + |
| 109 | + original_build_resolver = builder._build_upstream_resolver |
| 110 | + |
| 111 | + def _patched_build_resolver(yml): |
| 112 | + resolver = original_build_resolver(yml) |
| 113 | + # Swap the cache to one backed by our fixture directory. |
| 114 | + resolver._cache = UpstreamCache( |
| 115 | + base_dir=cache_dir, |
| 116 | + fetch_callback=lambda k, a: upstream_manifest, |
| 117 | + ) |
| 118 | + return resolver |
| 119 | + |
| 120 | + builder._build_upstream_resolver = _patched_build_resolver |
| 121 | + return builder |
| 122 | + |
| 123 | + |
| 124 | +# --------------------------------------------------------------------------- |
| 125 | +# Helpers |
| 126 | +# --------------------------------------------------------------------------- |
| 127 | + |
| 128 | + |
| 129 | +# --------------------------------------------------------------------------- |
| 130 | +# Tests |
| 131 | +# --------------------------------------------------------------------------- |
| 132 | + |
| 133 | + |
| 134 | +class TestUpstreamBuildIntegration: |
| 135 | + """Full-pipeline integration tests for upstream resolution.""" |
| 136 | + |
| 137 | + def test_mixed_build_emits_both_entries(self, tmp_path: Path) -> None: |
| 138 | + """A mixed direct+upstream apm.yml must produce a marketplace.json |
| 139 | + that contains BOTH plugin entries, with no ``metadata.apm.*`` keys |
| 140 | + injected.""" |
| 141 | + cache_dir = tmp_path / "cache" |
| 142 | + cache_dir.mkdir() |
| 143 | + _pre_populate_cache(cache_dir, _UPSTREAM_MANIFEST) |
| 144 | + yml_path = _write_yml(tmp_path, _MIXED_YML) |
| 145 | + |
| 146 | + builder = _make_builder(yml_path, cache_dir) |
| 147 | + report = builder.build() |
| 148 | + |
| 149 | + assert not report.errors, f"Build had errors: {report.errors}" |
| 150 | + |
| 151 | + out_path = tmp_path / ".claude-plugin" / "marketplace.json" |
| 152 | + assert out_path.exists(), "marketplace.json was not produced" |
| 153 | + |
| 154 | + marketplace = json.loads(out_path.read_text(encoding="utf-8")) |
| 155 | + plugins = marketplace.get("plugins", []) |
| 156 | + plugin_names = {p["name"] for p in plugins} |
| 157 | + |
| 158 | + assert "direct-pkg" in plugin_names, f"direct-pkg missing from {plugin_names}" |
| 159 | + assert "upstream-pkg" in plugin_names, f"upstream-pkg missing from {plugin_names}" |
| 160 | + |
| 161 | + # No APM-specific metadata keys in the emitted JSON (pass-through |
| 162 | + # contract: emitted marketplace.json must be Anthropic-conformant). |
| 163 | + raw_text = out_path.read_text(encoding="utf-8") |
| 164 | + assert "apm" not in raw_text.split('"metadata"', 1)[-1].split('"plugins"')[0], ( |
| 165 | + "APM-specific metadata keys found in emitted marketplace.json" |
| 166 | + ) |
| 167 | + |
| 168 | + def test_lockfile_records_upstream_provenance(self, tmp_path: Path) -> None: |
| 169 | + """After a successful build the lockfile must record upstream |
| 170 | + provenance: manifest_sha and the resolved plugin sha.""" |
| 171 | + cache_dir = tmp_path / "cache" |
| 172 | + cache_dir.mkdir() |
| 173 | + _pre_populate_cache(cache_dir, _UPSTREAM_MANIFEST) |
| 174 | + yml_path = _write_yml(tmp_path, _MIXED_YML) |
| 175 | + |
| 176 | + builder = _make_builder(yml_path, cache_dir) |
| 177 | + builder.build() |
| 178 | + |
| 179 | + lock_path = tmp_path / "apm.lock.yaml" |
| 180 | + assert lock_path.exists(), "apm.lock.yaml was not produced" |
| 181 | + |
| 182 | + lock = yaml.safe_load(lock_path.read_text(encoding="utf-8")) |
| 183 | + upstreams = lock.get("upstreams", {}) |
| 184 | + assert "fixture-upstream" in upstreams, ( |
| 185 | + f"upstream alias not in lockfile. Keys: {list(upstreams)}" |
| 186 | + ) |
| 187 | + alias_entry = upstreams["fixture-upstream"] |
| 188 | + assert alias_entry.get("manifest_sha") == _SHA_MANIFEST, ( |
| 189 | + f"manifest_sha mismatch: {alias_entry.get('manifest_sha')}" |
| 190 | + ) |
| 191 | + plugins_lock = alias_entry.get("plugins", {}) |
| 192 | + assert "upstream-plugin" in plugins_lock, ( |
| 193 | + f"upstream-plugin not in lockfile plugins: {list(plugins_lock)}" |
| 194 | + ) |
| 195 | + assert plugins_lock["upstream-plugin"].get("resolved_sha") == _SHA_PLUGIN, ( |
| 196 | + f"resolved_sha mismatch: {plugins_lock['upstream-plugin'].get('resolved_sha')}" |
| 197 | + ) |
| 198 | + |
| 199 | + def test_offline_rebuild_is_byte_identical(self, tmp_path: Path) -> None: |
| 200 | + """Rebuilding with offline=True after an initial build must produce |
| 201 | + byte-identical marketplace.json output (reproducibility invariant). |
| 202 | +
|
| 203 | + The initial build populates apm.lock.yaml with pinned SHAs. The |
| 204 | + offline rebuild reads those SHAs from the lock instead of calling |
| 205 | + the network, and must produce the same emitted JSON.""" |
| 206 | + cache_dir = tmp_path / "cache" |
| 207 | + cache_dir.mkdir() |
| 208 | + _pre_populate_cache(cache_dir, _UPSTREAM_MANIFEST) |
| 209 | + yml_path = _write_yml(tmp_path, _MIXED_YML) |
| 210 | + |
| 211 | + # ---- first build ------------------------------------------------------- |
| 212 | + builder = _make_builder(yml_path, cache_dir) |
| 213 | + report1 = builder.build() |
| 214 | + |
| 215 | + assert not report1.errors, f"First build had errors: {report1.errors}" |
| 216 | + out_path = tmp_path / ".claude-plugin" / "marketplace.json" |
| 217 | + first_output = out_path.read_bytes() |
| 218 | + |
| 219 | + # ---- offline rebuild --------------------------------------------------- |
| 220 | + # Both direct (pinned SHA) and upstream (locked manifest SHA) bypass |
| 221 | + # the network entirely in offline mode. |
| 222 | + builder2 = _make_builder(yml_path, cache_dir, offline=True) |
| 223 | + report2 = builder2.build() |
| 224 | + assert not report2.errors, f"Offline rebuild had errors: {report2.errors}" |
| 225 | + |
| 226 | + second_output = out_path.read_bytes() |
| 227 | + assert first_output == second_output, ( |
| 228 | + "Offline rebuild produced different marketplace.json output.\n" |
| 229 | + f"First: {first_output[:200]}\n" |
| 230 | + f"Second: {second_output[:200]}" |
| 231 | + ) |
0 commit comments