Skip to content

Commit dbba333

Browse files
Sergio SisternesCopilot
andcommitted
test(marketplace): add integration-with-fixtures test for upstream build pipeline (A2)
Adds three end-to-end fixture tests for the upstream build pipeline without network calls: - test_mixed_build_emits_both_entries: verifies a direct+upstream apm.yml produces marketplace.json with both plugin entries and no metadata.apm.* keys (Anthropic-conformant pass-through contract). - test_lockfile_records_upstream_provenance: verifies apm.lock.yaml records manifest_sha and resolved plugin sha for the upstream alias. - test_offline_rebuild_is_byte_identical: verifies that an offline rebuild using the populated lockfile produces byte-identical output (reproducibility invariant). Both the direct package (pinned SHA ref) and upstream manifest (pre-populated UpstreamCache from fixture dict) bypass the network entirely, making these tests fast and deterministic. Also registers the new test file in scripts/test-integration.sh so it runs in CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d33cc26 commit dbba333

2 files changed

Lines changed: 241 additions & 0 deletions

File tree

scripts/test-integration.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,16 @@ run_e2e_tests() {
498498
exit 1
499499
fi
500500

501+
log_info "Running marketplace upstream build integration tests..."
502+
echo "Command: pytest tests/integration/marketplace/test_upstream_build_integration.py -v --tb=short"
503+
504+
if pytest tests/integration/marketplace/test_upstream_build_integration.py -v --tb=short; then
505+
log_success "Marketplace upstream build integration tests passed!"
506+
else
507+
log_error "Marketplace upstream build integration tests failed!"
508+
exit 1
509+
fi
510+
501511
log_success "All integration test suites completed successfully!"
502512

503513

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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

Comments
 (0)