main
py 186 lines 7.55 KB
Raw
1 from __future__ import annotations
2
3 import sys
4 import tempfile
5 import threading
6 from contextlib import contextmanager
7 from pathlib import Path
8 from types import SimpleNamespace
9 from typing import Iterator
10
11 import pytest
12 from flask import Flask
13
14 PROJECT_ROOT = Path(__file__).resolve().parents[1]
15 if str(PROJECT_ROOT) not in sys.path:
16 sys.path.insert(0, str(PROJECT_ROOT))
17
18
19 class _TestAgentContext:
20 @staticmethod
21 def get(context_id):
22 return None
23
24
25 sys.modules.setdefault("agent", SimpleNamespace(AgentContext=_TestAgentContext))
26
27 from api.load_webui_extensions import LoadWebuiExtensions
28 from helpers.extension import get_webui_extension_manifest
29
30
31 SURFACE_SCENARIOS: list[tuple[str, str]] = [
32 ("sidebar-start", "webui/components/sidebar/left-sidebar.html"),
33 ("sidebar-end", "webui/components/sidebar/left-sidebar.html"),
34 ("sidebar-top-wrapper-start", "webui/components/sidebar/top-section/sidebar-top.html"),
35 ("sidebar-top-wrapper-end", "webui/components/sidebar/top-section/sidebar-top.html"),
36 ("sidebar-quick-actions-main-start", "webui/components/sidebar/top-section/quick-actions.html"),
37 ("sidebar-quick-actions-main-end", "webui/components/sidebar/top-section/quick-actions.html"),
38 ("sidebar-quick-actions-dropdown-start", "webui/components/sidebar/top-section/quick-actions.html"),
39 ("sidebar-quick-actions-dropdown-end", "webui/components/sidebar/top-section/quick-actions.html"),
40 ("sidebar-chats-list-start", "webui/components/sidebar/chats/chats-list.html"),
41 ("sidebar-chats-list-end", "webui/components/sidebar/chats/chats-list.html"),
42 ("sidebar-tasks-list-start", "webui/components/sidebar/tasks/tasks-list.html"),
43 ("sidebar-tasks-list-end", "webui/components/sidebar/tasks/tasks-list.html"),
44 ("sidebar-row-actions-menu", "webui/components/sidebar/left-sidebar.html"),
45 ("sidebar-bottom-wrapper-start", "webui/components/sidebar/bottom/sidebar-bottom.html"),
46 ("sidebar-bottom-wrapper-end", "webui/components/sidebar/bottom/sidebar-bottom.html"),
47 ("chat-input-start", "webui/components/chat/input/chat-bar.html"),
48 ("chat-input-end", "webui/components/chat/input/chat-bar.html"),
49 ("chat-input-progress-start", "webui/components/chat/input/progress.html"),
50 ("chat-input-progress-end", "webui/components/chat/input/progress.html"),
51 ("chat-input-box-start", "webui/components/chat/input/chat-bar-input.html"),
52 ("chat-input-box-end", "webui/components/chat/input/chat-bar-input.html"),
53 ("chat-input-bottom-actions-start", "webui/components/chat/input/bottom-actions-bar.html"),
54 ("chat-input-bottom-actions-end", "webui/components/chat/input/bottom-actions-bar.html"),
55 ("chat-top-start", "webui/components/chat/top-section/chat-top.html"),
56 ("chat-top-end", "webui/components/chat/top-section/chat-top.html"),
57 ("sync-status-end", "webui/components/sync/sync-status.html"),
58 ("welcome-screen-start", "webui/components/welcome/welcome-screen.html"),
59 ("welcome-screen-end", "webui/components/welcome/welcome-screen.html"),
60 ("welcome-actions-start", "webui/components/welcome/welcome-screen.html"),
61 ("welcome-actions-end", "webui/components/welcome/welcome-screen.html"),
62 ("welcome-banners-start", "webui/components/welcome/welcome-screen.html"),
63 ("welcome-banners-end", "webui/components/welcome/welcome-screen.html"),
64 ("plugins-list-dropdown-start", "webui/components/plugins/list/plugin-list.html"),
65 ("plugins-list-dropdown-end", "webui/components/plugins/list/plugin-list.html"),
66 ("modal-shell-start", "webui/js/modals.js"),
67 ("modal-shell-end", "webui/js/modals.js"),
68 ("right-canvas-shell-start", "webui/components/canvas/right-canvas.html"),
69 ("right-canvas-tabs-start", "webui/components/canvas/right-canvas.html"),
70 ("right-canvas-tabs-end", "webui/components/canvas/right-canvas.html"),
71 ("right-canvas-toolbar-start", "webui/components/canvas/right-canvas.html"),
72 ("right-canvas-toolbar-end", "webui/components/canvas/right-canvas.html"),
73 ("right-canvas-panels", "webui/components/canvas/right-canvas.html"),
74 ("right-canvas-empty-state", "webui/components/canvas/right-canvas.html"),
75 ("right-canvas-shell-end", "webui/components/canvas/right-canvas.html"),
76 ]
77
78
79 def _new_handler() -> LoadWebuiExtensions:
80 app = Flask("test_webui_extension_surfaces")
81 app.secret_key = "test-secret"
82 return LoadWebuiExtensions(app, threading.RLock())
83
84
85 @pytest.fixture
86 def anyio_backend():
87 return "asyncio"
88
89
90 def _assert_surface_anchor_in_template(surface: str, template_rel_path: str) -> None:
91 template_path = PROJECT_ROOT / template_rel_path
92 template_html = template_path.read_text(encoding="utf-8")
93 assert f'<x-extension id="{surface}"></x-extension>' in template_html
94
95
96 @contextmanager
97 def _temporary_probe_plugin(surface: str) -> Iterator[tuple[str, str]]:
98 plugins_root = PROJECT_ROOT / "plugins"
99 with tempfile.TemporaryDirectory(
100 prefix="tmp_surface_probe_",
101 dir=plugins_root,
102 ) as temp_plugin_dir:
103 plugin_id = Path(temp_plugin_dir).name
104 (Path(temp_plugin_dir) / "plugin.yaml").write_text(
105 (
106 f"name: {plugin_id}\n"
107 f"title: {plugin_id}\n"
108 "description: Temporary WebUI surface probe.\n"
109 "version: 0.0.0\n"
110 "always_enabled: false\n"
111 ),
112 encoding="utf-8",
113 )
114 from helpers import cache
115
116 cache.clear("*(plugins)*")
117 probe_file = (
118 Path(temp_plugin_dir)
119 / "extensions"
120 / "webui"
121 / surface
122 / "surface-probe.html"
123 )
124 probe_file.parent.mkdir(parents=True, exist_ok=True)
125 probe_file.write_text(
126 (
127 "<div x-data "
128 f'data-surface-probe="{surface}" '
129 f'data-plugin-id="{plugin_id}"></div>'
130 ),
131 encoding="utf-8",
132 )
133 try:
134 yield plugin_id, probe_file.name
135 finally:
136 cache.clear("*(plugins)*")
137
138
139 @pytest.mark.anyio
140 @pytest.mark.parametrize(
141 ("surface", "template_rel_path"),
142 SURFACE_SCENARIOS,
143 ids=[scenario[0] for scenario in SURFACE_SCENARIOS],
144 )
145 async def test_webui_surface_extension_point_end_to_end(
146 surface: str,
147 template_rel_path: str,
148 ) -> None:
149 _assert_surface_anchor_in_template(surface, template_rel_path)
150
151 with _temporary_probe_plugin(surface) as (plugin_id, probe_file_name):
152 payload = await _new_handler().process(
153 {"extension_point": surface, "filters": ["*.html"]},
154 None,
155 )
156 assert isinstance(payload, dict)
157 extensions = payload.get("extensions", [])
158 expected_suffix = (
159 f"{plugin_id}/extensions/webui/{surface}/{probe_file_name}"
160 )
161
162 extension_paths = [
163 str(
164 extension.get("path", "")
165 if isinstance(extension, dict)
166 else extension
167 ).replace("\\", "/")
168 for extension in extensions
169 ]
170
171 assert any(path.endswith(expected_suffix) for path in extension_paths)
172
173
174 def test_webui_extension_manifest_groups_plugin_assets_by_type_and_surface() -> None:
175 surface = "manifest-probe"
176 with _temporary_probe_plugin(surface) as (plugin_id, probe_file_name):
177 manifest = get_webui_extension_manifest(agent=None)
178 expected_suffix = (
179 f"/{plugin_id}/extensions/webui/{surface}/{probe_file_name}"
180 )
181
182 assert any(
183 path.endswith(expected_suffix)
184 for path in manifest["html"].get(surface, [])
185 )
186 assert surface not in manifest["js"]