628 lines
21 KiB
Python
628 lines
21 KiB
Python
|
|
# -*- 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)
|