- support CRLF docstrings and folded YAML metadata blocks - detect community repo plugins such as iChristGit/OpenWebui-Tools correctly - align README, mirrored docs, and announcement wording with actual behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
405 lines
13 KiB
Python
405 lines
13 KiB
Python
import asyncio
|
||
import importlib.util
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
|
||
MODULE_PATH = (
|
||
Path(__file__).resolve().parents[3]
|
||
/ "plugins"
|
||
/ "tools"
|
||
/ "batch-install-plugins"
|
||
/ "batch_install_plugins.py"
|
||
)
|
||
SPEC = importlib.util.spec_from_file_location("batch_install_plugins", MODULE_PATH)
|
||
batch_install_plugins = importlib.util.module_from_spec(SPEC)
|
||
assert SPEC.loader is not None
|
||
sys.modules[SPEC.name] = batch_install_plugins
|
||
SPEC.loader.exec_module(batch_install_plugins)
|
||
|
||
|
||
def make_candidate(title: str, file_path: str, function_id: str):
|
||
return batch_install_plugins.PluginCandidate(
|
||
plugin_type="tool",
|
||
file_path=file_path,
|
||
metadata={"title": title, "description": f"{title} description"},
|
||
content="class Tools:\n pass\n",
|
||
function_id=function_id,
|
||
)
|
||
|
||
|
||
def make_request():
|
||
class Request:
|
||
base_url = "http://localhost:3000/"
|
||
headers = {"Authorization": "Bearer token"}
|
||
|
||
return Request()
|
||
|
||
|
||
class DummyResponse:
|
||
def __init__(self, status_code: int, json_data=None, text: str = ""):
|
||
self.status_code = status_code
|
||
self._json_data = json_data
|
||
self.text = text
|
||
|
||
def json(self):
|
||
if self._json_data is None:
|
||
raise ValueError("no json body")
|
||
return self._json_data
|
||
|
||
|
||
class FakeAsyncClient:
|
||
posts = []
|
||
responses = []
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
pass
|
||
|
||
async def __aenter__(self):
|
||
return self
|
||
|
||
async def __aexit__(self, exc_type, exc, tb):
|
||
return False
|
||
|
||
async def post(self, url, headers=None, json=None):
|
||
type(self).posts.append((url, headers, json))
|
||
if not type(self).responses:
|
||
raise AssertionError("No fake response configured for POST request")
|
||
response = type(self).responses.pop(0)
|
||
if isinstance(response, Exception):
|
||
raise response
|
||
return response
|
||
|
||
|
||
class FakeGithubAsyncClient:
|
||
def __init__(self, *args, **kwargs):
|
||
pass
|
||
|
||
async def __aenter__(self):
|
||
return self
|
||
|
||
async def __aexit__(self, exc_type, exc, tb):
|
||
return False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_install_all_plugins_only_installs_filtered_candidates(monkeypatch):
|
||
keep = make_candidate("Keep Plugin", "plugins/tools/keep/keep.py", "keep_plugin")
|
||
exclude = make_candidate(
|
||
"Exclude Me",
|
||
"plugins/tools/exclude-me/exclude_me.py",
|
||
"exclude_me",
|
||
)
|
||
self_plugin = make_candidate(
|
||
"Batch Install Plugins from GitHub",
|
||
"plugins/tools/batch-install-plugins/batch_install_plugins.py",
|
||
"batch_install_plugins",
|
||
)
|
||
|
||
async def fake_discover_plugins(url, skip_keywords):
|
||
return [keep, exclude, self_plugin], []
|
||
|
||
monkeypatch.setattr(batch_install_plugins, "discover_plugins", fake_discover_plugins)
|
||
FakeAsyncClient.posts = []
|
||
FakeAsyncClient.responses = [DummyResponse(404), DummyResponse(201)]
|
||
monkeypatch.setattr(batch_install_plugins.httpx, "AsyncClient", FakeAsyncClient)
|
||
|
||
events = []
|
||
captured = {}
|
||
|
||
async def event_call(payload):
|
||
if payload["type"] == "confirmation":
|
||
captured["message"] = payload["data"]["message"]
|
||
elif payload["type"] == "execute":
|
||
captured.setdefault("execute_codes", []).append(payload["data"]["code"])
|
||
return True
|
||
|
||
async def emitter(event):
|
||
events.append(event)
|
||
|
||
result = await batch_install_plugins.Tools().install_all_plugins(
|
||
__user__={"id": "u1", "language": "en-US"},
|
||
__event_call__=event_call,
|
||
__request__=make_request(),
|
||
__event_emitter__=emitter,
|
||
repo=batch_install_plugins.DEFAULT_REPO,
|
||
plugin_types=["tool"],
|
||
exclude_keywords="exclude",
|
||
)
|
||
|
||
assert "Created: Keep Plugin" in result
|
||
assert "Exclude Me" not in result
|
||
assert "1/1" in result
|
||
assert captured["message"].count("[tool]") == 1
|
||
assert "Keep Plugin" in captured["message"]
|
||
assert "Exclude Me" not in captured["message"]
|
||
assert "Batch Install Plugins from GitHub" not in captured["message"]
|
||
assert "exclude, batch-install-plugins" in captured["message"]
|
||
|
||
urls = [url for url, _, _ in FakeAsyncClient.posts]
|
||
assert urls == [
|
||
"http://localhost:3000/api/v1/tools/id/keep_plugin/update",
|
||
"http://localhost:3000/api/v1/tools/create",
|
||
]
|
||
assert any(
|
||
"Starting OpenWebUI install requests" in code
|
||
for code in captured.get("execute_codes", [])
|
||
)
|
||
assert events[-1]["type"] == "notification"
|
||
assert events[-1]["data"]["type"] == "success"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_install_all_plugins_supports_missing_event_emitter(monkeypatch):
|
||
keep = make_candidate("Keep Plugin", "plugins/tools/keep/keep.py", "keep_plugin")
|
||
|
||
async def fake_discover_plugins(url, skip_keywords):
|
||
return [keep], []
|
||
|
||
monkeypatch.setattr(batch_install_plugins, "discover_plugins", fake_discover_plugins)
|
||
FakeAsyncClient.posts = []
|
||
FakeAsyncClient.responses = [DummyResponse(404), DummyResponse(201)]
|
||
monkeypatch.setattr(batch_install_plugins.httpx, "AsyncClient", FakeAsyncClient)
|
||
|
||
result = await batch_install_plugins.Tools().install_all_plugins(
|
||
__user__={"id": "u1", "language": "en-US"},
|
||
__request__=make_request(),
|
||
repo="example/repo",
|
||
plugin_types=["tool"],
|
||
)
|
||
|
||
assert "Created: Keep Plugin" in result
|
||
assert "1/1" in result
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_install_all_plugins_handles_confirmation_timeout(monkeypatch):
|
||
keep = make_candidate("Keep Plugin", "plugins/tools/keep/keep.py", "keep_plugin")
|
||
|
||
async def fake_discover_plugins(url, skip_keywords):
|
||
return [keep], []
|
||
|
||
async def fake_wait_for(awaitable, timeout):
|
||
awaitable.close()
|
||
raise asyncio.TimeoutError
|
||
|
||
monkeypatch.setattr(batch_install_plugins, "discover_plugins", fake_discover_plugins)
|
||
monkeypatch.setattr(batch_install_plugins.asyncio, "wait_for", fake_wait_for)
|
||
|
||
events = []
|
||
|
||
async def event_call(payload):
|
||
return True
|
||
|
||
async def emitter(event):
|
||
events.append(event)
|
||
|
||
result = await batch_install_plugins.Tools().install_all_plugins(
|
||
__user__={"id": "u1", "language": "en-US"},
|
||
__event_call__=event_call,
|
||
__request__=make_request(),
|
||
__event_emitter__=emitter,
|
||
repo="example/repo",
|
||
plugin_types=["tool"],
|
||
)
|
||
|
||
assert result == "Confirmation timed out or failed. Installation cancelled."
|
||
assert events[-1]["type"] == "notification"
|
||
assert events[-1]["data"]["type"] == "warning"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_install_all_plugins_marks_total_failure_as_error(monkeypatch):
|
||
keep = make_candidate("Keep Plugin", "plugins/tools/keep/keep.py", "keep_plugin")
|
||
|
||
async def fake_discover_plugins(url, skip_keywords):
|
||
return [keep], []
|
||
|
||
monkeypatch.setattr(batch_install_plugins, "discover_plugins", fake_discover_plugins)
|
||
FakeAsyncClient.posts = []
|
||
FakeAsyncClient.responses = [
|
||
DummyResponse(500, {"detail": "update failed"}, "update failed"),
|
||
DummyResponse(500, {"detail": "create failed"}, "create failed"),
|
||
]
|
||
monkeypatch.setattr(batch_install_plugins.httpx, "AsyncClient", FakeAsyncClient)
|
||
|
||
events = []
|
||
|
||
async def emitter(event):
|
||
events.append(event)
|
||
|
||
result = await batch_install_plugins.Tools().install_all_plugins(
|
||
__user__={"id": "u1", "language": "en-US"},
|
||
__request__=make_request(),
|
||
__event_emitter__=emitter,
|
||
repo="example/repo",
|
||
plugin_types=["tool"],
|
||
)
|
||
|
||
assert "Failed: Keep Plugin - status 500:" in result
|
||
assert "0/1" in result
|
||
assert events[-1]["type"] == "notification"
|
||
assert events[-1]["data"]["type"] == "error"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_install_all_plugins_localizes_timeout_errors(monkeypatch):
|
||
keep = make_candidate("Keep Plugin", "plugins/tools/keep/keep.py", "keep_plugin")
|
||
|
||
async def fake_discover_plugins(url, skip_keywords):
|
||
return [keep], []
|
||
|
||
monkeypatch.setattr(batch_install_plugins, "discover_plugins", fake_discover_plugins)
|
||
FakeAsyncClient.posts = []
|
||
FakeAsyncClient.responses = [httpx.TimeoutException("timed out")]
|
||
monkeypatch.setattr(batch_install_plugins.httpx, "AsyncClient", FakeAsyncClient)
|
||
|
||
result = await batch_install_plugins.Tools().install_all_plugins(
|
||
__user__={"id": "u1", "language": "zh-CN"},
|
||
__request__=make_request(),
|
||
repo="example/repo",
|
||
plugin_types=["tool"],
|
||
)
|
||
|
||
assert "失败:Keep Plugin - 请求超时" in result
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_install_all_plugins_emits_frontend_debug_logs_on_connect_error(
|
||
monkeypatch,
|
||
):
|
||
keep = make_candidate("Keep Plugin", "plugins/tools/keep/keep.py", "keep_plugin")
|
||
|
||
async def fake_discover_plugins(url, skip_keywords):
|
||
return [keep], []
|
||
|
||
monkeypatch.setattr(batch_install_plugins, "discover_plugins", fake_discover_plugins)
|
||
FakeAsyncClient.posts = []
|
||
# Both initial attempt and fallback retry should fail
|
||
FakeAsyncClient.responses = [httpx.ConnectError("connect failed"), httpx.ConnectError("connect failed")]
|
||
monkeypatch.setattr(batch_install_plugins.httpx, "AsyncClient", FakeAsyncClient)
|
||
|
||
execute_codes = []
|
||
events = []
|
||
|
||
async def event_call(payload):
|
||
if payload["type"] == "execute":
|
||
execute_codes.append(payload["data"]["code"])
|
||
return True
|
||
if payload["type"] == "confirmation":
|
||
return True
|
||
raise AssertionError(f"Unexpected event_call payload type: {payload['type']}")
|
||
|
||
async def emitter(event):
|
||
events.append(event)
|
||
|
||
result = await batch_install_plugins.Tools().install_all_plugins(
|
||
__user__={"id": "u1", "language": "en-US"},
|
||
__event_call__=event_call,
|
||
__request__=make_request(),
|
||
__event_emitter__=emitter,
|
||
repo="example/repo",
|
||
plugin_types=["tool"],
|
||
)
|
||
|
||
assert result == "Cannot connect to OpenWebUI. Is it running?"
|
||
assert any("OpenWebUI connection failed" in code for code in execute_codes)
|
||
assert any("console.error" in code for code in execute_codes)
|
||
assert any("http://localhost:3000" in code for code in execute_codes)
|
||
assert events[-1]["type"] == "notification"
|
||
assert events[-1]["data"]["type"] == "error"
|
||
|
||
|
||
def test_extract_metadata_supports_crlf_and_folded_yaml_docstrings():
|
||
content = (
|
||
'"""\r\n'
|
||
"title: Persona Selector\r\n"
|
||
"author: ichrist\r\n"
|
||
"description: >\r\n"
|
||
" Two-step persona picker. Step 1: numbered category list (16 categories).\r\n"
|
||
" Step 2: numbered persona list (10 per category). 160 personas + Custom.\r\n"
|
||
"version: 6.0.2\r\n"
|
||
'"""\r\n\r\n'
|
||
"class Tools:\r\n"
|
||
" pass\r\n"
|
||
)
|
||
|
||
metadata = batch_install_plugins.extract_metadata(content)
|
||
|
||
assert metadata["title"] == "Persona Selector"
|
||
assert metadata["author"] == "ichrist"
|
||
assert metadata["version"] == "6.0.2"
|
||
assert metadata["description"] == (
|
||
"Two-step persona picker. Step 1: numbered category list (16 categories). "
|
||
"Step 2: numbered persona list (10 per category). 160 personas + Custom."
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_discover_plugins_supports_community_repo_crlf_docstrings(monkeypatch):
|
||
tree = [
|
||
{"type": "blob", "path": "Tools/ask-user.py"},
|
||
{"type": "blob", "path": "Tools/persona.py"},
|
||
{"type": "blob", "path": "Tools/orchestrator.py"},
|
||
]
|
||
contents = {
|
||
"Tools/ask-user.py": (
|
||
'"""\r\n'
|
||
"title: Ask User\r\n"
|
||
"author: ichrist\r\n"
|
||
"version: 1.0\r\n"
|
||
"description: Allows the LLM to autonomously trigger 1-5 interactive pop-up questions.\r\n"
|
||
'"""\r\n\r\n'
|
||
"class Tools:\r\n"
|
||
" pass\r\n"
|
||
),
|
||
"Tools/persona.py": (
|
||
'"""\r\n'
|
||
"title: Persona Selector\r\n"
|
||
"author: ichrist\r\n"
|
||
"description: >\r\n"
|
||
" Two-step persona picker. Step 1: numbered category list (16 categories).\r\n"
|
||
" Step 2: numbered persona list (10 per category). 160 personas + Custom.\r\n"
|
||
"version: 6.0.2\r\n"
|
||
'"""\r\n\r\n'
|
||
"class Tools:\r\n"
|
||
" pass\r\n"
|
||
),
|
||
"Tools/orchestrator.py": (
|
||
'"""\r\n'
|
||
"title: 🌌 The Omniscient Orchestrator\r\n"
|
||
"author: ichrist\r\n"
|
||
"version: 2.0\r\n"
|
||
"description: A high-polish, multi-stage workflow engine.\r\n"
|
||
'"""\r\n\r\n'
|
||
"class Tools:\r\n"
|
||
" pass\r\n"
|
||
),
|
||
}
|
||
|
||
async def fake_fetch_tree(client, owner, repo, branch):
|
||
assert (owner, repo, branch) == ("iChristGit", "OpenWebui-Tools", "main")
|
||
return tree
|
||
|
||
async def fake_fetch_file(client, owner, repo, branch, path):
|
||
return contents[path]
|
||
|
||
monkeypatch.setattr(batch_install_plugins.httpx, "AsyncClient", FakeGithubAsyncClient)
|
||
monkeypatch.setattr(batch_install_plugins, "fetch_github_tree", fake_fetch_tree)
|
||
monkeypatch.setattr(batch_install_plugins, "fetch_github_file", fake_fetch_file)
|
||
|
||
candidates, skipped = await batch_install_plugins.discover_plugins(
|
||
"https://github.com/iChristGit/OpenWebui-Tools/",
|
||
batch_install_plugins.DEFAULT_SKIP_KEYWORDS,
|
||
)
|
||
|
||
assert sorted(candidate.title for candidate in candidates) == [
|
||
"Ask User",
|
||
"Persona Selector",
|
||
"🌌 The Omniscient Orchestrator",
|
||
]
|
||
assert skipped == []
|