| 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"] |