Files
agent-desktop/tests/integration/test_plugins.py
T

628 lines
21 KiB
Python
Raw Normal View History

2026-08-23 22:44:18 +08:00
# -*- coding: utf-8 -*-
"""Integration tests for plugin install / upload / uninstall lifecycle.
Covers both ingest paths (POST /install with a local source path,
POST /upload with an in-memory zip) plus the catalog and status
endpoints. Two real bundled plugins (``cloudpaw`` and
``gpt-image2-tool``) are installed and uninstalled to exercise the
full hot-load → unload pipeline; both have light enough dependency
footprints (stdlib only / httpx) that the test environment can satisfy
them without extra pip installs.
The catalog endpoint proxies an external CDN; in CI / offline the CDN
may be unreachable. The catalog test asserts the graceful 200 +
``plugins`` contract that the server is supposed to fall back to,
without depending on real CDN content.
"""
from __future__ import annotations
import io
import json
import time
import zipfile
from pathlib import Path
from typing import Any
import httpx
import pytest
from tests.integration.helpers import (
LOADER_READY_TIMEOUT,
OFFICIAL_PLUGINS_DIR,
PLUGIN_HTTP_TIMEOUT,
wait_until_plugin_loader_ready,
)
# --------------------------------------------------------------------------- #
# helpers
# --------------------------------------------------------------------------- #
_SAMPLE_BACKEND_PY = (
"# -*- coding: utf-8 -*-\n"
'"""Minimal noop plugin backend for integration testing.\n\n'
"PluginLoader imports this module and looks for a top-level\n"
"``plugin`` object exposing a ``register(api)`` method. We give\n"
"the smallest possible implementation so the loader is satisfied\n"
"without registering any tools / providers / commands.\n"
'"""\n\n'
"\n"
"class _IntegSamplePlugin:\n"
" def register(self, api):\n"
" # No tools / providers / commands to register.\n"
" pass\n"
"\n"
"\n"
"plugin = _IntegSamplePlugin()\n"
)
def _build_sample_plugin_zip(
*,
plugin_id: str,
version: str,
name: str | None = None,
) -> bytes:
"""Build an in-memory zip containing a minimal valid plugin.
PluginManifest requires ``id`` + ``version``, and the loader
additionally rejects manifests with no entry points (HTTP 400
"no entry points declared"). We declare ``entry.backend`` pointing
at an empty ``plugin.py`` so the loader is happy importing nothing.
"""
manifest = {
"id": plugin_id,
"version": version,
"name": name or plugin_id,
"description": "Sprint 1.3 integration sample plugin",
"author": "qwenpaw-test",
"plugin_type": "general",
"entry": {"backend": "plugin.py"},
}
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
# Real bundles have plugin.json + entry.backend at the archive
# root inside a per-plugin directory; replicate that shape so
# _find_plugin_dir in plugins.py can locate the manifest.
zf.writestr(f"{plugin_id}/plugin.json", json.dumps(manifest))
zf.writestr(f"{plugin_id}/plugin.py", _SAMPLE_BACKEND_PY)
return buf.getvalue()
def _list_loaded_plugin_ids(app_server) -> set[str]:
"""Return the set of loaded plugin ids per GET /api/plugins."""
resp = app_server.api_request(
"GET",
"/api/plugins",
timeout=PLUGIN_HTTP_TIMEOUT,
)
assert resp.status_code == 200, app_server.logs_tail()
payload = resp.json()
# The router returns a list[dict] of loaded plugins; tolerate a
# dict wrapper if the shape ever changes.
items = (
payload if isinstance(payload, list) else payload.get("plugins", [])
)
return {
str(item["id"])
for item in items
if isinstance(item, dict) and isinstance(item.get("id"), str)
}
def _delete_plugin(app_server, plugin_id: str):
"""DELETE /api/plugins/{plugin_id} — retry on transient loader 503."""
deadline = time.time() + LOADER_READY_TIMEOUT
while True:
wait_until_plugin_loader_ready(app_server)
resp = app_server.api_request(
"DELETE",
f"/api/plugins/{plugin_id}",
timeout=PLUGIN_HTTP_TIMEOUT,
)
if resp.status_code != 503 or time.time() >= deadline:
return resp
time.sleep(0.5)
def _delete_plugin_quietly(app_server, plugin_id: str) -> None:
"""Best-effort uninstall used in finally blocks; ignore HTTP errors."""
try:
_delete_plugin(app_server, plugin_id)
except AssertionError:
pass
def _install_local_official_plugin(
app_server,
source_path: Path,
) -> dict[str, Any]:
"""POST /api/plugins/install with a local-path source. Returns the
install response payload (retries on transient 503)."""
deadline = time.time() + LOADER_READY_TIMEOUT
resp = None
while True:
wait_until_plugin_loader_ready(app_server)
try:
resp = app_server.api_request(
"POST",
"/api/plugins/install",
json={"source": str(source_path), "force": False},
timeout=PLUGIN_HTTP_TIMEOUT,
)
except httpx.TimeoutException:
if time.time() >= deadline:
raise
time.sleep(0.5)
continue
if resp.status_code != 503 or time.time() >= deadline:
break
time.sleep(0.5)
assert resp.status_code == 200, (
f"install failed: {resp.status_code} | {resp.text} | "
f"logs: {app_server.logs_tail()}"
)
return resp.json()
def _upload_plugin_zip(
app_server,
plugin_id: str,
zip_bytes: bytes,
*,
force: bool = False,
):
"""POST /api/plugins/upload — returns raw response (caller asserts).
Retries on transient 503 ``Plugin loader is not ready`` returned by
the server during the background-reload window that follows a
previous install/uninstall.
"""
kwargs: dict[str, Any] = {
"files": {
"file": (f"{plugin_id}.zip", zip_bytes, "application/zip"),
},
"timeout": PLUGIN_HTTP_TIMEOUT,
}
if force:
kwargs["params"] = {"force": "true"}
deadline = time.time() + LOADER_READY_TIMEOUT
while True:
wait_until_plugin_loader_ready(app_server)
try:
resp = app_server.api_request(
"POST",
"/api/plugins/upload",
**kwargs,
)
except httpx.TimeoutException:
if time.time() >= deadline:
raise
time.sleep(0.5)
continue
if resp.status_code != 503 or time.time() >= deadline:
return resp
time.sleep(0.5)
# --------------------------------------------------------------------------- #
# cases — list / catalog / 404 contracts
# --------------------------------------------------------------------------- #
@pytest.mark.integration
@pytest.mark.p1
def test_plugins_list_returns_empty_array_contract(app_server) -> None:
"""Test purpose:
- Verify GET /api/plugins returns an empty collection on a fresh
workspace where no plugin has been installed yet. Console's
plugin page hits this on first load.
Test flow:
1. GET /api/plugins.
2. Assert 200 + response is a list (possibly empty) or a dict with
an empty ``plugins`` key.
API endpoints:
- GET /api/plugins
"""
resp = app_server.api_request(
"GET",
"/api/plugins",
timeout=PLUGIN_HTTP_TIMEOUT,
)
assert resp.status_code == 200, app_server.logs_tail()
payload = resp.json()
items = (
payload if isinstance(payload, list) else payload.get("plugins", [])
)
assert isinstance(items, list)
@pytest.mark.integration
@pytest.mark.p1
@pytest.mark.xfail(
strict=False,
reason=(
"Pre-existing product bug: GET /api/plugins/catalog returns HTTP "
"500 instead of the documented graceful 200 fallback when the CDN "
"fetch in build_plugin_catalog (src/pineagents/plugins/"
"download_catalog.py) raises an exception outside its narrow "
"except set (urllib.error.URLError, json.JSONDecodeError, "
"TimeoutError). In CI the fetch commonly fails with "
"ConnectionResetError / ssl.SSLError / http.client.IncompleteRead "
"(not URLError subclasses) or returns a non-dict JSON body, all of "
"which propagate uncaught. strict=False: the endpoint returns 200 "
"where the CDN is reachable (e.g. local dev), 500 where the fetch "
"errors (CI). See localfile evidence report."
),
)
def test_plugins_catalog_returns_200_with_plugins_field_contract(
app_server,
) -> None:
"""Test purpose:
- Verify GET /api/plugins/catalog returns the documented contract
even when the upstream CDN is unreachable. The server-side
proxy is supposed to fall back to ``{"plugins": [], "error": ...}``
with HTTP 200 instead of 5xx, so the Console plugin page
degrades gracefully.
Test flow:
1. GET /api/plugins/catalog (real call; CDN may be unreachable
in offline / CI environments).
2. Assert 200, response is a dict with a ``plugins`` list field.
Do not assert on the catalog contents — only on the contract.
API endpoints:
- GET /api/plugins/catalog
"""
resp = app_server.api_request(
"GET",
"/api/plugins/catalog",
timeout=PLUGIN_HTTP_TIMEOUT,
)
assert resp.status_code == 200, app_server.logs_tail()
payload = resp.json()
assert isinstance(payload, dict), f"expected dict, got {type(payload)}"
assert isinstance(
payload.get("plugins"),
list,
), f"plugins field missing or not a list: {payload}"
@pytest.mark.integration
@pytest.mark.p2
def test_plugins_status_returns_404_for_missing(app_server) -> None:
"""Test purpose:
- Verify GET /api/plugins/{plugin_id}/status returns 404 when the
plugin is neither loaded nor present on disk.
Test flow:
1. GET /api/plugins/integ-missing-plugin/status.
2. Assert 404.
API endpoints:
- GET /api/plugins/{plugin_id}/status
"""
resp = app_server.api_request(
"GET",
"/api/plugins/integ-missing-plugin-status/status",
timeout=PLUGIN_HTTP_TIMEOUT,
)
assert resp.status_code == 404, app_server.logs_tail()
@pytest.mark.integration
@pytest.mark.p2
def test_plugins_delete_missing_returns_404(app_server) -> None:
"""Test purpose:
- Verify DELETE /api/plugins/{plugin_id} returns 404 when the
plugin is not loaded, so the client can distinguish "already
uninstalled" from a real server error.
Test flow:
1. DELETE /api/plugins/integ-not-loaded.
2. Assert 404 + detail mentions the plugin id.
API endpoints:
- DELETE /api/plugins/{plugin_id}
"""
plugin_id = "integ-not-loaded-plugin"
resp = _delete_plugin(app_server, plugin_id)
assert resp.status_code == 404, app_server.logs_tail()
detail = resp.json().get("detail", "")
assert plugin_id in detail or "not loaded" in detail.lower()
# --------------------------------------------------------------------------- #
# cases — error paths for upload / install
# --------------------------------------------------------------------------- #
@pytest.mark.integration
@pytest.mark.p2
def test_plugins_upload_rejects_non_zip_filename(app_server) -> None:
"""Test purpose:
- Verify POST /api/plugins/upload rejects files whose name does
not end with ``.zip`` before any extraction is attempted.
Test flow:
1. POST multipart with a .txt file.
2. Assert 400 + detail mentions ``.zip``.
API endpoints:
- POST /api/plugins/upload
"""
resp = app_server.api_request(
"POST",
"/api/plugins/upload",
files={
"file": (
"not_a_plugin.txt",
b"this is plain text",
"text/plain",
),
},
timeout=PLUGIN_HTTP_TIMEOUT,
)
assert resp.status_code == 400, app_server.logs_tail()
assert ".zip" in resp.json().get("detail", "")
@pytest.mark.integration
@pytest.mark.p2
def test_plugins_install_rejects_invalid_source(app_server) -> None:
"""Test purpose:
- Verify POST /api/plugins/install with a source path that does
not exist on disk returns 400 with a clear "Path not found"
detail (rather than crashing or going through the URL-download
branch).
Test flow:
1. POST /install body source=/tmp/integ-nonexistent-plugin-source.
2. Assert 400 + detail contains ``Path not found``.
API endpoints:
- POST /api/plugins/install
"""
resp = app_server.api_request(
"POST",
"/api/plugins/install",
json={
"source": "/tmp/integ-nonexistent-plugin-source-xyz-0001",
"force": False,
},
timeout=PLUGIN_HTTP_TIMEOUT,
)
assert resp.status_code == 400, app_server.logs_tail()
assert "Path not found" in resp.json().get("detail", "")
# --------------------------------------------------------------------------- #
# cases — official plugin install lifecycle (real plugins on disk)
# --------------------------------------------------------------------------- #
@pytest.mark.integration
@pytest.mark.p0
def test_plugins_install_official_cloudpaw_lifecycle(app_server) -> None:
"""Test purpose:
- Exercise the install → list → status → uninstall pipeline against
a real bundled plugin (``cloudpaw``, deps=[] stdlib-only). This
is the most representative coverage of the plugin loader's
hot-load / unload paths.
Test flow:
1. POST /api/plugins/install with source=<repo>/plugins/bundle/cloudpaw.
2. Assert 200 and response contains id=cloudpaw + loaded=True.
3. GET /api/plugins; assert cloudpaw is in the loaded set.
4. GET /api/plugins/cloudpaw/status; assert ``loaded=True`` and
``version`` matches the manifest.
5. DELETE /api/plugins/cloudpaw; assert 200.
6. GET /api/plugins; assert cloudpaw is gone.
7. finally — best-effort delete (covers any earlier-step failure).
API endpoints:
- POST /api/plugins/install
- GET /api/plugins
- GET /api/plugins/{plugin_id}/status
- DELETE /api/plugins/{plugin_id}
"""
plugin_id = "cloudpaw"
source_path = OFFICIAL_PLUGINS_DIR / "bundle" / "cloudpaw"
assert source_path.is_dir(), f"missing source: {source_path}"
try:
install_payload = _install_local_official_plugin(
app_server,
source_path,
)
assert install_payload.get("id") == plugin_id
assert install_payload.get("loaded") is True
assert plugin_id in _list_loaded_plugin_ids(app_server)
status_resp = app_server.api_request(
"GET",
f"/api/plugins/{plugin_id}/status",
timeout=PLUGIN_HTTP_TIMEOUT,
)
assert status_resp.status_code == 200, app_server.logs_tail()
status = status_resp.json()
assert status.get("loaded") is True
assert isinstance(status.get("version"), str) and status["version"]
delete_resp = _delete_plugin(app_server, plugin_id)
assert delete_resp.status_code == 200, app_server.logs_tail()
assert plugin_id not in _list_loaded_plugin_ids(app_server)
finally:
_delete_plugin_quietly(app_server, plugin_id)
@pytest.mark.integration
@pytest.mark.p1
def test_plugins_install_official_gpt_image2_lifecycle(app_server) -> None:
"""Test purpose:
- Same lifecycle as cloudpaw but against a tool-type plugin
(``gpt-image2-tool``, deps=[httpx]). Verifies the loader handles
a different ``plugin_type`` (tool vs general/bundle) on the same
install/uninstall pipeline.
Test flow:
1. POST /api/plugins/install source=<repo>/plugins/tool/gpt-image2.
2. Assert 200 and the install response includes id=gpt-image2-tool.
3. GET /api/plugins; assert id present.
4. GET status; assert ``loaded=True``.
5. DELETE; assert 200 and id is gone from the list.
6. finally — best-effort delete.
API endpoints:
- POST /api/plugins/install
- GET /api/plugins
- GET /api/plugins/{plugin_id}/status
- DELETE /api/plugins/{plugin_id}
"""
plugin_id = "gpt-image2-tool"
source_path = OFFICIAL_PLUGINS_DIR / "tool" / "gpt-image2"
assert source_path.is_dir(), f"missing source: {source_path}"
try:
install_payload = _install_local_official_plugin(
app_server,
source_path,
)
assert install_payload.get("id") == plugin_id
assert plugin_id in _list_loaded_plugin_ids(app_server)
status_resp = app_server.api_request(
"GET",
f"/api/plugins/{plugin_id}/status",
timeout=PLUGIN_HTTP_TIMEOUT,
)
assert status_resp.status_code == 200, app_server.logs_tail()
assert status_resp.json().get("loaded") is True
delete_resp = _delete_plugin(app_server, plugin_id)
assert delete_resp.status_code == 200, app_server.logs_tail()
assert plugin_id not in _list_loaded_plugin_ids(app_server)
finally:
_delete_plugin_quietly(app_server, plugin_id)
# --------------------------------------------------------------------------- #
# cases — upload ingest path lifecycle + force replace
# --------------------------------------------------------------------------- #
@pytest.mark.integration
@pytest.mark.p1
def test_plugins_upload_sample_plugin_lifecycle(app_server) -> None:
"""Test purpose:
- Verify the upload ingest path (multipart zip) on a minimal
manifest-only plugin. Avoids the install-via-path code path
tested in cloudpaw/gpt-image2 to isolate upload-specific bugs.
Test flow:
1. Build an in-memory zip with only plugin.json
(id=integ-sample-upload, version=0.0.1).
2. POST /api/plugins/upload multipart.
3. Assert 200; capture plugin_id from response.
4. GET /api/plugins; assert id present.
5. GET status; assert ``loaded=True`` and version=0.0.1.
6. DELETE; assert 200 + id gone.
7. finally — best-effort delete.
API endpoints:
- POST /api/plugins/upload
- GET /api/plugins
- GET /api/plugins/{plugin_id}/status
- DELETE /api/plugins/{plugin_id}
"""
plugin_id = "integ-sample-upload"
zip_bytes = _build_sample_plugin_zip(
plugin_id=plugin_id,
version="0.0.1",
)
try:
upload_resp = _upload_plugin_zip(app_server, plugin_id, zip_bytes)
assert upload_resp.status_code == 200, app_server.logs_tail()
upload_payload = upload_resp.json()
assert upload_payload.get("id") == plugin_id
assert plugin_id in _list_loaded_plugin_ids(app_server)
status_resp = app_server.api_request(
"GET",
f"/api/plugins/{plugin_id}/status",
timeout=PLUGIN_HTTP_TIMEOUT,
)
assert status_resp.status_code == 200, app_server.logs_tail()
status = status_resp.json()
assert status.get("loaded") is True
assert status.get("version") == "0.0.1"
delete_resp = _delete_plugin(app_server, plugin_id)
assert delete_resp.status_code == 200, app_server.logs_tail()
assert plugin_id not in _list_loaded_plugin_ids(app_server)
finally:
_delete_plugin_quietly(app_server, plugin_id)
@pytest.mark.integration
@pytest.mark.p1
def test_plugins_upload_force_replaces_existing(app_server) -> None:
"""Test purpose:
- Verify the upload force-replace flow: re-uploading the same
plugin_id without ``force=true`` is rejected with a conflict
(409), but passing ``force=true`` unloads the existing record
and installs the new version. Console's "reinstall" button is
this exact flow.
Test flow:
1. Upload v0.0.1 (success); confirm via status.version == 0.0.1.
2. Upload v0.0.1 again without force — assert 409 (conflict
surfaced as ValueError → 409 in install handler).
3. Upload v0.0.2 with ``?force=true``; assert 200.
4. GET status; assert version == 0.0.2.
5. finally — best-effort delete.
API endpoints:
- POST /api/plugins/upload (×3, with ?force=true on the last)
- GET /api/plugins/{plugin_id}/status
- DELETE /api/plugins/{plugin_id}
"""
plugin_id = "integ-sample-force-replace"
zip_v1 = _build_sample_plugin_zip(plugin_id=plugin_id, version="0.0.1")
zip_v2 = _build_sample_plugin_zip(plugin_id=plugin_id, version="0.0.2")
try:
first = _upload_plugin_zip(app_server, plugin_id, zip_v1)
assert first.status_code == 200, app_server.logs_tail()
assert first.json().get("version") == "0.0.1"
conflict = _upload_plugin_zip(app_server, plugin_id, zip_v1)
assert conflict.status_code == 409, app_server.logs_tail()
replace = _upload_plugin_zip(
app_server,
plugin_id,
zip_v2,
force=True,
)
assert replace.status_code == 200, app_server.logs_tail()
status_resp = app_server.api_request(
"GET",
f"/api/plugins/{plugin_id}/status",
timeout=PLUGIN_HTTP_TIMEOUT,
)
assert status_resp.status_code == 200, app_server.logs_tail()
assert status_resp.json().get("version") == "0.0.2"
finally:
_delete_plugin_quietly(app_server, plugin_id)