From 3d391e9de067d2f1c1929b71d2c87df7f46aaeca Mon Sep 17 00:00:00 2001 From: ShaoLOWu <3034261766@qq.com> Date: Thu, 22 Oct 2026 20:08:38 +0800 Subject: [PATCH 1/2] perf(deps): sweep raw CDN across marker probes before any anonymous Contents API call Virtual-package validation on public github.com repos spent anonymous Contents API calls per marker file before discovering the layout, and `apm marketplace add` re-fetched the catalog immediately after the discovery probe had just downloaded it. On the default 60 req/h anonymous budget a single Claude-plugin-layout install could burn 4+ calls (validation + registration combined). - validate_virtual_package_exists: probe every marker path through the raw CDN first (zero API calls) before falling back to the existing per-marker Contents API probes; fallbacks and non-public hosts are unchanged - download_raw_file: new raw_only pass-through for CDN-only probes - marketplace add: reuse the catalog response captured during path discovery instead of re-fetching it (sidecar cache semantics kept) - tests: 12 new unit tests covering sweep ordering, API-spend assertions, fallback retention, and catalog reuse Fixes #3283 --- src/apm_cli/commands/marketplace/__init__.py | 16 +- src/apm_cli/deps/download_strategies.py | 26 ++- src/apm_cli/deps/github_downloader.py | 15 +- .../deps/github_downloader_validation.py | 43 +++- src/apm_cli/marketplace/client.py | 44 +++- .../deps/test_github_downloader_validation.py | 2 +- .../test_virtual_package_cdn_first_sweep.py | 201 ++++++++++++++++++ .../test_catalog_discovery_reuse.py | 114 ++++++++++ .../marketplace/test_marketplace_commands.py | 88 ++++---- 9 files changed, 490 insertions(+), 59 deletions(-) create mode 100644 tests/unit/deps/test_virtual_package_cdn_first_sweep.py create mode 100644 tests/unit/marketplace/test_catalog_discovery_reuse.py diff --git a/src/apm_cli/commands/marketplace/__init__.py b/src/apm_cli/commands/marketplace/__init__.py index ac22e5eb10..525c2eb724 100644 --- a/src/apm_cli/commands/marketplace/__init__.py +++ b/src/apm_cli/commands/marketplace/__init__.py @@ -338,7 +338,10 @@ def add(source, name, ref, branch, host, verbose): """ logger = CommandLogger("marketplace-add", verbose=verbose) try: - from ...marketplace.client import _auto_detect_path, fetch_marketplace + from ...marketplace.client import ( + _auto_detect_path_with_payload, + fetch_marketplace, + ) from ...marketplace.models import MarketplaceSource from ...marketplace.registry import add_marketplace from ...marketplace.source_identity import parse_marketplace_source @@ -443,8 +446,9 @@ def add(source, name, ref, branch, host, verbose): ) if is_direct_url or _local_source_points_to_file(probe_source): detected_path = "" + discovered_manifest = None else: - detected_path = _auto_detect_path(probe_source) + detected_path, discovered_manifest = _auto_detect_path_with_payload(probe_source) if detected_path is None: logger.error( @@ -461,7 +465,13 @@ def add(source, name, ref, branch, host, verbose): ref="" if is_direct_url else effective_ref, path=detected_path, ) - manifest = fetch_marketplace(fetch_source, force_refresh=True) + manifest = fetch_marketplace( + fetch_source, + force_refresh=True, + # Reuse the catalog response the probe above just fetched from + # the same url/ref/path instead of re-fetching it (apm#3283). + discovery_payload=discovered_manifest, + ) plugin_count = len(manifest.plugins) manifest_name = (manifest.name or "").strip() diff --git a/src/apm_cli/deps/download_strategies.py b/src/apm_cli/deps/download_strategies.py index d0424ce533..8ce834896b 100644 --- a/src/apm_cli/deps/download_strategies.py +++ b/src/apm_cli/deps/download_strategies.py @@ -1187,8 +1187,16 @@ def _download_public_github_file_anonymous_first( file_path: str, ref: str, verbose_callback=None, + raw_only: bool = False, ) -> bytes: - """Fetch one github.com file anonymously, resolving auth only on 4xx.""" + """Fetch one github.com file anonymously, resolving auth only on 4xx. + + With ``raw_only=True`` the Contents API fallback is skipped: the + helper reports a miss (``RuntimeError``) when the raw CDN does not + have the file under any candidate ref. This is the request-budget + seam used by virtual-package validation (microsoft/apm#3283) so a + CDN-only marker sweep never spends the shared anonymous REST quota. + """ host = dep_ref.host or default_host() owner_repo = repository_owner_and_repo(dep_ref.repo_url) if owner_repo is None: @@ -1205,6 +1213,15 @@ def _download_public_github_file_anonymous_first( verbose_callback(f"Downloaded file: {host}/{dep_ref.repo_url}/{file_path}") return content + if raw_only: + # Raw-CDN-only callers (virtual-package marker sweep) must not + # escalate a miss to the Contents API: that is the exact + # per-marker anonymous-quota waste this seam exists to avoid. + raise RuntimeError( + f"File {file_path} not found via raw CDN at ref {ref}" + f" (refs tried: {', '.join(refs)})" + ) + def _contents_request( token: str | None, _git_env: dict[str, str], @@ -1318,6 +1335,7 @@ def download_github_file( file_path: str, ref: str = "main", verbose_callback=None, + raw_only: bool = False, ) -> bytes: """Download a file from GitHub repository. @@ -1331,6 +1349,11 @@ def download_github_file( file_path: Path to file within the repository ref: Git reference (branch, tag, or commit SHA) verbose_callback: Optional callable for verbose logging + raw_only: Skip the Contents API fallback and report a miss via + ``RuntimeError`` when the raw CDN does not have the file. + Only honored on the public-github anonymous-first path (the + sole caller is virtual-package validation, microsoft/apm#3283); + all other paths keep their existing behavior. Returns: bytes: File content @@ -1350,6 +1373,7 @@ def download_github_file( file_path, ref, verbose_callback, + raw_only=raw_only, ) # Parse owner/repo from repo_url diff --git a/src/apm_cli/deps/github_downloader.py b/src/apm_cli/deps/github_downloader.py index 4746f21ac2..ce467c3ce7 100644 --- a/src/apm_cli/deps/github_downloader.py +++ b/src/apm_cli/deps/github_downloader.py @@ -825,7 +825,12 @@ def _resolve_commit_sha_for_ref(self, dep_ref: DependencyReference, ref: str) -> return self._refs.resolve_commit_sha_for_ref(dep_ref, ref) def download_raw_file( - self, dep_ref: DependencyReference, file_path: str, ref: str = "main", verbose_callback=None + self, + dep_ref: DependencyReference, + file_path: str, + ref: str = "main", + verbose_callback=None, + raw_only: bool = False, ) -> bytes: """Download a single file from repository (GitHub or Azure DevOps). @@ -834,6 +839,10 @@ def download_raw_file( file_path: Path to file within the repository (e.g., "prompts/code-review.prompt.md") ref: Git reference (branch, tag, or commit SHA). Defaults to "main" verbose_callback: Optional callable for verbose logging (receives str messages) + raw_only: Skip the Contents API fallback on the public-github + anonymous-first path and report a miss via ``RuntimeError`` + when the raw CDN does not have the file (microsoft/apm#3283 + virtual-package marker sweep). Ignored everywhere else. Returns: bytes: File content @@ -875,7 +884,7 @@ def download_raw_file( # GitHub API return self._download_github_file( - dep_ref, file_path, ref, verbose_callback=verbose_callback + dep_ref, file_path, ref, verbose_callback=verbose_callback, raw_only=raw_only ) def _download_ado_file( @@ -906,6 +915,7 @@ def _download_github_file( file_path: str, ref: str = "main", verbose_callback=None, + raw_only: bool = False, ) -> bytes: """Backward-compat stub -- delegates to backend-specific strategies.""" host = dep_ref.host or default_host() @@ -925,6 +935,7 @@ def _download_github_file( file_path, ref=ref, verbose_callback=verbose_callback, + raw_only=raw_only, ) def validate_virtual_package_exists( diff --git a/src/apm_cli/deps/github_downloader_validation.py b/src/apm_cli/deps/github_downloader_validation.py index c57cdbfcb6..98be4ddcfc 100644 --- a/src/apm_cli/deps/github_downloader_validation.py +++ b/src/apm_cli/deps/github_downloader_validation.py @@ -145,9 +145,9 @@ def _log(msg: str) -> None: if verbose_callback: verbose_callback(msg) - def _probe(path: str) -> bool: + def _probe(path: str, raw_only: bool = False) -> bool: try: - downloader.download_raw_file(dep_ref, path, ref) + downloader.download_raw_file(dep_ref, path, ref, raw_only=raw_only) _log(f" [+] {path}@{ref}") return True except GitHubThrottleError: @@ -158,6 +158,28 @@ def _probe(path: str) -> bool: _log(f" [i] {path}@{ref} ({exc})") return False + def _raw_cdn_first_applies() -> bool: + """Whether a raw-CDN-only marker sweep is safe and useful here. + + True only for public github.com repositories on the anonymous-first + path: the CDN is un-metered there, while every Contents API miss + spends the shared 60/h anonymous budget (microsoft/apm#3283). + """ + host = dep_ref.host or default_host() + if dep_ref.is_azure_devops() or not is_github_hostname(host): + return False + try: + return ( + downloader.auth_resolver.uses_public_github_anonymous_first( + host, + port=dep_ref.port, + host_type=dep_ref.host_type, + ) + is True + ) + except Exception: + return False + _log(f' [i] Validating virtual package at ref "{ref}": {dep_ref.repo_url}/{vpath}') if dep_ref.is_virtual_file(): @@ -176,6 +198,23 @@ def _probe(path: str) -> bool: f"{vpath}/.cursor-plugin/plugin.json", f"{vpath}/README.md", ] + + # Efficiency repair (microsoft/apm#3283, maintainer-approved bounded + # scope): for public github.com repositories the raw CDN answers + # marker probes without spending the shared anonymous REST budget, + # so sweep every candidate marker CDN-only first. A Claude-plugin + # layout (manifest at ``.claude-plugin/plugin.json``) previously + # burned one anonymous Contents API call per earlier-marker miss; + # the sweep reduces marker validation to zero API calls when any + # marker is reachable through the raw-file path. Each miss here is + # expected, not an error: when the sweep finds nothing, the original + # per-marker probes below run unchanged, so API-only, private, and + # enterprise-host validation keeps its exact behavior. + if _raw_cdn_first_applies(): + for marker_path in marker_paths: + if _probe(marker_path, raw_only=True): + return True + for marker_path in marker_paths: if _probe(marker_path): return True diff --git a/src/apm_cli/marketplace/client.py b/src/apm_cli/marketplace/client.py index a8a8c622d4..96ba6b9542 100644 --- a/src/apm_cli/marketplace/client.py +++ b/src/apm_cli/marketplace/client.py @@ -1178,6 +1178,26 @@ def _fetch_file( return fetcher(source, file_path, host_info=host_info, auth_resolver=auth_resolver) +def _auto_detect_path_with_payload( + source: MarketplaceSource, + auth_resolver: object | None = None, +) -> tuple[str | None, dict | None]: + """Probe candidate locations and return the first that exists. + + Returns ``(path, data)`` where ``data`` is the successfully fetched + catalog JSON for the winning candidate, so callers can reuse the + response instead of immediately re-fetching the same URL + (microsoft/apm#3283). Returns ``(None, None)`` if no location + contains a marketplace.json. Raises ``MarketplaceFetchError`` on + non-404 failures (auth errors, etc.). + """ + for candidate in _MARKETPLACE_PATHS: + data = _fetch_file(source, candidate, auth_resolver=auth_resolver) + if data is not None: + return candidate, data + return None, None + + def _auto_detect_path( source: MarketplaceSource, auth_resolver: object | None = None, @@ -1187,11 +1207,8 @@ def _auto_detect_path( Returns ``None`` if no location contains a marketplace.json. Raises ``MarketplaceFetchError`` on non-404 failures (auth errors, etc.). """ - for candidate in _MARKETPLACE_PATHS: - data = _fetch_file(source, candidate, auth_resolver=auth_resolver) - if data is not None: - return candidate - return None + detected, _payload = _auto_detect_path_with_payload(source, auth_resolver=auth_resolver) + return detected # --------------------------------------------------------------------------- @@ -1204,6 +1221,7 @@ def fetch_marketplace( *, force_refresh: bool = False, auth_resolver: object | None = None, + discovery_payload: dict | None = None, ) -> MarketplaceManifest: """Fetch and parse a marketplace manifest. @@ -1215,6 +1233,12 @@ def fetch_marketplace( source: Marketplace source to fetch. force_refresh: Skip cache and re-fetch from network. auth_resolver: Optional ``AuthResolver`` instance (created if None). + discovery_payload: Optional catalog JSON already fetched during + path discovery in the same invocation (microsoft/apm#3283). + When provided, the network fetch of ``source.path`` is skipped + and the discovery response is parsed and cached instead. The + payload MUST come from the same source URL and ref; callers + that cannot guarantee that must pass ``None``. Returns: MarketplaceManifest: Parsed manifest. @@ -1280,7 +1304,15 @@ def fetch_marketplace( source_digest=result.digest, ) - data = _fetch_file(source, source.path, auth_resolver=auth_resolver) + if discovery_payload is not None: + # Reuse the catalog response already fetched by path discovery + # in this same invocation instead of re-fetching the identical + # URL (microsoft/apm#3283). The payload is still written to + # the sidecar cache below, so later invocations see the same + # freshness behavior as a network fetch. + data = discovery_payload + else: + data = _fetch_file(source, source.path, auth_resolver=auth_resolver) if data is None: raise MarketplaceFetchError( source.name, diff --git a/tests/unit/deps/test_github_downloader_validation.py b/tests/unit/deps/test_github_downloader_validation.py index f61a4a9593..58ce25fa58 100644 --- a/tests/unit/deps/test_github_downloader_validation.py +++ b/tests/unit/deps/test_github_downloader_validation.py @@ -819,7 +819,7 @@ def test_apm_yml_at_collections_path_short_circuits_collection_probe(self) -> No dep_ref = _make_subdir_dep(vpath="collections/writing", ref="main") # Whitelist only `/apm.yml`; everything else 404s. - def fake_download(_dr, path, _ref): + def fake_download(_dr, path, _ref, **kwargs): if path == "collections/writing/apm.yml": return b"name: writing\nversion: 1.0.0\n" raise RuntimeError("404") diff --git a/tests/unit/deps/test_virtual_package_cdn_first_sweep.py b/tests/unit/deps/test_virtual_package_cdn_first_sweep.py new file mode 100644 index 0000000000..bc8b73afd6 --- /dev/null +++ b/tests/unit/deps/test_virtual_package_cdn_first_sweep.py @@ -0,0 +1,201 @@ +"""Regression tests for the raw-CDN-first marker sweep (microsoft/apm#3283). + +A public in-repo plugin whose manifest sits at ``.claude-plugin/plugin.json`` +previously spent one anonymous Contents API call per earlier marker the CDN +missed (four for a Claude Code plugin layout), exhausting the shared 60/h +anonymous budget after roughly fifteen plugins. The approved bounded repair +sweeps every candidate marker through the raw CDN first; the Contents API is +only reached when the sweep finds nothing (API-only / private recovery kept +byte-for-byte). +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from apm_cli.deps import github_downloader_validation as gdv +from apm_cli.deps.github_downloader import GitHubPackageDownloader +from apm_cli.models.apm_package import DependencyReference + +MARKER_COUNT = len( + [ + "apm.yml", + "SKILL.md", + "plugin.json", + ".github/plugin/plugin.json", + ".claude-plugin/plugin.json", + ".cursor-plugin/plugin.json", + "README.md", + ] +) + + +def _make_subdir_dep( + repo_url: str = "owner/repo", + vpath: str = "plugins/skill-creator", + ref: str | None = "main", +) -> DependencyReference: + return DependencyReference( + repo_url=repo_url, + reference=ref, + virtual_path=vpath, + is_virtual=True, + ) + + +def _not_found_response() -> MagicMock: + response = MagicMock() + response.status_code = 404 + response.headers = {} + response.content = b"{}" + response.raise_for_status.side_effect = requests.exceptions.HTTPError( + "404 Not Found", response=response + ) + return response + + +def _recording_raw_wrapper(downloader: GitHubPackageDownloader): + """Wrap ``download_raw_file`` recording every ``raw_only`` value seen.""" + calls: list[bool] = [] + original = downloader.download_raw_file + + def _wrapper(dep_ref, file_path, ref="main", **kwargs): + calls.append(bool(kwargs.get("raw_only", False))) + return original(dep_ref, file_path, ref, **kwargs) + + return calls, _wrapper + + +class TestRawCdnFirstSweep: + """Public github.com subdirectory validation must not spend API calls.""" + + def test_sweep_hit_costs_zero_contents_api_calls(self) -> None: + """A Claude-plugin layout validates with zero Contents API requests.""" + downloader = GitHubPackageDownloader() + dep_ref = _make_subdir_dep() + cdn_only_hit = f"{dep_ref.virtual_path}/.claude-plugin/plugin.json" + + def _cdn(owner, repo, ref, file_path): + return b'{"name": "skill-creator"}' if file_path == cdn_only_hit else None + + with ( + patch.object(downloader._strategies, "try_raw_download", side_effect=_cdn), + patch.object(downloader, "_resilient_get") as api_mock, + ): + ok = gdv.validate_virtual_package_exists(downloader, dep_ref) + + assert ok is True + api_mock.assert_not_called() + + def test_sweep_probes_every_marker_raw_only_before_api(self) -> None: + """Sweep calls are exactly the marker list, all with raw_only=True.""" + downloader = GitHubPackageDownloader() + dep_ref = _make_subdir_dep() + calls, wrapper = _recording_raw_wrapper(downloader) + + with ( + patch.object(downloader, "download_raw_file", side_effect=wrapper), + patch.object(downloader._strategies, "try_raw_download", return_value=None), + patch.object( + downloader.auth_resolver, + "try_with_fallback", + side_effect=lambda _host, fn, **kw: fn(None, {}), + ), + patch.object(downloader, "_resilient_get", return_value=_not_found_response()) as api_mock, + patch.object(gdv, "_ref_exists_via_ls_remote", return_value=(False, None)), + ): + ok = gdv.validate_virtual_package_exists(downloader, dep_ref) + + # Sweep ran over every marker raw-only; then the legacy per-marker + # probes escalated to the API (which 404s below) -- so we see the + # marker list twice: once raw_only=True, once legacy. + assert ok is False + assert calls[:MARKER_COUNT] == [True] * MARKER_COUNT + assert calls[MARKER_COUNT:] == [False] * MARKER_COUNT + # One Contents API request per candidate ref (main + master fallback) + # per marker in the legacy pass, plus the fallback-1 directory probe + # -- the sweep itself spent zero. + assert api_mock.call_count == 2 * MARKER_COUNT + 1 + + def test_sweep_miss_still_validates_via_api(self) -> None: + """API-only recovery is preserved when the CDN has no marker.""" + downloader = GitHubPackageDownloader() + dep_ref = _make_subdir_dep() + + response = MagicMock() + response.status_code = 200 + response.content = b"marker-bytes" + response.raise_for_status = lambda: None + + with ( + patch.object(downloader._strategies, "try_raw_download", return_value=None), + patch.object( + downloader.auth_resolver, + "try_with_fallback", + side_effect=lambda _host, fn, **kw: fn(None, {}), + ), + patch.object(downloader, "_resilient_get", return_value=response), + ): + ok = gdv.validate_virtual_package_exists(downloader, dep_ref) + + assert ok is True + + def test_non_public_host_skips_sweep(self) -> None: + """Enterprise/ADO/GitLab hosts keep the legacy probe order.""" + downloader = GitHubPackageDownloader() + dep_ref = _make_subdir_dep() + calls, wrapper = _recording_raw_wrapper(downloader) + + with ( + patch.object(downloader, "download_raw_file", side_effect=wrapper), + patch.object( + downloader.auth_resolver, + "uses_public_github_anonymous_first", + return_value=False, + ), + patch.object(downloader._strategies, "try_raw_download") as cdn_mock, + ): + ok = gdv.validate_virtual_package_exists(downloader, dep_ref) + + assert ok is False + cdn_mock.assert_not_called() + assert calls and not any(calls), "no probe may run raw-only off public github" + + def test_virtual_file_probe_keeps_legacy_semantics(self) -> None: + """Single-file virtual packages are unaffected by the sweep.""" + downloader = GitHubPackageDownloader() + dep_ref = DependencyReference( + repo_url="owner/repo", + reference="main", + virtual_path="prompts/code-review.prompt.md", + is_virtual=True, + ) + calls, wrapper = _recording_raw_wrapper(downloader) + + with ( + patch.object(downloader, "download_raw_file", side_effect=wrapper), + patch.object(downloader._strategies, "try_raw_download", return_value=None), + ): + ok = gdv.validate_virtual_package_exists(downloader, dep_ref) + + assert ok is False + assert calls == [False] + + def test_raw_only_never_touches_contents_api_on_miss(self) -> None: + """raw_only=True miss raises without any Contents API traffic.""" + downloader = GitHubPackageDownloader() + dep_ref = _make_subdir_dep(vpath="plugins/missing") + + with ( + patch.object(downloader._strategies, "try_raw_download", return_value=None), + patch.object(downloader, "_resilient_get") as api_mock, + ): + with pytest.raises(RuntimeError, match="not found via raw CDN"): + downloader.download_raw_file( + dep_ref, "plugins/missing/apm.yml", "main", raw_only=True + ) + + api_mock.assert_not_called() diff --git a/tests/unit/marketplace/test_catalog_discovery_reuse.py b/tests/unit/marketplace/test_catalog_discovery_reuse.py new file mode 100644 index 0000000000..89a06f1edd --- /dev/null +++ b/tests/unit/marketplace/test_catalog_discovery_reuse.py @@ -0,0 +1,114 @@ +"""Regression tests for marketplace catalog discovery reuse (microsoft/apm#3283). + +``apm marketplace add`` used to fetch the winning catalog during path +discovery and then immediately re-fetch the identical URL through +``fetch_marketplace(force_refresh=True)``. The approved bounded repair +threads the discovery response through so registration consumes it without +a duplicate successful fetch, while the sidecar-cache write (and therefore +explicit catalog freshness on later invocations) is preserved. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from apm_cli.marketplace import client as mp_client +from apm_cli.marketplace.models import MarketplaceSource + +CATALOG = {"name": "catalog", "plugins": [{"name": "skill-creator", "source": "owner/repo"}]} + + +def _github_source(path: str = "marketplace.json") -> MarketplaceSource: + return MarketplaceSource( + name="catalog", + url="https://github.com/owner/repo", + ref="v1.0.0", + path=path, + ) + + +class TestAutoDetectPathWithPayload: + def test_returns_path_and_payload_for_first_hit(self) -> None: + with patch.object( + mp_client, + "_fetch_file", + side_effect=lambda source, path, **kw: CATALOG + if path == ".claude-plugin/marketplace.json" + else None, + ) as fetch_mock: + detected, payload = mp_client._auto_detect_path_with_payload(_github_source()) + + assert detected == ".claude-plugin/marketplace.json" + assert payload == CATALOG + assert fetch_mock.call_count == 3 # first two candidates miss + + def test_returns_none_none_when_no_candidate_exists(self) -> None: + with patch.object(mp_client, "_fetch_file", return_value=None): + detected, payload = mp_client._auto_detect_path_with_payload(_github_source()) + + assert detected is None + assert payload is None + + def test_auto_detect_path_still_returns_path_only(self) -> None: + """Backward-compat wrapper keeps its contract.""" + with patch.object( + mp_client, + "_fetch_file", + side_effect=lambda source, path, **kw: CATALOG if path == "marketplace.json" else None, + ): + assert mp_client._auto_detect_path(_github_source()) == "marketplace.json" + + +class TestFetchMarketplaceDiscoveryPayload: + def test_discovery_payload_skips_duplicate_fetch(self) -> None: + source = _github_source() + + with patch.object(mp_client, "_fetch_file") as fetch_mock, patch.object( + mp_client, "_write_cache" + ) as write_mock: + manifest = mp_client.fetch_marketplace( + source, + force_refresh=True, + discovery_payload=CATALOG, + ) + + fetch_mock.assert_not_called() + write_mock.assert_called_once() + assert manifest.name == "catalog" + assert [p.name for p in manifest.plugins] == ["skill-creator"] + + def test_discovery_payload_none_fetches_normally(self) -> None: + source = _github_source() + + with patch.object( + mp_client, "_fetch_file", return_value=CATALOG + ) as fetch_mock, patch.object(mp_client, "_write_cache"): + manifest = mp_client.fetch_marketplace(source, force_refresh=True) + + fetch_mock.assert_called_once() + assert manifest.name == "catalog" + + def test_discovery_payload_still_writes_sidecar_cache(self) -> None: + """Freshness behavior on later invocations is preserved.""" + import json + import os + + source = _github_source() + + with patch.object(mp_client, "_fetch_file") as fetch_mock, patch.object( + mp_client, "_cache_dir", return_value=mp_client._cache_dir() + ): + # Real cache write under the test's HOME (see conftest), then a + # second fetch must be served from the fresh sidecar cache. + mp_client.fetch_marketplace(source, force_refresh=True, discovery_payload=CATALOG) + fetch_mock.assert_not_called() + + cache_path = mp_client._cache_data_path(mp_client._cache_key(source)) + with open(cache_path, encoding="utf-8") as fh: + data = json.load(fh) + assert data == CATALOG + + mp_client.fetch_marketplace(source) # no force_refresh -> cache hit + fetch_mock.assert_not_called() + + assert os.path.exists(mp_client._cache_data_path(mp_client._cache_key(source))) diff --git a/tests/unit/marketplace/test_marketplace_commands.py b/tests/unit/marketplace/test_marketplace_commands.py index cffedf5779..bc313570e1 100644 --- a/tests/unit/marketplace/test_marketplace_commands.py +++ b/tests/unit/marketplace/test_marketplace_commands.py @@ -58,12 +58,12 @@ def test_invalid_format_no_slash(self, runner): @patch("apm_cli.marketplace.registry.add_marketplace") @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_uses_manifest_name_when_available(self, mock_detect, mock_fetch, mock_add, runner): """Manifest's `name` field becomes the registered alias.""" from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = ".claude-plugin/marketplace.json" + mock_detect.return_value = (".claude-plugin/marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="addy-agent-skills", plugins=(MarketplacePlugin(name="agent-skills"),), @@ -80,12 +80,12 @@ def test_add_uses_manifest_name_when_available(self, mock_detect, mock_fetch, mo @patch("apm_cli.marketplace.registry.add_marketplace") @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_cli_name_overrides_manifest(self, mock_detect, mock_fetch, mock_add, runner): """An explicit --name flag wins over the manifest's name.""" from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="manifest-alias", plugins=(MarketplacePlugin(name="p1"),), @@ -100,14 +100,14 @@ def test_add_cli_name_overrides_manifest(self, mock_detect, mock_fetch, mock_add @patch("apm_cli.marketplace.registry.add_marketplace") @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_falls_back_when_manifest_name_invalid( self, mock_detect, mock_fetch, mock_add, runner ): """Invalid manifest.name triggers a soft fallback to the repo name.""" from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="has spaces!", plugins=(MarketplacePlugin(name="p1"),), @@ -124,14 +124,14 @@ def test_add_falls_back_when_manifest_name_invalid( @patch("apm_cli.marketplace.registry.add_marketplace") @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_falls_back_when_manifest_name_missing( self, mock_detect, mock_fetch, mock_add, runner ): """Empty manifest.name silently falls back to the repo name.""" from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="", plugins=(MarketplacePlugin(name="p1"),), @@ -156,12 +156,12 @@ def test_add_rejects_invalid_cli_name(self, runner): @patch("apm_cli.marketplace.registry.add_marketplace") @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_awesome_copilot_pattern_unchanged(self, mock_detect, mock_fetch, mock_add, runner): """Regression: github/awesome-copilot manifest name == repo name -> no behaviour change.""" from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = ".github/plugin/marketplace.json" + mock_detect.return_value = (".github/plugin/marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="awesome-copilot", plugins=(MarketplacePlugin(name="azure-cloud-development"),), @@ -175,12 +175,12 @@ def test_add_awesome_copilot_pattern_unchanged(self, mock_detect, mock_fetch, mo assert "Install plugins with" not in result.output @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_verbose_shows_alias_source(self, mock_detect, mock_fetch, runner): """Verbose mode reports which precedence tier picked the alias.""" from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="acme-tools", plugins=(MarketplacePlugin(name="p1"),), @@ -200,11 +200,11 @@ def test_remote_url_default_alias_includes_nearest_path_segment(self): ) @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_successful_add(self, mock_detect, mock_fetch, runner): from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="Test", plugins=(MarketplacePlugin(name="p1"),), @@ -215,12 +215,12 @@ def test_successful_add(self, mock_detect, mock_fetch, runner): assert "registered" in result.output.lower() or "1 plugin" in result.output @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_respects_github_host(self, mock_detect, mock_fetch, runner, monkeypatch): from apm_cli.commands.marketplace import marketplace monkeypatch.setenv("GITHUB_HOST", "ghe.corp.example.com") - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="Test", plugins=(MarketplacePlugin(name="p1"),), @@ -237,11 +237,11 @@ def test_add_respects_github_host(self, mock_detect, mock_fetch, runner, monkeyp final_source = mock_fetch.call_args[0][0] assert final_source.host == "ghe.corp.example.com" - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_no_marketplace_json_found(self, mock_detect, runner): from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = None + mock_detect.return_value = (None, None) result = runner.invoke(marketplace, ["add", "acme-org/empty-repo"]) assert result.exit_code != 0 assert "marketplace.json" in result.output @@ -253,12 +253,12 @@ def test_no_marketplace_json_found(self, mock_detect, runner): # ------------------------------------------------------------------ @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_accepts_full_https_url(self, mock_detect, mock_fetch, runner): """`apm marketplace add https://github.com/org/repo` parses as github.com/org/repo.""" from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="m", plugins=(MarketplacePlugin(name="p1"),) ) @@ -273,12 +273,12 @@ def test_add_accepts_full_https_url(self, mock_detect, mock_fetch, runner): assert probe_source.repo == "plugin-marketplace" @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_git_url_fragment_sets_ref_and_canonical_url(self, mock_detect, mock_fetch, runner): """Anthropic git URL shape accepts #ref and stores the URL without the fragment.""" from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="m", plugins=(MarketplacePlugin(name="p1"),) ) @@ -297,12 +297,12 @@ def test_add_git_url_fragment_sets_ref_and_canonical_url(self, mock_detect, mock assert final_source.ref == "v1.2.3" @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_unpinned_git_url_warns_about_mutable_ref(self, mock_detect, mock_fetch, runner): """Git URL sources without #ref get an actionable pinning warning.""" from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="m", plugins=(MarketplacePlugin(name="p1"),) ) @@ -439,13 +439,13 @@ def get(self, url, headers=None, timeout=None, stream=False): @patch("apm_cli.marketplace.registry.add_marketplace") @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_mixed_case_ssh_url_warns_host_flag_is_ignored( self, mock_detect, mock_fetch, mock_add, runner ): from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="m", plugins=(MarketplacePlugin(name="p1"),), @@ -469,13 +469,13 @@ def test_add_mixed_case_ssh_url_warns_host_flag_is_ignored( @patch("apm_cli.marketplace.registry.add_marketplace") @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_unpinned_ssh_url_warns_about_mutable_ref( self, mock_detect, mock_fetch, mock_add, runner ): from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest(name="m", plugins=()) result = runner.invoke( marketplace, @@ -488,11 +488,11 @@ def test_add_unpinned_ssh_url_warns_about_mutable_ref( assert "#v1.0.0" not in result.output @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_strips_dot_git_suffix(self, mock_detect, mock_fetch, runner): from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="m", plugins=(MarketplacePlugin(name="p1"),) ) @@ -505,7 +505,7 @@ def test_add_strips_dot_git_suffix(self, mock_detect, mock_fetch, runner): assert probe_source.repo == "plugin-marketplace" @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_accepts_nested_subpath_on_github_host( self, mock_detect, mock_fetch, runner, monkeypatch ): @@ -516,7 +516,7 @@ def test_add_accepts_nested_subpath_on_github_host( # trusted-host gate accepts it. This is the realistic shape on which # nested sub-paths actually appear. monkeypatch.setenv("GITHUB_HOST", "ghes.corp.example.com") - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="m", plugins=(MarketplacePlugin(name="p1"),) ) @@ -532,12 +532,12 @@ def test_add_accepts_nested_subpath_on_github_host( assert probe_source.repo == "plugin-marketplace" @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_accepts_gitlab_com_https_url(self, mock_detect, mock_fetch, runner): """GitLab SaaS HTTPS URL passes registration and probes the GitLab host.""" from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="m", plugins=(MarketplacePlugin(name="p1"),) ) @@ -552,12 +552,12 @@ def test_add_accepts_gitlab_com_https_url(self, mock_detect, mock_fetch, runner) assert probe_source.repo == "plugin-marketplace" @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_accepts_gitlab_com_host_shorthand(self, mock_detect, mock_fetch, runner): """Host-qualified shorthand for gitlab.com passes the registration gate.""" from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="m", plugins=(MarketplacePlugin(name="p1"),) ) @@ -570,7 +570,7 @@ def test_add_accepts_gitlab_com_host_shorthand(self, mock_detect, mock_fetch, ru assert probe_source.repo == "plugin-marketplace" @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_accepts_self_managed_gitlab_with_gitlab_host_env( self, mock_detect, mock_fetch, runner, monkeypatch ): @@ -578,7 +578,7 @@ def test_add_accepts_self_managed_gitlab_with_gitlab_host_env( from apm_cli.commands.marketplace import marketplace monkeypatch.setenv("GITLAB_HOST", "git.epam.com") - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="m", plugins=(MarketplacePlugin(name="p1"),) ) @@ -591,7 +591,7 @@ def test_add_accepts_self_managed_gitlab_with_gitlab_host_env( assert probe_source.repo == "apm-registry" @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_add_accepts_generic_host_via_git_fetcher(self, mock_detect, mock_fetch, runner): """Unknown FQDN is now accepted: kind=git routes through GitCache. @@ -603,7 +603,7 @@ def test_add_accepts_generic_host_via_git_fetcher(self, mock_detect, mock_fetch, """ from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="m", plugins=(MarketplacePlugin(name="p1"),) ) @@ -694,7 +694,7 @@ def test_marketplace_host_classification_via_auth_resolver(self): ) @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_unknown_host_no_longer_rejects_with_export_hint(self, mock_detect, mock_fetch, runner): """Replacement for the round-3 'untrusted host' regression trap. @@ -707,7 +707,7 @@ def test_unknown_host_no_longer_rejects_with_export_hint(self, mock_detect, mock """ from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="m", plugins=(MarketplacePlugin(name="p1"),) ) @@ -724,7 +724,7 @@ def test_unknown_host_no_longer_rejects_with_export_hint(self, mock_detect, mock assert "export GITHUB_HOST=" not in normalized @patch("apm_cli.marketplace.client.fetch_marketplace") - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_ghes_shorthand_routes_through_git_fetcher_when_unclassified( self, mock_detect, mock_fetch, runner ): @@ -738,7 +738,7 @@ def test_ghes_shorthand_routes_through_git_fetcher_when_unclassified( """ from apm_cli.commands.marketplace import marketplace - mock_detect.return_value = "marketplace.json" + mock_detect.return_value = ("marketplace.json", None) mock_fetch.return_value = MarketplaceManifest( name="m", plugins=(MarketplacePlugin(name="p1"),) ) From 3bfc1f47251e36f0128eea4a075fd923ccf848ac Mon Sep 17 00:00:00 2001 From: ShaoLOWu <3034261766@qq.com> Date: Fri, 23 Oct 2026 17:52:31 +0800 Subject: [PATCH 2/2] test: retarget discovery mocks to _auto_detect_path_with_payload and de-network sweep tests Addresses Copilot review feedback on #3348: - Retarget the traceback tests (phase3/surface) and the five wave7 patch sites from _auto_detect_path to _auto_detect_path_with_payload, returning (path, payload) tuples so discovery stays mocked instead of executing the real fetch path. - test_non_public_host_skips_sweep: use an actual non-public-host dependency and simulate legacy per-marker misses on the download strategy seam (a bare MagicMock return was accepted as file content), stubbing the directory and ls-remote fallbacks at the validation module boundary so the host boundary is checked fully offline. - test_virtual_file_probe_keeps_legacy_semantics: mock the auth callback and return controlled API 404s (matching the full-miss test) so the single-file probe never attempts real GitHub requests. Sweep test suite runtime drops from ~57s (network-dependent) to ~3s. Verified: 6/6 sweep tests green; 332 tests across the touched marketplace/deps suites pass; tests/unit/deps failure set identical to the pre-change Windows baseline (7 pre-existing symlink/routing failures); ruff clean. --- ...test_wave7_marketplace_install_coverage.py | 20 +++++++------- .../test_virtual_package_cdn_first_sweep.py | 26 ++++++++++++++++--- .../test_marketplace_commands_phase3.py | 2 +- .../test_marketplace_commands_surface.py | 2 +- 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/tests/integration/test_wave7_marketplace_install_coverage.py b/tests/integration/test_wave7_marketplace_install_coverage.py index a6b942e19c..41885dca96 100644 --- a/tests/integration/test_wave7_marketplace_install_coverage.py +++ b/tests/integration/test_wave7_marketplace_install_coverage.py @@ -522,8 +522,8 @@ def test_add_valid_repo(self, runner: CliRunner, tmp_path: Path) -> None: return_value=host_info, ), patch( - "apm_cli.marketplace.client._auto_detect_path", - return_value="marketplace.json", + "apm_cli.marketplace.client._auto_detect_path_with_payload", + return_value=("marketplace.json", None), ), patch( "apm_cli.marketplace.client.fetch_marketplace", @@ -563,8 +563,8 @@ def test_add_no_marketplace_json_found(self, runner: CliRunner, tmp_path: Path) return_value=host_info, ), patch( - "apm_cli.marketplace.client._auto_detect_path", - return_value=None, + "apm_cli.marketplace.client._auto_detect_path_with_payload", + return_value=(None, None), ), ): result = runner.invoke(cli, ["marketplace", "add", "acme/repo"]) @@ -582,8 +582,8 @@ def test_add_invalid_name_flag(self, runner: CliRunner, tmp_path: Path) -> None: return_value=host_info, ), patch( - "apm_cli.marketplace.client._auto_detect_path", - return_value="marketplace.json", + "apm_cli.marketplace.client._auto_detect_path_with_payload", + return_value=("marketplace.json", None), ), ): result = runner.invoke( @@ -616,8 +616,8 @@ def test_add_with_custom_branch(self, runner: CliRunner, tmp_path: Path) -> None return_value=host_info, ), patch( - "apm_cli.marketplace.client._auto_detect_path", - return_value="marketplace.json", + "apm_cli.marketplace.client._auto_detect_path_with_payload", + return_value=("marketplace.json", None), ), patch( "apm_cli.marketplace.client.fetch_marketplace", @@ -646,8 +646,8 @@ def test_add_verbose_mode(self, runner: CliRunner, tmp_path: Path) -> None: return_value=host_info, ), patch( - "apm_cli.marketplace.client._auto_detect_path", - return_value="marketplace.json", + "apm_cli.marketplace.client._auto_detect_path_with_payload", + return_value=("marketplace.json", None), ), patch( "apm_cli.marketplace.client.fetch_marketplace", diff --git a/tests/unit/deps/test_virtual_package_cdn_first_sweep.py b/tests/unit/deps/test_virtual_package_cdn_first_sweep.py index bc8b73afd6..2bfdf9fe1d 100644 --- a/tests/unit/deps/test_virtual_package_cdn_first_sweep.py +++ b/tests/unit/deps/test_virtual_package_cdn_first_sweep.py @@ -146,7 +146,9 @@ def test_sweep_miss_still_validates_via_api(self) -> None: def test_non_public_host_skips_sweep(self) -> None: """Enterprise/ADO/GitLab hosts keep the legacy probe order.""" downloader = GitHubPackageDownloader() - dep_ref = _make_subdir_dep() + dep_ref = _make_subdir_dep( + repo_url="https://github.enterprise.example.com/owner/repo" + ) calls, wrapper = _recording_raw_wrapper(downloader) with ( @@ -156,12 +158,22 @@ def test_non_public_host_skips_sweep(self) -> None: "uses_public_github_anonymous_first", return_value=False, ), - patch.object(downloader._strategies, "try_raw_download") as cdn_mock, + # Simulated legacy per-marker miss: keeps the probe entirely + # offline (a bare MagicMock return would be accepted as file + # content), while the directory and ls-remote fallbacks are + # stubbed at the validation-module boundary. + patch.object( + downloader._strategies, + "download_github_file", + side_effect=RuntimeError("simulated miss"), + ) as legacy_mock, + patch.object(gdv, "_directory_exists_at_ref", return_value=False), + patch.object(gdv, "_ref_exists_via_ls_remote", return_value=(False, None)), ): ok = gdv.validate_virtual_package_exists(downloader, dep_ref) assert ok is False - cdn_mock.assert_not_called() + legacy_mock.assert_called() assert calls and not any(calls), "no probe may run raw-only off public github" def test_virtual_file_probe_keeps_legacy_semantics(self) -> None: @@ -178,6 +190,14 @@ def test_virtual_file_probe_keeps_legacy_semantics(self) -> None: with ( patch.object(downloader, "download_raw_file", side_effect=wrapper), patch.object(downloader._strategies, "try_raw_download", return_value=None), + patch.object( + downloader.auth_resolver, + "try_with_fallback", + side_effect=lambda _host, fn, **kw: fn(None, {}), + ), + patch.object( + downloader, "_resilient_get", return_value=_not_found_response() + ), ): ok = gdv.validate_virtual_package_exists(downloader, dep_ref) diff --git a/tests/unit/marketplace/test_marketplace_commands_phase3.py b/tests/unit/marketplace/test_marketplace_commands_phase3.py index db72fdc097..5d8c086f98 100644 --- a/tests/unit/marketplace/test_marketplace_commands_phase3.py +++ b/tests/unit/marketplace/test_marketplace_commands_phase3.py @@ -1028,7 +1028,7 @@ def test_long_description_truncated(self, runner: CliRunner) -> None: class TestAddCmdVerboseTraceback: - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_verbose_flag_shows_traceback_on_error( self, mock_detect: MagicMock, runner: CliRunner ) -> None: diff --git a/tests/unit/marketplace/test_marketplace_commands_surface.py b/tests/unit/marketplace/test_marketplace_commands_surface.py index 1d2bb27eb1..ab0298a2e7 100644 --- a/tests/unit/marketplace/test_marketplace_commands_surface.py +++ b/tests/unit/marketplace/test_marketplace_commands_surface.py @@ -1064,7 +1064,7 @@ def test_long_description_truncated(self, runner: CliRunner) -> None: class TestAddCmdVerboseTraceback: - @patch("apm_cli.marketplace.client._auto_detect_path") + @patch("apm_cli.marketplace.client._auto_detect_path_with_payload") def test_verbose_flag_shows_traceback_on_error( self, mock_detect: MagicMock, runner: CliRunner ) -> None: