From d637f3c2b68edfffd6e84e102b4f934abaa8d3e1 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Thu, 7 May 2026 00:15:50 +0200 Subject: [PATCH] Add refactor guardrails and runtime docs Cover the modal/surface boundary, Desktop ownership, Office document-only behavior, explicit Desktop opens, plugin-owned runtime paths, renamed skills, connector ownership rules, Browser context handoff, and Playwright cache stability. Update operator docs to match the retained Docker Playwright install path. --- docs/guides/troubleshooting.md | 2 +- docs/setup/dev-setup.md | 4 +- tests/test_browser_agent_regressions.py | 276 +++++++-- tests/test_office_canvas_setup.py | 786 ++++++++---------------- tests/test_office_desktop_state.py | 2 +- tests/test_office_document_store.py | 455 ++++++++++---- tests/test_skills_runtime.py | 97 ++- 7 files changed, 911 insertions(+), 711 deletions(-) diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index dc42254e5..74dcc3812 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -27,7 +27,7 @@ Refer to the [Choosing your LLMs](../setup/installation.md#installing-and-using- Use **Settings → Backup & Restore** and avoid mapping the entire `/a0` directory. See [How to update Agent Zero](../setup/installation.md#how-to-update-agent-zero). **8. My browser tool fails or says Playwright is missing. What now?** -The built-in browser is provided by the `_browser` plugin and the direct `browser` tool. **Docker:** the Chromium headless shell is shipped preinstalled (typically under `/a0/tmp/playwright`). **Local development:** if the binary is missing, `ensure_playwright_binary()` in `plugins/_browser/helpers/playwright.py` runs `playwright install chromium --only-shell` into `tmp/playwright` on first browser use (you may see UI notifications). To install ahead of time, run `PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium --only-shell` after `pip install -r requirements.txt`. If you prefer an external browser stack, use MCP alternatives such as Browser OS, Chrome DevTools, or Playwright MCP. See [MCP Setup](mcp-setup.md). +The built-in browser is provided by the `_browser` plugin and the direct `browser` tool. **Docker:** the Chromium headless shell is shipped preinstalled (typically under `/a0/usr/plugins/_browser/playwright`). **Local development:** if the binary is missing, `ensure_playwright_binary()` in `plugins/_browser/helpers/playwright.py` runs `playwright install chromium --only-shell` into `usr/plugins/_browser/playwright` on first browser use (you may see UI notifications). To install ahead of time, run `PLAYWRIGHT_BROWSERS_PATH=usr/plugins/_browser/playwright playwright install chromium --only-shell` after `pip install -r requirements.txt`. If you prefer an external browser stack, use MCP alternatives such as Browser OS, Chrome DevTools, or Playwright MCP. See [MCP Setup](mcp-setup.md). **9. My secrets disappeared after a backup restore.** Secrets are stored in `/a0/usr/secrets.env` and are not always included in backup archives. Copy them manually. diff --git a/docs/setup/dev-setup.md b/docs/setup/dev-setup.md index 025296310..dfd9e0043 100644 --- a/docs/setup/dev-setup.md +++ b/docs/setup/dev-setup.md @@ -67,9 +67,9 @@ Now when you select one of the python files in the project, you should see prope 3. Install dependencies. Run these two commands in the terminal: ```bash pip install -r requirements.txt -PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium --only-shell +PLAYWRIGHT_BROWSERS_PATH=usr/plugins/_browser/playwright playwright install chromium --only-shell ``` -The first command installs Python dependencies. The second installs the Chromium headless shell into `tmp/playwright` ahead of time (same path in Docker: `/a0/tmp/playwright`). If you skip the second command, **local development** still downloads the shell on first browser use through `ensure_playwright_binary()` in `plugins/_browser/helpers/playwright.py`. Pre-installing avoids that wait. **Docker** images ship the shell preinstalled; runtime install is for local dev when the binary is missing. +The first command installs Python dependencies. The second installs the Chromium headless shell into `usr/plugins/_browser/playwright` ahead of time (same path in Docker: `/a0/usr/plugins/_browser/playwright`). If you skip the second command, **local development** still downloads the shell on first browser use through `ensure_playwright_binary()` in `plugins/_browser/helpers/playwright.py`. Pre-installing avoids that wait. **Docker** images ship the shell preinstalled; runtime install is for local dev when the binary is missing. Errors in the code editor caused by missing packages should now be gone. If not, try reloading the window. diff --git a/tests/test_browser_agent_regressions.py b/tests/test_browser_agent_regressions.py index 1a619c70c..deed48aca 100644 --- a/tests/test_browser_agent_regressions.py +++ b/tests/test_browser_agent_regressions.py @@ -258,6 +258,32 @@ def test_browser_launch_config_uses_full_chromium_for_all_sessions(tmp_path): def test_browser_playwright_cache_uses_persistent_usr_path(monkeypatch, tmp_path): + monkeypatch.delenv("A0_BROWSER_PLAYWRIGHT_CACHE_DIR", raising=False) + monkeypatch.setattr( + browser_playwright_module.files, + "get_abs_path", + lambda *parts: str(tmp_path.joinpath(*parts)), + ) + browser_binary = ( + tmp_path + / "usr" + / "plugins" + / "_browser" + / "playwright" + / "chromium-1169" + / "chrome-linux" + / "chrome" + ) + browser_binary.parent.mkdir(parents=True) + browser_binary.write_text("#!/bin/sh\n", encoding="utf-8") + + assert get_playwright_cache_dir() == str( + tmp_path / "usr" / "plugins" / "_browser" / "playwright" + ) + assert get_playwright_binary() == browser_binary + + +def test_browser_playwright_cache_falls_back_to_existing_legacy_install(monkeypatch, tmp_path): monkeypatch.delenv("A0_BROWSER_PLAYWRIGHT_CACHE_DIR", raising=False) monkeypatch.setattr( browser_playwright_module.files, @@ -275,9 +301,11 @@ def test_browser_playwright_cache_uses_persistent_usr_path(monkeypatch, tmp_path legacy_binary.parent.mkdir(parents=True) legacy_binary.write_text("#!/bin/sh\n", encoding="utf-8") - assert get_playwright_cache_dir() == str( - tmp_path / "usr" / "plugins" / "_browser" / "playwright" - ) + assert browser_playwright_module.get_playwright_cache_dirs() == [ + tmp_path / "usr" / "plugins" / "_browser" / "playwright", + tmp_path / "usr" / "browser" / "playwright", + tmp_path / "tmp" / "playwright", + ] assert get_playwright_binary() == legacy_binary @@ -288,7 +316,7 @@ def test_browser_extension_storage_uses_plugin_user_path(monkeypatch, tmp_path): lambda *parts: str(tmp_path.joinpath(*parts)), ) - assert get_extensions_root() == tmp_path / "usr" / "plugins" / "_browser" / "extensions" + assert get_extensions_root() == tmp_path / "usr" / "_browser" / "extensions" def test_browser_extension_manager_uninstalls_only_managed_extensions(monkeypatch, tmp_path): @@ -472,7 +500,8 @@ def test_browser_viewer_creates_chat_when_no_context_is_selected(): assert "chatsStore.setSelected?.(contextId)" in js assert "this.contextId = existingContextId;" in js assert "this.contextId = contextId;" in js - assert "let targetContextId = requestedContextId;" in js + assert "let targetContextId = requestedContextId" in js + assert "|| this.resolveContextId();" in js assert "targetContextId = await this.ensureContextId();" in js assert "contextId: targetContextId" in js assert "No active chat context is selected." not in js @@ -496,6 +525,30 @@ def test_browser_canvas_startup_waits_for_raw_viewport_settle(): assert "isCurrentSurfaceOpen(surfaceSequence)" in js assert "isCanvasSurfaceVisible(element)" in js assert "scheduleViewportSyncForSurface" in js + assert "const targetChanged = Boolean(" in js + assert "if (this.frameSrc && !targetChanged)" in js + assert "this.cancelFrameRender();" in js + assert "this.resetRenderedFrame();" in js + + +def test_browser_surface_handoffs_keep_existing_frame_until_replacement_arrives(): + js = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text( + encoding="utf-8" + ) + prepare_start = js.index("prepareSurfaceOpen(nextMode") + prepare_block = js[prepare_start: js.index("resetViewportTracking()", prepare_start)] + viewport_start = js.index("resetRenderedFrameIfViewportChanged(viewport =") + viewport_block = js[viewport_start: js.index("async waitForSurfaceViewport", viewport_start)] + clear_start = js.index("clearRenderedFrameIfViewportChanged()") + clear_block = js[clear_start: js.index("beginCommand()", clear_start)] + + assert "modeChanged" not in prepare_block + assert "if (this.frameSrc && !targetChanged)" in prepare_block + assert "this.resetRenderedFrame();" in prepare_block + assert "this.cancelFrameRender();" in viewport_block + assert "this.resetRenderedFrame();" not in viewport_block + assert "this.cancelFrameRender();" in clear_block + assert "this.resetRenderedFrame();" not in clear_block def test_browser_canvas_surface_open_waits_for_visible_panel(): @@ -595,7 +648,7 @@ def test_browser_entry_points_prefer_canvas_and_modal_dock_handoff(): / "_browser" / "extensions" / "webui" - / "right_canvas_register_surfaces" + / "surfaces_register" / "register-browser.js" ).read_text(encoding="utf-8") browser_store = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js").read_text( @@ -605,10 +658,11 @@ def test_browser_entry_points_prefer_canvas_and_modal_dock_handoff(): encoding="utf-8" ) modals_js = (PROJECT_ROOT / "webui" / "js" / "modals.js").read_text(encoding="utf-8") + surfaces_js = (PROJECT_ROOT / "webui" / "js" / "surfaces.js").read_text(encoding="utf-8") assert "Open Browser" in button_html - assert "$store.rightCanvas ? $store.rightCanvas.open('browser')" in button_html - assert "window.ensureModalOpen ? window.ensureModalOpen('/plugins/_browser/webui/main.html')" in button_html + assert "import('/js/surfaces.js')" in button_html + assert "open('browser')" in button_html assert "$store.rightCanvas.toggle('browser')" not in button_html assert 'defaultOpenMode: "modal"' not in register_js assert "beginDockHandoff()" in register_js @@ -623,11 +677,9 @@ def test_browser_entry_points_prefer_canvas_and_modal_dock_handoff(): assert "await surface.cancelDockHandoff?.(payload)" in canvas_store assert "async closeDockSourceModal" in canvas_store - assert "sourceModalPath: modal.path" in modals_js - assert "closeSourceModal: async () =>" in modals_js - assert "const closed = await closeModal(modal.path)" in modals_js - assert "const fallbackClosed = await closeModal()" in modals_js - assert "button.disabled = true" in modals_js + assert "dock(metadata.surfaceId" in surfaces_js + assert "button.disabled = true" in surfaces_js + assert "dockSurface(metadata.surfaceId" not in modals_js assert "beginSurfaceHandoff()" in browser_store assert "finishSurfaceHandoff()" in browser_store @@ -635,28 +687,36 @@ def test_browser_entry_points_prefer_canvas_and_modal_dock_handoff(): assert "releaseSurfaceBindings()" in browser_store assert "this.releaseSurfaceBindings();" in browser_store - assert "async function openBrowserCanvas" in tool_handler - assert 'await rightCanvasStore.open("browser", payload);' in tool_handler - assert "window.ensureModalOpen" in tool_handler - assert "window.openModal" in tool_handler + assert "async function openBrowserSurface" in tool_handler + assert 'await openSurface("browser", payload);' in tool_handler + assert "rightCanvasStore" not in tool_handler + assert "window.ensureModalOpen" not in tool_handler + assert "window.openModal" not in tool_handler assert "function syncOpenBrowserCanvas" in tool_handler assert "async function syncOpenBrowserCanvas" in after_loop_handler assert "syncBrowserResultsIntoOpenCanvas" in after_loop_handler + assert '${contextId || ""}' in tool_handler + assert "contextId || \"\"" in after_loop_handler + assert '"context_id"' in after_loop_handler + assert '"contextId"' in after_loop_handler + assert "rightCanvasStore" not in after_loop_handler assert "window.ensureModalOpen" not in after_loop_handler assert "window.openModal" not in after_loop_handler for js in (tool_handler, after_loop_handler): assert "openBrowserModal" not in js assert "isBrowserCanvasAlreadyOpen" in js - assert "rightCanvasStore?.isOpen" in js - assert 'rightCanvasStore?.activeSurfaceId === "browser"' in js + assert '[data-surface-id="browser"].is-active .browser-panel' in js assert "autoOpenBrowserCanvas" not in js assert "autoOpenedBrowsers" not in js assert "syncedBrowserCanvases" in js assert "const FOCUS_ACTIONS = new Set" in js assert "FOCUS_ACTIONS.has(action)" in js - for js in (tool_handler, after_loop_handler, register_js, browser_store, modals_js): + assert 'id: "browser"' in surfaces_js + assert "/plugins/_browser/webui/main.html" in surfaces_js + + for js in (tool_handler, after_loop_handler, register_js, browser_store, modals_js, surfaces_js): assert "globalThis.Alpine" not in js assert "Alpine?.store" not in js assert "Alpine.store" not in js @@ -671,9 +731,23 @@ def test_browser_and_desktop_surface_buttons_remember_latest_window_mode(): ) modals_js = (PROJECT_ROOT / "webui" / "js" / "modals.js").read_text(encoding="utf-8") modals_css = (PROJECT_ROOT / "webui" / "css" / "modals.css").read_text(encoding="utf-8") - surface_button_block = modals_js[ - modals_js.index("function createModalSurfaceButton"): - modals_js.index("function configureModalSurfaceSwitcher") + surfaces_js = (PROJECT_ROOT / "webui" / "js" / "surfaces.js").read_text(encoding="utf-8") + surfaces_css = (PROJECT_ROOT / "webui" / "css" / "surfaces.css").read_text(encoding="utf-8") + close_block = canvas_store[ + canvas_store.index("async close()"): + canvas_store.index("async dockSurface") + ] + undock_block = canvas_store[ + canvas_store.index("async undockSurface"): + canvas_store.index("async openModalSurface") + ] + open_modal_block = canvas_store[ + canvas_store.index("async openModalSurface"): + canvas_store.index("async undockActiveSurface") + ] + surface_button_block = surfaces_js[ + surfaces_js.index("function createModalSurfaceButton"): + surfaces_js.index("function configureModalSurfaceSwitcher") ] assert "surfaceModes: {}" in canvas_store @@ -685,38 +759,57 @@ def test_browser_and_desktop_surface_buttons_remember_latest_window_mode(): assert "isSurfaceVisible(id)" in canvas_store assert "async openLatest(surfaceId" in canvas_store assert "async openModalSurface(surfaceId" in canvas_store - assert "this.recordSurfaceMode(targetId, SURFACE_MODE_CANVAS" in canvas_store - assert "this.recordSurfaceMode(targetId, SURFACE_MODE_MODAL)" in canvas_store + assert "this.recordSurfaceMode(targetId, SURFACE_MODE_DOCKED" in canvas_store + assert "this.recordSurfaceMode(targetId, SURFACE_MODE_FLOATING)" in canvas_store assert "surfaceModes: this.surfaceModes" in canvas_store assert "normalizeSurfaceMode(mode)" in canvas_store + assert "migratePersistedSurfaceState" in canvas_store + assert "this.mountedSurfaces = {}" not in close_block + assert "surface?.close" not in close_block + assert "this.mountedSurfaces = {}" not in undock_block + assert "failed to close while undocking" not in undock_block + assert "this.mountedSurfaces = {}" not in open_modal_block + assert "failed to close before modal open" not in open_modal_block assert '@click="$store.rightCanvas.openLatest(surface.id)"' in canvas_html assert '@click="$store.rightCanvas.open(surface.id)"' in canvas_html - assert 'rightCanvasStore.recordSurfaceMode?.(metadata.surfaceId, "modal")' in modals_js - assert "configureModalSurfaceSwitcher" in modals_js - assert "modal-surface-switcher" in modals_js - assert "modal-surface-button" in modals_js - assert "SINGLE_VISIBLE_MODAL_SURFACE_PATHS" in modals_js - assert "modal-surface-parked" in modals_js - assert "parkSiblingSurfaceModals(activeModal)" in modals_js + assert "recordMode(metadata.surfaceId, SURFACE_MODE_FLOATING)" in surfaces_js + assert "configureModalSurfaceSwitcher" in surfaces_js + assert "surface-switcher" in surfaces_js + assert "surface-button" in surfaces_js + assert "SINGLE_VISIBLE_MODAL_SURFACE_PATHS" not in modals_js + assert "modal-surface-parked" in surfaces_js + assert "parkSiblingSurfaceModals(activeModal)" in surfaces_js assert "activateModal(modal)" in modals_js + assert "closeSurfaceGroupModals" not in modals_js + assert "closeSurfaceGroupModals" in surfaces_js + assert "const closed = await closeSurfaceGroupModals()" in surfaces_js + assert "globalThis.closeSurfaceGroupModals = closeSurfaceGroupModals" in surfaces_js assert "button.title = title" not in modals_js assert "button.title = metadata.title" not in modals_js - assert "rightCanvasStore.panelSurfaces" in modals_js - assert 'rightCanvasStore.recordSurfaceMode?.(surface.id, "modal")' in modals_js + assert "rightCanvasStore.panelSurfaces" not in modals_js + assert 'await recordMode(normalizedId, SURFACE_MODE_FLOATING)' in surfaces_js assert "const openPromise = ensureModalOpen(targetModalPath)" in surface_button_block assert "await closeModal(modal.path)" not in surface_button_block assert "modalRequiresExplicitClose" in modals_js - assert '"plugins/_browser/webui/main.html"' in modals_js - assert '"plugins/_office/webui/main.html"' in modals_js + assert "modalSurfaceMetadata" not in modals_js + assert "modal-content-loaded" in modals_js + assert '"plugins/_browser/webui/main.html"' not in modals_js + assert '"plugins/_office/webui/main.html"' not in modals_js assert "&& !modalRequiresExplicitClose(newModal)" in modals_js assert "if (modalRequiresExplicitClose(modalStack[modalStack.length - 1])) return;" in modals_js - assert ".modal-surface-switcher" in modals_css - assert ".modal-surface-button.is-active" in modals_css - assert ".modal-surface-image" in modals_css - assert ".modal.modal-surface-parked" in modals_css - assert "grid-auto-flow: column" in modals_css + assert ".modal-surface-switcher" not in modals_css + assert ".surface-switcher" in surfaces_css + assert ".surface-button" in surfaces_css + assert ".modal-surface-button.is-active" in surfaces_css + assert ".modal-surface-image" in surfaces_css + assert ".modal.modal-surface-parked" in surfaces_css + assert "grid-auto-flow: column" in surfaces_css + assert 'id: "browser"' in surfaces_js + assert 'id: "desktop"' in surfaces_js + assert "/plugins/_browser/webui/main.html" in surfaces_js + assert "/plugins/_desktop/webui/main.html" in surfaces_js def test_browser_tool_does_not_auto_open_canvas_policy_is_documented(): @@ -730,8 +823,8 @@ def test_browser_tool_does_not_auto_open_canvas_policy_is_documented(): encoding="utf-8" ) - assert "must not open the right canvas automatically" in prompt - assert "Use the tool headlessly unless the user opens the Browser canvas" in prompt + assert "must not open a Browser surface automatically" in prompt + assert "Use the tool headlessly unless the user opens the Browser surface" in prompt assert "optional visible WebUI viewer" in prompt assert "screenshot" in prompt assert "vision_load" in prompt @@ -741,7 +834,7 @@ def test_browser_tool_does_not_auto_open_canvas_policy_is_documented(): assert "browser-forms" in prompt assert "does not automatically load screenshots" in prompt assert "already open" in config - assert "already-open Browser canvas" in config_html + assert "already-open Browser surface" in config_html def test_browser_forms_skill_is_plugin_owned_and_discoverable(): @@ -849,6 +942,12 @@ def test_browser_viewer_uses_tabs_for_session_switching(): assert "activeBrowserContextId" in browser_store assert "sameBrowserTab" in browser_store assert "applyBrowserListing" in browser_store + assert "syncViewerToSelectedContext(selectedContextId)" in browser_store + assert "async syncViewerToSelectedContext" in browser_store + assert "isVisibleBrowserSurface()" in browser_store + assert "firstBrowserInContext(selectedContextId)" in browser_store + assert "requestedContextId && requestedContextId !== inFlightContextId" in browser_store + assert "create_browser: Boolean(options.createBrowser || options.create_browser)" in browser_store assert "browserTabTooltip(browser)" in browser_store assert "browserChatTitle(browser = {})" in browser_store assert "contextId.slice" not in browser_store @@ -1560,9 +1659,12 @@ async def test_browser_viewer_subscribe_unregisters_stream(monkeypatch): return {"id": 1, "state": {"id": 1, "currentUrl": "about:blank"}} raise AssertionError(method) + fake_runtime = FakeRuntime() + async def fake_get_runtime(context_id, create=True): assert context_id == "ctx" - return FakeRuntime() + assert create is False + return fake_runtime monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime) monkeypatch.setattr( @@ -1584,6 +1686,8 @@ async def test_browser_viewer_subscribe_unregisters_stream(monkeypatch): ) assert result["context_id"] == "ctx" + assert result["active_browser_id"] is None + assert fake_runtime.opened is False assert ("sid-1", "ctx") in ws_browser_module.WsBrowser._streams await handler.on_disconnect("sid-1") @@ -1591,6 +1695,88 @@ async def test_browser_viewer_subscribe_unregisters_stream(monkeypatch): assert ("sid-1", "ctx") not in ws_browser_module.WsBrowser._streams +@pytest.mark.anyio +async def test_browser_viewer_subscribe_can_create_blank_tab_when_requested(monkeypatch): + class FakeRuntime: + def __init__(self) -> None: + self.opened = False + + async def call(self, method, *args): + if method == "list": + if self.opened: + return { + "browsers": [{"id": 1, "currentUrl": "about:blank", "title": ""}], + "last_interacted_browser_id": 1, + } + return {"browsers": [], "last_interacted_browser_id": None} + if method == "open": + self.opened = True + return {"id": 1, "state": {"id": 1, "currentUrl": "about:blank"}} + raise AssertionError(method) + + fake_runtime = FakeRuntime() + + async def fake_get_runtime(context_id, create=True): + assert context_id == "ctx" + assert create is True + return fake_runtime + + monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime) + monkeypatch.setattr( + ws_browser_module.AgentContext, + "get", + staticmethod(lambda context_id: SimpleNamespace(id=context_id)), + ) + + handler = ws_browser_module.WsBrowser( + SimpleNamespace(), + threading.RLock(), + manager=None, + ) + + result = await handler.process( + "browser_viewer_subscribe", + {"context_id": "ctx", "create_browser": True}, + "sid-create", + ) + + assert result["active_browser_id"] == 1 + assert fake_runtime.opened is True + + await handler.on_disconnect("sid-create") + + +@pytest.mark.anyio +async def test_browser_viewer_subscribe_without_runtime_does_not_create_runtime(monkeypatch): + async def fake_get_runtime(context_id, create=True): + assert context_id == "ctx" + assert create is False + return None + + monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime) + monkeypatch.setattr( + ws_browser_module.AgentContext, + "get", + staticmethod(lambda context_id: SimpleNamespace(id=context_id)), + ) + + handler = ws_browser_module.WsBrowser( + SimpleNamespace(), + threading.RLock(), + manager=None, + ) + + result = await handler.process( + "browser_viewer_subscribe", + {"context_id": "ctx"}, + "sid-empty", + ) + + assert result["active_browser_id"] is None + assert result["browsers"] == [] + assert ("sid-empty", "ctx") not in ws_browser_module.WsBrowser._streams + + @pytest.mark.anyio async def test_browser_runtime_sessions_are_context_qualified(monkeypatch): class FakeRuntime: diff --git a/tests/test_office_canvas_setup.py b/tests/test_office_canvas_setup.py index a43aa0544..c531d49fc 100644 --- a/tests/test_office_canvas_setup.py +++ b/tests/test_office_canvas_setup.py @@ -6,536 +6,282 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] -def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_frame(): - panel = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-panel.html").read_text( - encoding="utf-8", - ) - store = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-store.js").read_text( - encoding="utf-8", - ) - canvas_panel = ( - PROJECT_ROOT / "plugins" / "_office" / "extensions" / "webui" / "right-canvas-panels" / "office-panel.html" - ).read_text(encoding="utf-8") - - assert "office-source-editor" in panel - assert "data-office-source" in panel - assert "office-rich-editor" not in panel - assert "office-docx-pages" not in panel - assert "office-desktop-frame" in panel - assert "data-office-desktop-host" in panel - assert 'x-init="$nextTick(() => $store.office.mountDesktopFrameHost($el))"' in panel - assert 'x-effect="$store.office.attachDesktopFrame($el)"' not in panel - assert "data-office-desktop-frame" in store - assert 'title="LibreOffice desktop"' not in panel - assert 'frame.setAttribute("aria-label", "Desktop")' in store - assert "office-command-button" in panel - assert "office-button-label" in panel - assert "grid-template-columns: minmax(0, 1fr) auto auto auto" in panel - assert "flex-wrap: nowrap" in panel - assert ".modal-inner.office-modal .modal-scroll" in panel - assert "office-modal-resizer" in panel - assert "resize: both" not in panel - assert 'frame.setAttribute("tabindex", "0")' in store - assert "format_underlined" not in panel - assert "format_align_center" not in panel - assert "is-native-tile" not in panel - assert "hasOfficialOffice()" in panel - assert 'title="Rename"' in panel - assert "@click=\"$store.office.renameActiveFile()\"" in panel - assert "office_save" in store - assert "desktop_save" in store - assert "openRenameModal" in store - assert 'callOffice("renamed"' in store - assert "performRename" in store - assert "payload.text" in store - assert "handleActiveFileRenamed" in store - assert "--office-zoom" not in panel - assert "zoom: 1" not in store - assert 'callOffice("desktop")' in store - assert "ensureDesktopSession" in store - assert 'await this.onOpen({ source: "modal" });' in store - assert "setDesktopHostVisible" in store - assert "isDesktopHostVisible" in store - assert "clearDesktopViewportSyncTimers" in store - assert "setDesktopHostVisible" in canvas_panel - assert "queueMicrotask" in canvas_panel - assert "isSurfaceRendered('office')" in canvas_panel - assert "isSurfaceVisible('office')" in canvas_panel - assert "canvas.isSurfaceMounted?.(\"office\")" in store - assert "Starting Agent Zero Desktop environment" in store - assert "handleOfficialOfficeClosed" in store - assert "ResizeObserver" in store - assert "_desktopResizeSuspended" in store - assert "_desktopResizePending" in store - assert "_desktopResizePendingKey" in store - assert "_desktopViewportSyncTimers" in store - assert "shouldDeferDesktopResize" in store - assert "right-canvas-resize-start" in store - assert "right-canvas-resize-end" in store - assert "isDesktopSession" in store - assert "desktopFrame" in store - assert "attachDesktopFrame" in store - assert "mountDesktopFrameHost" in store - assert "desktopFrameSrcMatches" in store - assert "moveDesktopFrameToKeepalive" in store - assert "destroyDesktopFrame" in store - assert "office-desktop-keepalive" in store - assert "DESKTOP_SHUTDOWN_STORAGE_KEY" in store - assert 'callOffice("desktop_shutdown"' in store - assert "intentional_shutdown" in store - assert "restartDesktopSession" in store - assert "shouldShowDesktopEmptyState" in store - assert "Restart Desktop" in panel - assert "office-desktop-empty" in panel - assert "unloadDesktopFrames" in store - assert "restoreDesktopFrames" in store - assert "officeDesktopUnloaded" not in store - assert "primeXpraDesktopFrame" in store - assert "normalizeXpraDesktopWindow" in store - assert "installXpraDesktopWheelBridge" in store - assert "installXpraDesktopAgentBridge" in store - assert "agentZeroDesktop" in store - assert 'callOffice("desktop_state"' in store - assert "desktopToClient" in store - assert "clientToDesktop" in store - assert "requestRefresh" in store - assert "_desktopBridgeReady" in store - assert "_desktopKeyboardCaptureState" in store - assert "installXpraDesktopKeyboardBridge" in store - assert "focusDesktopFrame" in store - assert "_desktopFocusInProgress" in store - assert "if (this._desktopFocusInProgress) return" in store - assert "_desktopKeyboardActive" in store - assert "isEditableInputTarget" in store - assert "reloadDesktopFrame" in store - assert 'result?.reload' in store - assert "a0_reload" in store - assert "const DESKTOP_RESIZE_DELAY_MS = 80" in store - assert "requestServerResize: false" in store - assert "requestRefresh: false" in store - assert "_desktopResizeTarget" in store - assert "requestDesktopViewportSync" in store - assert "syncDesktopViewport" in store - assert "options.serverResize !== false" in store - assert "serverResize: true" in store - assert "server_is_desktop = true" in store - assert "server_resize_exact = true" in store - assert "_set_decorated?.(false)" in store - assert "topoffset = 0" in store - assert ".undecorated" in store - assert "a0-xpra-desktop-frame-css" in store - assert "installXpraDesktopFramePatches" in store - assert "installXpraDesktopClientPatches" in store - assert "patchedNoWindowList" in store - assert "patchedAddWindowListItem" in store - assert "patchedScreenResized" in store - assert "__a0AllowScreenResize" in store - assert "_desktopHeartbeatTimer" in store - assert "office-modal-focus-button" in store - assert "focusButton.title" not in store - assert "officialOfficeUrl" in store - assert 'parsed.searchParams.set("offscreen", secureContext ? "true" : "false")' in store - assert 'parsed.searchParams.set("clipboard_poll", secureContext ? "true" : "false")' in store - assert "hasOfficialOffice" in store - assert "isOfficeSocketData" in store - assert "office_command" not in store - assert "office_key" not in store - assert "office_mouse" not in store - assert ".uno:Bold" not in store - assert "nativeTilesToHtml" not in store - assert "editorContainsFocus" in store - assert "_focusAttempts" in store - assert "_nativeEventQueue" not in store - assert "await this.awaitNativeEvents()" not in store - assert "


" not in store - assert "setupTitle()" not in panel - assert "Setup in progress" not in store - assert "office-log" not in panel - assert "New Writer document" in panel - assert "DOCX" not in panel - assert "$store.office.create('document', 'odt')" in panel - assert "$store.office.create('spreadsheet', 'ods')" in panel - assert "$store.office.create('presentation', 'odp')" in panel +def read(*parts: str) -> str: + return (PROJECT_ROOT.joinpath(*parts)).read_text(encoding="utf-8") -def test_desktop_xpra_canvas_scroll_is_forwarded_to_the_remote_session(): - store = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-store.js").read_text( - encoding="utf-8", - ) +def test_modals_are_generic_and_surfaces_own_live_surface_paths(): + modals_js = read("webui", "js", "modals.js") + modals_css = read("webui", "css", "modals.css") + surfaces_js = read("webui", "js", "surfaces.js") + surfaces_css = read("webui", "css", "surfaces.css") - assert "canvas.addEventListener(\"wheel\"" in store - assert "mouse_scroll_cb(normalizedEvent, xpraWindow)" in store - assert "stopImmediatePropagation" in store - assert "{ passive: false, capture: true }" in store - assert "xpraDesktopWheelEvent" in store - assert "deltaMode: { value: 0 }" in store - assert "wheelDeltaY" in store - assert "getModifierState: { value: getModifierState }" in store - - -def test_office_surface_filters_tabs_to_desktop_and_markdown_without_dashboard(): - panel = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-panel.html").read_text( - encoding="utf-8", - ) - store = (PROJECT_ROOT / "plugins" / "_office" / "webui" / "office-store.js").read_text( - encoding="utf-8", - ) - - assert "office-card-grid" not in panel - assert "office-document-card" not in panel - assert "visibleTabs()" in panel - assert "openCards()" not in panel - assert "recentCards()" not in panel - assert "office-editor-head" not in panel - assert "office-recent-row" not in panel - assert "open_documents" not in store - assert "installDesktopDocumentSession" in store - assert "isDesktopOfficeDocument" in store - assert "isVisibleOfficeTab" in store - assert "return this.tabs.filter((tab) => this.isVisibleOfficeTab(tab));" in store - - file_browser_store = ( - PROJECT_ROOT / "webui" / "components" / "modals" / "file-browser" / "file-browser-store.js" - ).read_text(encoding="utf-8") - - assert "renameAfterConfirm" in file_browser_store - assert "renamePerformAction" in file_browser_store - assert "renameValidateName" in file_browser_store - assert "options.onRenamed" in file_browser_store - assert "options.performRename" in file_browser_store - assert "options.validateName" in file_browser_store - - -def test_right_canvas_surface_is_branded_as_desktop(): - surface = ( - PROJECT_ROOT - / "plugins" - / "_office" - / "extensions" - / "webui" - / "right_canvas_register_surfaces" - / "register-office.js" - ).read_text(encoding="utf-8") - handler = ( - PROJECT_ROOT - / "plugins" - / "_office" - / "extensions" - / "webui" - / "get_tool_message_handler" - / "document-artifact-handler.js" - ).read_text(encoding="utf-8") - document_actions = ( - PROJECT_ROOT - / "plugins" - / "_office" - / "extensions" - / "webui" - / "lib" - / "document-actions.js" - ).read_text(encoding="utf-8") - - assert 'title: "Desktop"' in surface - assert 'icon: "desktop_windows"' in surface - assert "buildDocumentFileActionButtons(document)" in handler - assert "Open in canvas" in document_actions - assert "downloadDocument" in document_actions - assert "/api/download_work_dir_file?path=" in document_actions - assert "source: \"message-action\"" in document_actions - - -def test_official_libreoffice_desktop_route_and_packages_are_declared(): - routes = (PROJECT_ROOT / "helpers" / "virtual_desktop_routes.py").read_text(encoding="utf-8") - primitive = (PROJECT_ROOT / "helpers" / "virtual_desktop.py").read_text(encoding="utf-8") - desktop = ( - PROJECT_ROOT / "plugins" / "_office" / "helpers" / "libreoffice_desktop.py" - ).read_text(encoding="utf-8") - install = (PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_additional.sh").read_text( - encoding="utf-8", - ) - linux_desktop_skill = ( - PROJECT_ROOT / "plugins" / "_office" / "skills" / "linux-desktop" / "SKILL.md" - ).read_text(encoding="utf-8") - linux_desktopctl = ( - PROJECT_ROOT / "plugins" / "_office" / "skills" / "linux-desktop" / "scripts" / "desktopctl.sh" - ).read_text(encoding="utf-8") - desktop_state_helper = ( - PROJECT_ROOT / "plugins" / "_office" / "helpers" / "desktop_state.py" - ).read_text(encoding="utf-8") - hooks_py = (PROJECT_ROOT / "plugins" / "_office" / "hooks.py").read_text(encoding="utf-8") - linux_calc_helper = ( - PROJECT_ROOT / "plugins" / "_office" / "skills" / "linux-desktop" / "scripts" / "calc_set_cell.py" - ).read_text(encoding="utf-8") - - assert 'Mount("/desktop"' in routes - assert 'Mount("/libreoffice"' not in routes - assert "http.client.HTTPConnection" in routes - assert "WSConnection" in routes - assert "/session/" in routes - assert "resize_session" in routes - assert "resize_display" in primitive - assert "DEFAULT_HEIGHT = 900" in primitive - assert "MAX_WIDTH = 1920" in primitive - assert "MAX_HEIGHT = 1080" in primitive - assert "xrandr" in primitive - assert "xpra-x11" in primitive - assert "xpramenu" in primitive - assert "floating_menu" in primitive - assert '"file_transfer": "true"' in primitive - assert '"sound": "false"' in primitive - assert '"encoding": "jpeg"' in primitive - assert '"quality": "85"' in primitive - assert '"speed": "80"' in primitive - assert '"printing": "true"' in primitive - assert '"offscreen": "true"' in primitive - assert "xpra" in desktop - assert "xpra-html5" in desktop - assert "Xvfb" in desktop - assert "xfce4-session" in desktop - assert "DISPLAY_START_TIMEOUT_SECONDS" in desktop - assert '"shadow"' in desktop - assert "--resize-display=yes" in desktop - assert "--tray=no" in desktop - assert "--system-tray=no" in desktop - assert "--file-transfer=yes" in desktop - assert "--open-files=no" in desktop - assert "--open-url=no" in desktop - assert "--printing=yes" in desktop - assert "--cursors=no" not in desktop - assert "--audio=no" in desktop - assert "--speaker=off" in desktop - assert "--microphone=off" in desktop - assert "--encoding=jpeg" in desktop - assert "--quality=85" in desktop - assert "--speed=80" in desktop - assert "_restart_xpra_shadow(session)" not in desktop - assert 'result["reload"] = True' not in desktop - assert "MAX_SCREEN_WIDTH}x{MAX_SCREEN_HEIGHT}x24" in desktop - assert '"-ac"' in desktop - assert "SYSTEM_TITLE = \"Desktop\"" in desktop - assert "title=\"Desktop\"" in desktop - assert "--log-file=xpra.log" in desktop - assert "virtual_desktop.session_url" in desktop - assert "xsetroot" in desktop - assert "BLOCKING_DIALOG_TITLES" in desktop - assert "xfce4-terminal" in desktop - assert "thunar" in desktop - assert "Browser.desktop" in desktop - assert "Files.desktop" in desktop - assert "org.xfce.terminal" in desktop - assert "org.xfce.settings.manager" in desktop - assert "firefox-esr" not in desktop - assert "xfce4-settings-manager" in desktop - assert "metadata::xfce-exe-checksum" in desktop - assert "DESKTOP_FOLDER_LINKS" in desktop - assert "HIDDEN_XPRA_DESKTOP_ENTRIES" in desktop - assert "HIDDEN_XFCE_MENU_ENTRIES" in desktop - assert "SHUTDOWN_HANDLER_DESKTOP_ID" in desktop - assert "SHUTDOWN_PANEL_LAUNCHER_ID" in desktop - assert "SHUTDOWN_CONFIRM_SECONDS" in desktop - assert "Shutdown Desktop" in desktop - assert "shutdown-request.json" in desktop - assert "shutdown-request.arm.json" in desktop - assert "shutdown_system_desktop" in desktop - assert "claim_shutdown_request" in desktop - assert "last-show-hidden" in desktop - assert "exo-mail-reader.desktop" in desktop - assert "exo-web-browser.desktop" in desktop - assert "xfce4-mail-reader.desktop" in desktop - assert "xfce4-web-browser.desktop" in desktop - assert "xfce4-session-logout.desktop" in desktop - assert "agent-zero-shutdown.desktop" in desktop - assert "libreoffice-gtk3" in install - assert "libreofficekit" not in install - assert "gir1.2-lokdocview" not in install - assert "python3-gi" not in install - assert "xpra" in install - assert "xpra-x11" in install - assert "xpra-html5" in install - assert "xfce4-session" in install - assert "thunar" in install - assert "libglib2.0-bin" in install - assert "xfce4-terminal" in install - assert "firefox-esr" not in install - assert "pulseaudio" not in install - assert "x11-xserver-utils" in install - assert "xauth" in install - assert "Linux Desktop Interface" in linux_desktop_skill - assert "Use the external Agent Zero Browser" in linux_desktop_skill - assert "/a0/usr/workdir" in linux_desktop_skill - assert "/a0/usr/projects" in linux_desktop_skill - assert "desktopctl.sh" in linux_desktop_skill - assert "/a0/plugins/_office/skills/linux-desktop/scripts/desktopctl.sh" in linux_desktop_skill - assert "calc-set-cell" in linux_desktop_skill - assert "Clicks are explicitly last resort" in linux_desktop_skill or "clicks are explicitly last resort" in linux_desktop_skill - assert "fresh Desktop observation" in linux_desktop_skill - assert "observe --json --screenshot" in linux_desktop_skill - assert "Terminal And CLI Agent Verification" in linux_desktop_skill - assert "Do not report from an earlier screenshot path" in linux_desktop_skill - assert "screenshot path returned by that final observation" in linux_desktop_skill - assert "Never paste natural-language text into that shell prompt" in linux_desktop_skill - assert "command not found" in linux_desktop_skill - assert "TARGET_CLI=\"example-cli-agent\"" in linux_desktop_skill - assert "FALLBACK_CMD" in linux_desktop_skill - assert "@openai/codex" not in linux_desktop_skill - assert "xdotool" in linux_desktopctl - assert "agent-zero-desktop" in linux_desktopctl - assert "launch_app" in linux_desktopctl - assert "paste_key_for_active_window" in linux_desktopctl - assert "active_window_is_terminal" in linux_desktopctl - assert "WM_CLASS" in linux_desktopctl - for command in ( - "state)", - "observe)", - "screenshot)", - "active-window)", - "geometry)", - "wait-window)", - "scroll)", - "drag)", - "right-click)", - "paste-text)", - "sequence)", + for forbidden in ( + "right-canvas-store", + "/plugins/_browser", + "/plugins/_office", + "/plugins/_desktop", + "SINGLE_VISIBLE_MODAL_SURFACE_PATHS", + "data-canvas", + "surface-window", ): - assert command in linux_desktopctl - assert "calc_set_cell.py" in linux_desktopctl - assert "collect_state" in desktop_state_helper - assert "compact_prompt_context" in desktop_state_helper - assert "fresh final" in desktop_state_helper - assert "xwd" in desktop_state_helper - assert "PIL" in desktop_state_helper - assert '"x11-utils"' in hooks_py - assert '"x11-apps"' in hooks_py - assert '"xclip"' in hooks_py - assert '"python3-pil"' in hooks_py - assert "wait_for_document" in linux_calc_helper - assert "document.store()" in linux_calc_helper - assert "read_xlsx_cell" in linux_calc_helper - assert "DisposedException" in linux_calc_helper + assert forbidden not in modals_js + + assert "modalStack" in modals_js + assert 'const backdrop = document.createElement("div")' in modals_js + assert "backdrop.style.display" in modals_js + assert "modalSurfaceMetadata" not in modals_js + assert "modal-content-loaded" in modals_js + assert ".surface-floating" not in modals_css + assert ".surface-switcher" not in modals_css + + assert "CORE_SURFACES" in surfaces_js + assert "modalSurfaceMetadata" in surfaces_js + assert "closeSurfaceGroupModals" in surfaces_js + assert 'id: "browser"' in surfaces_js + assert 'id: "desktop"' in surfaces_js + assert "/plugins/_browser/webui/main.html" in surfaces_js + assert "/plugins/_desktop/webui/main.html" in surfaces_js + assert "LEGACY_SURFACE_IDS" in surfaces_js + assert '["office", "desktop"]' in surfaces_js + assert "htmlDataset.surfaceId" in surfaces_js + assert "htmlDataset.canvasSurface" in surfaces_js + assert ".surface-modal" in surfaces_css + assert ".surface-floating" in surfaces_css + assert ".surface-resize-handle" in surfaces_css + assert ".surface-switcher" in surfaces_css + assert "surface-window" not in surfaces_js + surfaces_css -def test_right_canvas_requires_explicit_open_and_is_absent_on_mobile(): - canvas_store = ( - PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas-store.js" - ).read_text(encoding="utf-8") - canvas_html = ( - PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas.html" - ).read_text(encoding="utf-8") - canvas_css = ( - PROJECT_ROOT / "webui" / "components" / "canvas" / "right-canvas.css" - ).read_text(encoding="utf-8") - handler = ( - PROJECT_ROOT - / "plugins" - / "_office" - / "extensions" - / "webui" - / "get_tool_message_handler" - / "document-artifact-handler.js" - ).read_text(encoding="utf-8") - after_loop = ( - PROJECT_ROOT - / "plugins" - / "_office" - / "extensions" - / "webui" - / "set_messages_after_loop" - / "auto-open-document-results.js" - ).read_text(encoding="utf-8") +def test_right_canvas_uses_desktop_surface_id_and_migrates_legacy_office_state(): + canvas_store = read("webui", "components", "canvas", "right-canvas-store.js") + desktop_register = read( + "plugins", + "_desktop", + "extensions", + "webui", + "surfaces_register", + "register-desktop.js", + ) + desktop_panel = read( + "plugins", + "_desktop", + "extensions", + "webui", + "right-canvas-panels", + "desktop-panel.html", + ) + desktop_new_menu = read( + "plugins", + "_desktop", + "extensions", + "webui", + "right-canvas-toolbar-start", + "desktop-new-menu.html", + ) + right_canvas_css = read("webui", "components", "canvas", "right-canvas.css") + desktop_web_panel = read("plugins", "_desktop", "webui", "desktop-panel.html") - init_registration = canvas_store.index('await callJsExtensions("right_canvas_register_surfaces", this);') - init_ensure = canvas_store.index("this.ensureActiveSurface();", init_registration) - register_surface = canvas_store.index("registerSurface(surface)") - register_guard = canvas_store.index("if (!this._registering)", register_surface) - guarded_ensure = canvas_store.index("this.ensureActiveSurface();", register_guard) - open_surface = canvas_store.index("async open", register_surface) - - assert init_registration < init_ensure - assert register_surface < register_guard < guarded_ensure < open_surface - assert "right-canvas-resize-start" in canvas_store - assert "right-canvas-resize-end" in canvas_store - assert "dispatchResizeEvent" in canvas_store - assert "this.isOpen = false;" in canvas_store - assert "wasMobileMode && this.width < MIN_WIDTH" in canvas_store - assert "const MIN_WIDTH = 0" in canvas_store - assert "const MAX_WIDTH" not in canvas_store - assert "0.58" not in canvas_store - assert "min(900px, 58vw)" not in canvas_css - assert "max-width: none" in canvas_css - assert "if (this.isMobileMode && !surface.actionOnly)" in canvas_store - assert "if (this.isMobileMode)" in canvas_store - assert "shouldRender()" in canvas_store - assert "$store.rightCanvas.shouldRender()" in canvas_html - assert 'title="Open as window"' in canvas_html - assert 'title="Close canvas"' in canvas_html - assert 'aria-label="Close canvas"' in canvas_html - assert "@click=\"$store.rightCanvas.close()\"" in canvas_html - assert canvas_html.index('title="Open as window"') < canvas_html.index('title="Close canvas"') - assert "body.right-canvas-mobile-mode .right-canvas" in canvas_css - assert "display: none !important" in canvas_css - assert "autoOpenOfficeCanvas" not in handler - assert "isOfficeCanvasAlreadyOpen" in after_loop - assert 'canvas?.isOpen && canvas?.activeSurfaceId === "office"' in after_loop - assert "office.openSession?.(" in after_loop - assert 'source: "tool-result-sync"' in after_loop - assert 'rightCanvas.open' not in after_loop + assert 'await callJsExtensions("surfaces_register", this);' in canvas_store + assert 'await callJsExtensions("right_canvas_register_surfaces", this);' in canvas_store + assert "migratePersistedSurfaceState" in canvas_store + assert "normalizeSurfaceId" in canvas_store + assert "const saved = migratePersistedSurfaceState(JSON.parse" in canvas_store + assert 'id: "desktop"' in desktop_register + assert 'modalPath: "/plugins/_desktop/webui/main.html"' in desktop_register + assert 'data-surface-id="desktop"' in desktop_panel + assert "isSurfaceVisible('desktop')" in desktop_panel + assert "right-canvas-desktop-actions" in desktop_new_menu + assert "isSurfaceActive('desktop')" in desktop_new_menu + assert "runNewMenuAction('writer')" in desktop_new_menu + assert "runNewMenuAction('spreadsheet')" in desktop_new_menu + assert "runNewMenuAction('presentation')" in desktop_new_menu + assert ".right-canvas-header" in right_canvas_css + assert "overflow: visible;" in right_canvas_css + assert ".right-canvas-toolbar" in right_canvas_css + assert ".right-canvas-desktop-actions .office-new-menu" in desktop_web_panel + assert "z-index: 4000;" in desktop_web_panel + assert not (PROJECT_ROOT / "plugins" / "_office" / "extensions" / "webui" / "right_canvas_register_surfaces" / "register-office.js").exists() + assert not (PROJECT_ROOT / "plugins" / "_office" / "extensions" / "webui" / "right-canvas-panels" / "office-panel.html").exists() -def test_office_skills_preserve_markdown_first_and_opt_in_desktop_policy(): - office_skill = ( - PROJECT_ROOT / "plugins" / "_office" / "skills" / "office-artifacts" / "SKILL.md" - ).read_text(encoding="utf-8") - desktop_skill = ( - PROJECT_ROOT / "plugins" / "_office" / "skills" / "linux-desktop" / "SKILL.md" - ).read_text(encoding="utf-8") - markdown_skill = ( - PROJECT_ROOT / "plugins" / "_office" / "skills" / "markdown-documents" / "SKILL.md" - ).read_text(encoding="utf-8") - word_skill = ( - PROJECT_ROOT / "plugins" / "_office" / "skills" / "word-documents" / "SKILL.md" - ).read_text(encoding="utf-8") - excel_skill = ( - PROJECT_ROOT / "plugins" / "_office" / "skills" / "excel-workbooks" / "SKILL.md" - ).read_text(encoding="utf-8") - presentation_skill = ( - PROJECT_ROOT / "plugins" / "_office" / "skills" / "presentation-decks" / "SKILL.md" - ).read_text(encoding="utf-8") +def test_browser_surface_restores_focus_mode_chrome(): + browser_store = read("plugins", "_browser", "webui", "browser-store.js") + browser_panel = read("plugins", "_browser", "webui", "browser-panel.html") - assert "ODF is first-class" in office_skill - assert "DOCX, XLSX, or PPTX only" in office_skill - assert "custom document canvas" in office_skill - assert "must not open the canvas automatically" in office_skill - assert "Download and Open in canvas actions" in office_skill - assert "method: \"create\"" in office_skill - assert "The Desktop is opt-in" in desktop_skill - assert "coordinate clicks only as a last resort" in desktop_skill - assert "After any GUI action, verify" in desktop_skill - assert "custom Markdown editor" in desktop_skill - assert "Never open the Desktop/canvas automatically" in desktop_skill - assert "persistent Desktop runtime during initial startup" in desktop_skill - assert '"format": "md"' in markdown_skill - assert "never open the canvas automatically" in markdown_skill - assert '"format": "odt"' in word_skill - assert "DOCX only" in word_skill - assert "must not open the canvas automatically" in word_skill - assert '"format": "ods"' in excel_skill - assert "For a blank workbook request" in excel_skill - assert "must not open the canvas automatically" in excel_skill - assert '"format": "odp"' in presentation_skill - assert "must not open the canvas automatically" in presentation_skill + assert "browser-modal-focus-button" in browser_store + assert "is-focus-mode" in browser_store + assert "fullscreen_exit" in browser_store + assert "Focus mode" in browser_store + assert "Restore size" in browser_store + assert ".modal-inner.browser-modal.is-focus-mode" in browser_panel -def test_office_extra_prompt_includes_existing_desktop_state_without_opening_canvas(): - canvas_context = ( - PROJECT_ROOT / "plugins" / "_office" / "helpers" / "canvas_context.py" - ).read_text(encoding="utf-8") - prompt = ( - PROJECT_ROOT / "plugins" / "_office" / "prompts" / "agent.extras.office_canvas.md" - ).read_text(encoding="utf-8") +def test_office_frontend_is_document_only_and_does_not_import_browser_or_desktop_runtime_code(): + office_store = read("plugins", "_office", "webui", "office-store.js") + office_panel = read("plugins", "_office", "webui", "office-panel.html") + office_modal = read("plugins", "_office", "webui", "main.html") - assert "build_desktop_context" in canvas_context - assert "session_manifest_exists" in canvas_context - assert "collect_state(include_screenshot=False)" in canvas_context - assert "compact_prompt_context" in canvas_context - assert "ensure_system_desktop" not in canvas_context - assert "[DOCUMENT CANVAS]" in prompt + assert "/plugins/_browser" not in office_store + assert "right-canvas-store" not in office_store + assert "handleUrlIntent" not in office_store + assert "ensureDesktopSession" not in office_store + assert "desktop_save" not in office_store + assert "desktop_sync" not in office_store + assert "desktop_state" not in office_store + assert "desktop_shutdown" not in office_store + assert "Xpra" not in office_store + assert "xpra" not in office_store + assert "data-office-desktop-host" not in office_panel + assert "office-desktop-frame" not in office_panel + assert "Restart Desktop" not in office_panel + assert "data-surface-id" not in office_modal + assert "modal-no-backdrop" not in office_modal + assert "data-canvas-surface" not in office_modal + + assert "office-source-editor" in office_panel + assert "data-office-source" in office_panel + assert "openRenameModal" in office_store + assert "office_save" in office_store + assert 'callOffice("renamed"' in office_store + assert "requires_desktop" in office_store + assert "openSurface(\"desktop\"" in office_store + + +def test_desktop_plugin_owns_routes_runtime_surface_and_state_paths(): + desktop_plugin = PROJECT_ROOT / "plugins" / "_desktop" + assert (desktop_plugin / "plugin.yaml").exists() + assert (desktop_plugin / "api" / "desktop_session.py").exists() + assert (desktop_plugin / "helpers" / "desktop_session.py").exists() + assert (desktop_plugin / "helpers" / "desktop_state.py").exists() + assert (desktop_plugin / "skills" / "linux-desktop" / "scripts" / "desktopctl.sh").exists() + + desktop_startup = read("plugins", "_desktop", "extensions", "python", "startup_migration", "_20_desktop_routes.py") + desktop_api = read("plugins", "_desktop", "api", "desktop_session.py") + desktop_session = read("plugins", "_desktop", "helpers", "desktop_session.py") + desktop_state = read("plugins", "_desktop", "helpers", "desktop_state.py") + desktop_store = read("plugins", "_desktop", "webui", "desktop-store.js") + desktop_main = read("plugins", "_desktop", "webui", "main.html") + desktop_web_panel = read("plugins", "_desktop", "webui", "desktop-panel.html") + + assert "virtual_desktop_routes.install_route_hooks()" in desktop_startup + assert 'action in {"open_document", "document"}' in desktop_api + assert 'callJsonApi("/plugins/_desktop/desktop_session"' in desktop_store + assert 'callDesktop("open_document"' in desktop_store + assert 'callOffice("create"' in desktop_store + assert "open_in_desktop: isOfficialExtension(fmt)" in desktop_store + assert "__a0XpraOffsetWarnPatched" in desktop_store + assert "window does not fit in canvas, offsets" in desktop_store + assert "decode error packet" in desktop_store + assert 'data-surface-id="desktop"' in desktop_main + assert "virtual_desktop.session_url" in desktop_session + assert 'owner="desktop"' in desktop_session + assert 'STATE_DIR = Path(files.get_abs_path("usr", "_desktop"))' in desktop_session + assert 'STATE_DIR = BASE_DIR / "usr" / "_desktop"' in desktop_state + assert "> x-component > div[x-data] > .office-panel" in desktop_web_panel + assert ".office-state-line > span:not(.material-symbols-outlined)" in desktop_web_panel + + assert not (PROJECT_ROOT / "plugins" / "_office" / "helpers" / "desktop_state.py").exists() + assert not (PROJECT_ROOT / "plugins" / "_office" / "helpers" / "libreoffice_desktop_routes.py").exists() + assert not (PROJECT_ROOT / "plugins" / "_office" / "assets" / "desktop").exists() + + +def test_plugin_owned_runtime_state_paths_are_declared(): + office_documents = read("plugins", "_office", "helpers", "document_store.py") + browser_playwright = read("plugins", "_browser", "helpers", "playwright.py") + browser_extensions = read("plugins", "_browser", "helpers", "extension_manager.py") + docker_playwright = read("docker", "run", "fs", "ins", "install_playwright.sh") + + assert 'PLUGIN_NAME = "_office"' in office_documents + assert 'STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME, "documents"))' in office_documents + assert 'PLAYWRIGHT_CACHE_DIR = ("usr", "plugins", "_browser", "playwright")' in browser_playwright + assert "Path(files.get_abs_path(*PLAYWRIGHT_CACHE_DIR))" in browser_playwright + assert "Path(files.get_abs_path(*EXTENSIONS_ROOT_DIR))" in browser_extensions + assert "PLAYWRIGHT_BROWSERS_PATH=/a0/usr/plugins/_browser/playwright" in docker_playwright + + +def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests(): + auto_open = read( + "plugins", + "_office", + "extensions", + "webui", + "set_messages_after_loop", + "auto-open-document-results.js", + ) + document_actions = read("plugins", "_office", "extensions", "webui", "lib", "document-actions.js") + document_tool = read("plugins", "_office", "tools", "document_artifact.py") + office_api = read("plugins", "_office", "api", "office_session.py") + + assert 'openSurface("desktop"' in auto_open + assert "isExplicitDocumentUiRequest(payload)" in auto_open + assert 'action === "open"' in auto_open + assert "open_in_canvas" in auto_open + assert "open_in_desktop" in auto_open + assert 'surfaces.open("desktop"' not in auto_open + assert "rightCanvas.open" not in auto_open + assert "globalThis.Alpine" not in auto_open + assert "syncDocumentResultsIntoOpenOfficeModal" in auto_open + assert "isOfficeCanvas" not in auto_open + assert "officeStore" in auto_open + assert "openDocumentInDesktop" in document_actions + assert "openDocumentArtifact" in document_actions + assert "ensureModalOpen" in document_actions + assert "Open Document" in document_actions + assert 'openSurface("desktop"' in document_actions + assert "Edit in Writer" in document_actions + assert "Edit in Calc" in document_actions + assert "Edit in Impress" in document_actions + assert "open_in_canvas: bool = False" in document_tool + assert '"open_in_canvas": bool(open_in_canvas)' in document_tool + assert '"open_in_desktop": bool(open_in_desktop)' in document_tool + assert '"requires_desktop": True' in office_api + assert 'input.get("open_in_desktop") is not True' in office_api + + +def test_office_and_desktop_skills_are_rehomed_and_renamed(): + office_skills = PROJECT_ROOT / "plugins" / "_office" / "skills" + desktop_skills = PROJECT_ROOT / "plugins" / "_desktop" / "skills" + + assert not (office_skills / "linux-desktop").exists() + assert (desktop_skills / "linux-desktop" / "SKILL.md").exists() + assert not (office_skills / "office-artifacts").exists() + assert not (office_skills / "word-documents").exists() + assert not (office_skills / "excel-workbooks").exists() + assert not (office_skills / "presentation-decks").exists() + + expected = { + "document-artifacts": office_skills / "document-artifacts" / "SKILL.md", + "writer-documents": office_skills / "writer-documents" / "SKILL.md", + "calc-spreadsheets": office_skills / "calc-spreadsheets" / "SKILL.md", + "impress-presentations": office_skills / "impress-presentations" / "SKILL.md", + "markdown-documents": office_skills / "markdown-documents" / "SKILL.md", + } + for name, path in expected.items(): + text = path.read_text(encoding="utf-8") + assert f"name: {name}" in text + + desktop_skill = (desktop_skills / "linux-desktop" / "SKILL.md").read_text(encoding="utf-8") + desktopctl = (desktop_skills / "linux-desktop" / "scripts" / "desktopctl.sh").read_text(encoding="utf-8") + assert "/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh" in desktop_skill + assert "Open in Desktop action" in desktop_skill + assert "$BASE_DIR/usr/_desktop/profiles/$SESSION" in desktopctl + assert "$BASE_DIR/usr/_desktop/sessions/$SESSION.json" in desktopctl + + +def test_skill_catalog_and_connector_boundaries_are_static_guarded(): + skills_py = read("helpers", "skills.py") + connector_list = read("plugins", "_a0_connector", "api", "v1", "skills_list.py") + connector_delete = read("plugins", "_a0_connector", "api", "v1", "skills_delete.py") + + assert "RENAMED_SKILLS" not in skills_py + assert "RENAMED_SKILL_PATHS" not in skills_py + assert "_migrate_skill_name" not in skills_py + assert "_migrate_skill_path" not in skills_py + assert "Built-in plugin skills cannot be deleted" in skills_py + assert "list_skill_catalog" in connector_list + assert "list_skills(" not in connector_list + assert '"origin": skill["origin"]' in connector_list + assert "list_skill_catalog" in connector_delete + assert 'match.get("origin") not in {"User", "Project"}' in connector_delete + assert "only user or project skills can be deleted" in connector_delete diff --git a/tests/test_office_desktop_state.py b/tests/test_office_desktop_state.py index 44a3336ae..482f54d51 100644 --- a/tests/test_office_desktop_state.py +++ b/tests/test_office_desktop_state.py @@ -10,7 +10,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -from plugins._office.helpers import desktop_state +from plugins._desktop.helpers import desktop_state def _completed(command, returncode=0, stdout="", stderr=""): diff --git a/tests/test_office_document_store.py b/tests/test_office_document_store.py index b21ff9a8d..5f85d2681 100644 --- a/tests/test_office_document_store.py +++ b/tests/test_office_document_store.py @@ -19,12 +19,13 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from plugins._office import hooks +from plugins._desktop import hooks as desktop_hooks +from plugins._desktop.helpers import desktop_session from plugins._office.helpers import ( artifact_editor, canvas_context, document_store, libreoffice, - libreoffice_desktop, markdown_sessions, ) @@ -312,8 +313,8 @@ def test_odf_is_advertised_and_docx_remains_explicit_compatibility(office_state) assert "ODF is first-class for LibreOffice" in prompt assert "DOCX/XLSX/PPTX are compatibility formats" in prompt assert "`method` is accepted as an alias for action" in prompt - assert "they do not open the canvas automatically" in prompt - assert "Download and Open in canvas message actions" in prompt + assert "they do not open a surface automatically" in prompt + assert "explicit Download, Open Document, or Desktop edit message actions" in prompt doc = document_store.create_document("document", "Use ODT", "odt", "") assert doc["extension"] == "odt" @@ -450,7 +451,7 @@ def test_thunar_defaults_preserve_existing_profile_settings(tmp_path): encoding="utf-8", ) - libreoffice_desktop._write_thunar_defaults(thunar_xml) + desktop_session._write_thunar_defaults(thunar_xml) root = ET.parse(thunar_xml).getroot() values = {child.get("name"): child.get("value") for child in root.findall("property")} @@ -459,14 +460,14 @@ def test_thunar_defaults_preserve_existing_profile_settings(tmp_path): assert values["last-show-hidden"] == "true" -def test_official_libreoffice_desktop_status_and_url_contract(tmp_path, monkeypatch): +def test_official_desktop_session_status_and_url_contract(tmp_path, monkeypatch): xpra_html = tmp_path / "xpra" / "www" xpra_html.mkdir(parents=True) (xpra_html / "index.html").write_text("xpra", encoding="utf-8") - monkeypatch.setattr(libreoffice_desktop.libreoffice, "find_soffice", lambda: "/usr/bin/soffice") + monkeypatch.setattr(desktop_session.libreoffice, "find_soffice", lambda: "/usr/bin/soffice") monkeypatch.setattr( - libreoffice_desktop.shutil, + desktop_session.shutil, "which", lambda name: f"/usr/bin/{name}" if name @@ -484,11 +485,11 @@ def test_official_libreoffice_desktop_status_and_url_contract(tmp_path, monkeypa } else "", ) - monkeypatch.setattr(libreoffice_desktop.virtual_desktop, "XPRA_HTML_ROOT_CANDIDATES", (xpra_html,)) - monkeypatch.setattr(libreoffice_desktop.virtual_desktop, "_package_installed", lambda package: True) + monkeypatch.setattr(desktop_session.virtual_desktop, "XPRA_HTML_ROOT_CANDIDATES", (xpra_html,)) + monkeypatch.setattr(desktop_session.virtual_desktop, "_package_installed", lambda package: True) - status = libreoffice_desktop.collect_desktop_status() - url = libreoffice_desktop._xpra_url("abc123") + status = desktop_session.collect_desktop_status() + url = desktop_session._xpra_url("abc123") assert status["healthy"] is True assert status["xpra_html_root"] == str(xpra_html) @@ -504,6 +505,21 @@ def test_official_libreoffice_desktop_status_and_url_contract(tmp_path, monkeypa assert "printing=true" in url +def test_desktop_gateway_patches_xpra_menu_script(): + source = (PROJECT_ROOT / "helpers" / "virtual_desktop_routes.py").read_text(encoding="utf-8") + + assert "XPRA_MENU_CUSTOM_PATCH" in source + assert 'upstream_path.endswith("/js/MenuCustom.js")' in source + assert "window.noWindowList" in source + assert "__a0SafeWindowList" in source + assert "XPRA_WINDOW_OFFSET_WARNING_PATCH" in source + assert "XPRA_WINDOW_SCRIPT_PATCH" in source + assert "a0_desktop_patch=20260506" in source + assert 'upstream_path.endswith("/index.html")' in source + assert 'upstream_path.endswith("/js/Window.js")' in source + assert "window does not fit in canvas, offsets:" in source + + def test_office_session_desktop_state_action_defaults_without_screenshot(monkeypatch): api_module = types.ModuleType("helpers.api") @@ -527,7 +543,7 @@ def test_office_session_desktop_state_action_defaults_without_screenshot(monkeyp return { "ok": True, "display": ":120", - "profile_dir": "/a0/tmp/_office/desktop/profiles/agent-zero-desktop", + "profile_dir": "/a0/usr/_desktop/profiles/agent-zero-desktop", "size": {"width": 1440, "height": 900}, "pointer": {"x": 0, "y": 0, "screen": 0, "window": 0}, "active_window": None, @@ -537,7 +553,7 @@ def test_office_session_desktop_state_action_defaults_without_screenshot(monkeyp "errors": [], } - monkeypatch.setattr(office_session.libreoffice_desktop, "get_manager", lambda: FakeManager()) + monkeypatch.setattr(office_session.desktop_session, "get_manager", lambda: FakeManager()) handler = office_session.OfficeSession(app=None, thread_lock=None) request = types.SimpleNamespace(headers={}, host_url="http://localhost:32080") @@ -583,7 +599,7 @@ def test_office_session_desktop_shutdown_action_calls_manager(monkeypatch): "source": source, } - monkeypatch.setattr(office_session.libreoffice_desktop, "get_manager", lambda: FakeManager()) + monkeypatch.setattr(office_session.desktop_session, "get_manager", lambda: FakeManager()) handler = office_session.OfficeSession(app=None, thread_lock=None) request = types.SimpleNamespace(headers={}, host_url="http://localhost:32080") @@ -600,7 +616,50 @@ def test_office_session_desktop_shutdown_action_calls_manager(monkeypatch): monkeypatch.delattr(api_package, "office_session", raising=False) -def test_official_libreoffice_desktop_manager_opens_binary_session(office_state, tmp_path, monkeypatch): +def test_office_binary_open_requires_explicit_desktop_without_cold_session(office_state, monkeypatch): + api_module = types.ModuleType("helpers.api") + + class ApiHandler: + def __init__(self, app=None, thread_lock=None): + self.app = app + self.thread_lock = thread_lock + + api_module.ApiHandler = ApiHandler + api_module.Request = object + monkeypatch.setitem(sys.modules, "helpers.api", api_module) + monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False) + + from plugins._office.api import office_session + + doc = document_store.create_document("document", "Cold Memo", "odt", "No surprise Desktop.") + + def forbidden_session(*_args, **_kwargs): + raise AssertionError("cold binary open must not create a store session") + + class ForbiddenManager: + def open(self, *_args, **_kwargs): + raise AssertionError("cold binary open must not open Desktop") + + monkeypatch.setattr(office_session.document_store, "create_session", forbidden_session) + monkeypatch.setattr(office_session.desktop_session, "get_manager", lambda: ForbiddenManager()) + + handler = office_session.OfficeSession(app=None, thread_lock=None) + request = types.SimpleNamespace(headers={}, host_url="http://localhost:32080") + result = asyncio.run(handler.process({"action": "open", "file_id": doc["file_id"]}, request)) + + assert result["ok"] is True + assert result["requires_desktop"] is True + assert result["file_id"] == doc["file_id"] + assert "session_id" not in result + assert "store_session_id" not in result + + monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False) + api_package = sys.modules.get("plugins._office.api") + if api_package is not None: + monkeypatch.delattr(api_package, "office_session", raising=False) + + +def test_official_desktop_session_manager_opens_binary_session(office_state, tmp_path, monkeypatch): class FakeProcess: pid = 4242 @@ -616,21 +675,21 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state, def kill(self): return None - monkeypatch.setattr(libreoffice_desktop, "STATE_DIR", tmp_path / "desktop") - monkeypatch.setattr(libreoffice_desktop, "SESSION_DIR", tmp_path / "desktop" / "sessions") - monkeypatch.setattr(libreoffice_desktop, "PROFILE_DIR", tmp_path / "desktop" / "profiles") - monkeypatch.setattr(libreoffice_desktop, "collect_desktop_status", lambda: {"healthy": True, "message": "ok"}) - monkeypatch.setattr(libreoffice_desktop.libreoffice, "find_soffice", lambda: "/usr/bin/soffice") - monkeypatch.setattr(libreoffice_desktop, "_port_is_free", lambda port: True) - monkeypatch.setattr(libreoffice_desktop.virtual_desktop, "has_window", lambda **kwargs: True) - real_get_abs_path = libreoffice_desktop.files.get_abs_path + monkeypatch.setattr(desktop_session, "STATE_DIR", tmp_path / "desktop") + monkeypatch.setattr(desktop_session, "SESSION_DIR", tmp_path / "desktop" / "sessions") + monkeypatch.setattr(desktop_session, "PROFILE_DIR", tmp_path / "desktop" / "profiles") + monkeypatch.setattr(desktop_session, "collect_desktop_status", lambda: {"healthy": True, "message": "ok"}) + monkeypatch.setattr(desktop_session.libreoffice, "find_soffice", lambda: "/usr/bin/soffice") + monkeypatch.setattr(desktop_session, "_port_is_free", lambda port: True) + monkeypatch.setattr(desktop_session.virtual_desktop, "has_window", lambda **kwargs: True) + real_get_abs_path = desktop_session.files.get_abs_path def fake_get_abs_path(*parts): if parts and parts[0] == "usr": return str(tmp_path.joinpath(*parts)) return real_get_abs_path(*parts) - monkeypatch.setattr(libreoffice_desktop.files, "get_abs_path", fake_get_abs_path) + monkeypatch.setattr(desktop_session.files, "get_abs_path", fake_get_abs_path) def fake_spawn(self, session): session.profile_dir.mkdir(parents=True, exist_ok=True) @@ -639,11 +698,11 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state, def fake_open_document(self, session, doc): session.processes[f"soffice-{doc['file_id']}"] = FakeProcess() - monkeypatch.setattr(libreoffice_desktop.LibreOfficeDesktopManager, "_spawn_desktop_locked", fake_spawn) - monkeypatch.setattr(libreoffice_desktop.LibreOfficeDesktopManager, "_open_document_locked", fake_open_document) + monkeypatch.setattr(desktop_session.DesktopSessionManager, "_spawn_desktop_locked", fake_spawn) + monkeypatch.setattr(desktop_session.DesktopSessionManager, "_open_document_locked", fake_open_document) doc = document_store.create_document("spreadsheet", "Official Sheet", "ods", "Name,Value\nA,1") - manager = libreoffice_desktop.LibreOfficeDesktopManager() + manager = desktop_session.DesktopSessionManager() payload = manager.open(doc) assert payload["available"] is True @@ -792,7 +851,7 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state, assert "xmessage" in shutdown_script assert '"-buttons",' in shutdown_script desktop_helper = ( - PROJECT_ROOT / "plugins" / "_office" / "helpers" / "libreoffice_desktop.py" + PROJECT_ROOT / "plugins" / "_desktop" / "helpers" / "desktop_session.py" ).read_text(encoding="utf-8") assert "_refresh_xfce_desktop" in desktop_helper assert "DBUS_SESSION_BUS_ADDRESS" in desktop_helper @@ -803,7 +862,7 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state, / payload["session_id"] / ".config" / "autostart" - / "agent-zero-office-desktop.desktop" + / "agent-zero-desktop.desktop" ) assert "prepare-xfce-profile.sh" in autostart.read_text(encoding="utf-8") profile_script = ( @@ -841,31 +900,31 @@ def test_official_libreoffice_desktop_manager_opens_binary_session(office_state, ).read_text(encoding="utf-8") assert "NoDisplay=true" in entry assert "Hidden=true" in entry - assert manager.proxy_for_token(payload["token"]) == ("127.0.0.1", libreoffice_desktop.XPRA_PORT_BASE) + assert manager.proxy_for_token(payload["token"]) == ("127.0.0.1", desktop_session.XPRA_PORT_BASE) assert manager.close(payload["session_id"], save_first=False)["closed"] == 0 assert manager.close(payload["session_id"], save_first=False)["persistent"] is True def test_shutdown_panel_launcher_requires_second_click(tmp_path): - profile_dir = tmp_path / "desktop" / "profiles" / libreoffice_desktop.SYSTEM_SESSION_ID + profile_dir = tmp_path / "desktop" / "profiles" / desktop_session.SYSTEM_SESSION_ID profile_dir.mkdir(parents=True) desktop_path = tmp_path / "workdir" desktop_path.mkdir() - session = libreoffice_desktop.DesktopSession( - session_id=libreoffice_desktop.SYSTEM_SESSION_ID, - file_id=libreoffice_desktop.SYSTEM_FILE_ID, + session = desktop_session.DesktopSession( + session_id=desktop_session.SYSTEM_SESSION_ID, + file_id=desktop_session.SYSTEM_FILE_ID, extension="desktop", path=str(desktop_path), - title=libreoffice_desktop.SYSTEM_TITLE, - display=libreoffice_desktop.DISPLAY_BASE, - xpra_port=libreoffice_desktop.XPRA_PORT_BASE, - token=libreoffice_desktop.SYSTEM_SESSION_ID, + title=desktop_session.SYSTEM_TITLE, + display=desktop_session.DISPLAY_BASE, + xpra_port=desktop_session.XPRA_PORT_BASE, + token=desktop_session.SYSTEM_SESSION_ID, url="/desktop/session/agent-zero-desktop/index.html", profile_dir=profile_dir, ) - script = libreoffice_desktop._write_shutdown_bridge_script(session) - request = libreoffice_desktop._shutdown_request_path(session) - arm = libreoffice_desktop._shutdown_arm_path(session) + script = desktop_session._write_shutdown_bridge_script(session) + request = desktop_session._shutdown_request_path(session) + arm = desktop_session._shutdown_arm_path(session) env = dict(os.environ) env.pop("DISPLAY", None) @@ -882,7 +941,7 @@ def test_shutdown_panel_launcher_requires_second_click(tmp_path): assert not arm.exists() -def test_libreoffice_desktop_sync_consumes_shutdown_marker(tmp_path, monkeypatch): +def test_desktop_session_sync_consumes_shutdown_marker(tmp_path, monkeypatch): class FakeProcess: pid = 5252 terminated = False @@ -900,32 +959,32 @@ def test_libreoffice_desktop_sync_consumes_shutdown_marker(tmp_path, monkeypatch def kill(self): self.terminated = True - monkeypatch.setattr(libreoffice_desktop, "STATE_DIR", tmp_path / "desktop") - monkeypatch.setattr(libreoffice_desktop, "SESSION_DIR", tmp_path / "desktop" / "sessions") - monkeypatch.setattr(libreoffice_desktop, "PROFILE_DIR", tmp_path / "desktop" / "profiles") + monkeypatch.setattr(desktop_session, "STATE_DIR", tmp_path / "desktop") + monkeypatch.setattr(desktop_session, "SESSION_DIR", tmp_path / "desktop" / "sessions") + monkeypatch.setattr(desktop_session, "PROFILE_DIR", tmp_path / "desktop" / "profiles") - profile_dir = tmp_path / "desktop" / "profiles" / libreoffice_desktop.SYSTEM_SESSION_ID + profile_dir = tmp_path / "desktop" / "profiles" / desktop_session.SYSTEM_SESSION_ID profile_dir.mkdir(parents=True) desktop_path = tmp_path / "workdir" desktop_path.mkdir() - session = libreoffice_desktop.DesktopSession( - session_id=libreoffice_desktop.SYSTEM_SESSION_ID, - file_id=libreoffice_desktop.SYSTEM_FILE_ID, + session = desktop_session.DesktopSession( + session_id=desktop_session.SYSTEM_SESSION_ID, + file_id=desktop_session.SYSTEM_FILE_ID, extension="desktop", path=str(desktop_path), - title=libreoffice_desktop.SYSTEM_TITLE, - display=libreoffice_desktop.DISPLAY_BASE, - xpra_port=libreoffice_desktop.XPRA_PORT_BASE, - token=libreoffice_desktop.SYSTEM_SESSION_ID, + title=desktop_session.SYSTEM_TITLE, + display=desktop_session.DISPLAY_BASE, + xpra_port=desktop_session.XPRA_PORT_BASE, + token=desktop_session.SYSTEM_SESSION_ID, url="/desktop/session/agent-zero-desktop/index.html", profile_dir=profile_dir, processes={"xpra": FakeProcess()}, ) - manager = libreoffice_desktop.LibreOfficeDesktopManager() + manager = desktop_session.DesktopSessionManager() manager._sessions[session.session_id] = session manager._write_manifest(session) - libreoffice_desktop._write_url_bridge_script(session) - shutdown_request = libreoffice_desktop._shutdown_request_path(session) + desktop_session._write_url_bridge_script(session) + shutdown_request = desktop_session._shutdown_request_path(session) shutdown_request.write_text('{"source": "tray", "created_at": 123.0}\n', encoding="utf-8") save_calls = [] monkeypatch.setattr( @@ -940,56 +999,177 @@ def test_libreoffice_desktop_sync_consumes_shutdown_marker(tmp_path, monkeypatch assert result["intentional_shutdown"] is True assert result["source"] == "tray" assert result["closed"] == 1 - assert save_calls == [(libreoffice_desktop.SYSTEM_SESSION_ID, "")] + assert save_calls == [(desktop_session.SYSTEM_SESSION_ID, "")] assert not shutdown_request.exists() - assert not (libreoffice_desktop.SESSION_DIR / f"{session.session_id}.json").exists() + assert not (desktop_session.SESSION_DIR / f"{session.session_id}.json").exists() assert manager.get(session.session_id) is None -def test_libreoffice_desktop_cleanup_preserves_live_owner_manifest(tmp_path, monkeypatch): +def test_desktop_session_cleanup_preserves_live_owner_manifest(tmp_path, monkeypatch): session_dir = tmp_path / "sessions" + legacy_session_dir = tmp_path / "legacy-sessions" session_dir.mkdir() + legacy_session_dir.mkdir() manifest = session_dir / "live.json" manifest.write_text( json.dumps({"owner_pid": os.getpid(), "pids": {"xpra": 987654}}), encoding="utf-8", ) - monkeypatch.setattr(libreoffice_desktop, "SESSION_DIR", session_dir) + legacy_manifest = legacy_session_dir / "stale.json" + legacy_manifest.write_text( + json.dumps({"owner_pid": 987650, "pids": {"xpra": 987651, "xfce": 987652}}), + encoding="utf-8", + ) + monkeypatch.setattr(desktop_session, "SESSION_DIR", session_dir) + monkeypatch.setattr(desktop_session, "LEGACY_SESSION_DIRS", (legacy_session_dir,)) + killed = [] + + def fake_kill_pid(pid): + killed.append(pid) + return True + monkeypatch.setattr( - libreoffice_desktop, + desktop_session, "_kill_pid", - lambda _pid: pytest.fail("cleanup should not kill a desktop owned by a live UI process"), + fake_kill_pid, ) - result = libreoffice_desktop.cleanup_stale_runtime_state() + result = desktop_session.cleanup_stale_runtime_state() - assert result["killed"] == [] + assert result["killed"] == [987651, 987652] + assert killed == [987651, 987652] assert manifest.exists() + assert not legacy_manifest.exists() -def test_libreoffice_desktop_removes_stale_lock_file(tmp_path): +def test_desktop_session_removes_stale_lock_file(tmp_path): doc_path = tmp_path / "Deck.pptx" doc_path.write_text("pptx", encoding="utf-8") lock_path = tmp_path / ".~lock.Deck.pptx#" lock_path.write_text("stale", encoding="utf-8") - session = libreoffice_desktop.DesktopSession( + session = desktop_session.DesktopSession( session_id="session", file_id="file", extension="pptx", path=str(doc_path), title=doc_path.name, - display=libreoffice_desktop.DISPLAY_BASE, - xpra_port=libreoffice_desktop.XPRA_PORT_BASE, + display=desktop_session.DISPLAY_BASE, + xpra_port=desktop_session.XPRA_PORT_BASE, token="token", url="/desktop/session/token/index.html", profile_dir=tmp_path / "profile", ) - libreoffice_desktop.LibreOfficeDesktopManager()._remove_stale_lock_file(session) + desktop_session.DesktopSessionManager()._remove_stale_lock_file(session) assert not lock_path.exists() +def _isolate_office_cleanup_hook(monkeypatch, tmp_path): + monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", tmp_path / "missing.sources") + monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", tmp_path / "missing.gpg") + monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf") + monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", []) + monkeypatch.setattr(hooks, "CLEANUP_MARKER", tmp_path / "state" / "cleanup.done") + monkeypatch.setattr(hooks, "_installed_packages", lambda packages: []) + monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None) + monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None) + monkeypatch.setattr(hooks, "_purge_packages", lambda removed, errors, **kwargs: None) + monkeypatch.setattr(hooks.shutil, "which", lambda name: "") + + +def test_cleanup_hook_migrates_legacy_document_state_without_removing_source(tmp_path, monkeypatch): + _isolate_office_cleanup_hook(monkeypatch, tmp_path) + legacy_documents = tmp_path / "usr" / "plugins" / "_office" / "documents" + document_state = tmp_path / "usr" / "_office" / "documents" + legacy_documents.mkdir(parents=True) + (legacy_documents / "documents.sqlite3").write_text("legacy-db\n", encoding="utf-8") + (legacy_documents / "backups").mkdir() + (legacy_documents / "backups" / "draft.md").write_text("backup\n", encoding="utf-8") + + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", document_state) + monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [legacy_documents]) + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None) + + result = hooks.cleanup_stale_runtime_state(force=True) + + assert result["ok"] is True + assert result["migrated"] == [f"{legacy_documents} -> {document_state}"] + assert (legacy_documents / "documents.sqlite3").exists() + assert (document_state / "documents.sqlite3").read_text(encoding="utf-8") == "legacy-db\n" + assert (document_state / "backups" / "draft.md").read_text(encoding="utf-8") == "backup\n" + + +def test_cleanup_hook_prefers_existing_new_document_state_without_merge(tmp_path, monkeypatch): + _isolate_office_cleanup_hook(monkeypatch, tmp_path) + legacy_documents = tmp_path / "legacy-documents" + document_state = tmp_path / "usr" / "_office" / "documents" + legacy_documents.mkdir(parents=True) + document_state.mkdir(parents=True) + (legacy_documents / "documents.sqlite3").write_text("legacy-db\n", encoding="utf-8") + (document_state / "documents.sqlite3").write_text("new-db\n", encoding="utf-8") + + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", document_state) + monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [legacy_documents]) + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None) + + result = hooks.cleanup_stale_runtime_state(force=True) + + assert result["ok"] is True + assert result["migrated"] == [] + assert result["warnings"] == [ + f"Legacy Office document state left in place because {document_state} already exists: {legacy_documents}" + ] + assert (legacy_documents / "documents.sqlite3").read_text(encoding="utf-8") == "legacy-db\n" + assert (document_state / "documents.sqlite3").read_text(encoding="utf-8") == "new-db\n" + + +def test_office_hook_desktop_compat_forwards_runtime_result(monkeypatch): + monkeypatch.setattr( + desktop_hooks, + "cleanup_stale_runtime_state", + lambda: { + "installed": ["xpra-server"], + "removed": ["firefox-esr"], + "warnings": ["desktop warning"], + "errors": ["desktop error"], + }, + ) + installed = [] + removed = [] + warnings = [] + errors = [] + + hooks._ensure_desktop_runtime_compat(installed, removed, warnings, errors) + + assert installed == ["xpra-server"] + assert removed == ["firefox-esr"] + assert warnings == ["desktop warning"] + assert errors == ["desktop error"] + + +def test_cleanup_hook_delegates_desktop_runtime_for_legacy_self_update(tmp_path, monkeypatch): + _isolate_office_cleanup_hook(monkeypatch, tmp_path) + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "_office" / "documents") + monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", []) + calls = [] + + def fake_desktop_compat(installed, removed, warnings, errors): + calls.append("desktop") + installed.append("xpra-server") + removed.append("firefox-esr") + warnings.append("desktop runtime prepared through office compatibility hook") + + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", fake_desktop_compat) + + result = hooks.cleanup_stale_runtime_state(force=True) + + assert calls == ["desktop"] + assert result["installed"] == ["xpra-server"] + assert result["removed"] == ["firefox-esr"] + assert result["warnings"] == ["desktop runtime prepared through office compatibility hook"] + + def test_cleanup_hook_removes_stale_runtime_state_idempotently(tmp_path, monkeypatch): source = tmp_path / "sources.list.d" / "retired.sources" keyring = tmp_path / "keyrings" / "retired.gpg" @@ -1003,17 +1183,20 @@ def test_cleanup_hook_removes_stale_runtime_state_idempotently(tmp_path, monkeyp (runtime_dir / "nested").mkdir(parents=True, exist_ok=True) (runtime_dir / "nested" / "state.txt").write_text("old\n", encoding="utf-8") - monkeypatch.setattr(hooks, "APT_SOURCE_FILE", source) - monkeypatch.setattr(hooks, "APT_KEYRING_FILE", keyring) - monkeypatch.setattr(hooks, "SUPERVISOR_FILE", supervisor) - monkeypatch.setattr(hooks, "RUNTIME_DIRS", [runtime_dir]) + monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", source) + monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", keyring) + monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", supervisor) + monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [runtime_dir]) monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker) + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "_office" / "documents") + monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", []) monkeypatch.setattr(hooks, "_installed_packages", lambda packages: []) monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None) + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None) def fake_ensure(installed, errors): assert not source.exists() - installed.append("xpra") + installed.append("libreoffice-core") def fake_purge(removed, errors, **kwargs): return None @@ -1026,7 +1209,7 @@ def test_cleanup_hook_removes_stale_runtime_state_idempotently(tmp_path, monkeyp skipped = hooks.cleanup_stale_runtime_state() assert first["ok"] is True - assert first["installed"] == ["xpra"] + assert first["installed"] == ["libreoffice-core"] assert second["ok"] is True assert skipped["skipped"] is True assert not source.exists() @@ -1037,12 +1220,8 @@ def test_cleanup_hook_removes_stale_runtime_state_idempotently(tmp_path, monkeyp def test_office_startup_defers_persistent_desktop_runtime(monkeypatch): - calls = [] cleanup_calls = [] started_threads = [] - routes_module = types.ModuleType("plugins._office.helpers.libreoffice_desktop_routes") - routes_module.install_route_hooks = lambda: calls.append("routes") - monkeypatch.setitem(sys.modules, "plugins._office.helpers.libreoffice_desktop_routes", routes_module) monkeypatch.delitem( sys.modules, "plugins._office.extensions.python.startup_migration._20_office_routes", @@ -1073,12 +1252,11 @@ def test_office_startup_defers_persistent_desktop_runtime(monkeypatch): office_startup.OfficeStartupCleanup(agent=None).execute() - assert calls == ["routes"] assert cleanup_calls == [] assert len(started_threads) == 1 - assert started_threads[0].name == "a0-office-runtime-preparation" + assert started_threads[0].name == "a0-office-document-runtime-preparation" assert started_threads[0].daemon is True - assert not hasattr(office_startup, "libreoffice_desktop") + assert not hasattr(office_startup, "desktop_session") started_threads[0].target() assert cleanup_calls == ["cleanup"] @@ -1089,14 +1267,17 @@ def test_cleanup_hook_reruns_when_stale_packages_exist_after_old_marker(tmp_path marker.parent.mkdir(parents=True) marker.write_text("old\n", encoding="utf-8") - monkeypatch.setattr(hooks, "APT_SOURCE_FILE", tmp_path / "missing.sources") - monkeypatch.setattr(hooks, "APT_KEYRING_FILE", tmp_path / "missing.gpg") - monkeypatch.setattr(hooks, "SUPERVISOR_FILE", tmp_path / "missing.conf") - monkeypatch.setattr(hooks, "RUNTIME_DIRS", []) + monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", tmp_path / "missing.sources") + monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", tmp_path / "missing.gpg") + monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf") + monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", []) monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker) + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "_office" / "documents") + monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", []) monkeypatch.setattr(hooks, "_installed_packages", lambda packages: ["coolwsd"]) monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None) monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None) + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None) def fake_purge(removed, errors, **kwargs): removed.extend(kwargs["installed_packages"]) @@ -1115,19 +1296,21 @@ def test_cleanup_hook_removes_retired_supervisor_program_after_marker(tmp_path, marker.write_text("ok\n", encoding="utf-8") calls = [] - monkeypatch.setattr(hooks, "APT_SOURCE_FILE", tmp_path / "missing.sources") - monkeypatch.setattr(hooks, "APT_KEYRING_FILE", tmp_path / "missing.gpg") - monkeypatch.setattr(hooks, "SUPERVISOR_FILE", tmp_path / "missing.conf") - monkeypatch.setattr(hooks, "RUNTIME_DIRS", []) + monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", tmp_path / "missing.sources") + monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", tmp_path / "missing.gpg") + monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf") + monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", []) monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker) + monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "_office" / "documents") + monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", []) monkeypatch.setattr(hooks, "_installed_packages", lambda packages: []) monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None) - monkeypatch.setattr(hooks, "_cleanup_desktop_sessions", lambda errors: None) + monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None) monkeypatch.setattr(hooks.shutil, "which", lambda name: "/usr/bin/supervisorctl" if name == "supervisorctl" else "") def fake_supervisorctl(*args): calls.append(args) - if args == ("status", hooks.SUPERVISOR_PROGRAM): + if args == ("status", hooks.RETIRED_WEB_SUPERVISOR_PROGRAM): return types.SimpleNamespace( returncode=0, stdout="a0_office_collabora BACKOFF can't find command\n", @@ -1143,22 +1326,22 @@ def test_cleanup_hook_removes_retired_supervisor_program_after_marker(tmp_path, assert result["skipped"] is True assert result["errors"] == [] assert calls == [ - ("status", hooks.SUPERVISOR_PROGRAM), - ("stop", hooks.SUPERVISOR_PROGRAM), - ("remove", hooks.SUPERVISOR_PROGRAM), + ("status", hooks.RETIRED_WEB_SUPERVISOR_PROGRAM), + ("stop", hooks.RETIRED_WEB_SUPERVISOR_PROGRAM), + ("remove", hooks.RETIRED_WEB_SUPERVISOR_PROGRAM), ("reread",), ("update",), ] -def test_cleanup_hook_installs_missing_libreoffice_desktop_dependencies(monkeypatch): +def test_cleanup_hook_installs_missing_desktop_session_dependencies(monkeypatch): calls = [] installed_state = {"xpra": False} - monkeypatch.setattr(hooks.os, "geteuid", lambda: 0) - monkeypatch.setattr(hooks.shutil, "which", lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query"} else "") - monkeypatch.setattr(hooks, "RUNTIME_PACKAGES", ("xpra",)) - monkeypatch.setattr(hooks, "_package_installed", lambda package: installed_state.get(package, False)) + monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0) + monkeypatch.setattr(desktop_hooks.shutil, "which", lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query"} else "") + monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", ("xpra",)) + monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False)) def fake_run(command, **kwargs): calls.append(command) @@ -1166,11 +1349,11 @@ def test_cleanup_hook_installs_missing_libreoffice_desktop_dependencies(monkeypa installed_state["xpra"] = True return types.SimpleNamespace(returncode=0, stdout="", stderr="") - monkeypatch.setattr(hooks.subprocess, "run", fake_run) + monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run) installed = [] errors = [] - hooks._ensure_runtime_dependencies(installed, errors) + desktop_hooks._ensure_runtime_dependencies(installed, errors) assert installed == ["xpra"] assert errors == [] @@ -1184,19 +1367,19 @@ def test_cleanup_hook_enables_official_xpra_repo_when_kali_lacks_candidate(tmp_p keyring = tmp_path / "keyrings" / "xpra.asc" source = tmp_path / "sources.list.d" / "xpra.sources" - monkeypatch.setattr(hooks.os, "geteuid", lambda: 0) + monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0) monkeypatch.setattr( - hooks.shutil, + desktop_hooks.shutil, "which", lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "", ) - monkeypatch.setattr(hooks, "RUNTIME_PACKAGES", ("xpra",)) - monkeypatch.setattr(hooks, "XPRA_KEYRING_FILE", keyring) - monkeypatch.setattr(hooks, "XPRA_SOURCE_FILE", source) - monkeypatch.setattr(hooks, "_download", lambda url: b"xpra-key") - monkeypatch.setattr(hooks, "_read_os_release", lambda: {"ID": "kali", "VERSION_CODENAME": "kali-rolling"}) - monkeypatch.setattr(hooks, "_dpkg_architecture", lambda: "amd64") - monkeypatch.setattr(hooks, "_package_installed", lambda package: installed_state.get(package, False)) + monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", ("xpra",)) + monkeypatch.setattr(desktop_hooks, "XPRA_KEYRING_FILE", keyring) + monkeypatch.setattr(desktop_hooks, "XPRA_SOURCE_FILE", source) + monkeypatch.setattr(desktop_hooks, "_download", lambda url: b"xpra-key") + monkeypatch.setattr(desktop_hooks, "_read_os_release", lambda: {"ID": "kali", "VERSION_CODENAME": "kali-rolling"}) + monkeypatch.setattr(desktop_hooks, "_dpkg_architecture", lambda: "amd64") + monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False)) def fake_run(command, **kwargs): calls.append(command) @@ -1206,11 +1389,11 @@ def test_cleanup_hook_enables_official_xpra_repo_when_kali_lacks_candidate(tmp_p installed_state["xpra"] = True return types.SimpleNamespace(returncode=0, stdout="", stderr="") - monkeypatch.setattr(hooks.subprocess, "run", fake_run) + monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run) installed = [] errors = [] - hooks._ensure_runtime_dependencies(installed, errors) + desktop_hooks._ensure_runtime_dependencies(installed, errors) assert errors == [] assert installed == ["xpra"] @@ -1227,19 +1410,19 @@ def test_cleanup_hook_uses_trixie_xpra_components_for_kali_arm64(tmp_path, monke keyring = tmp_path / "keyrings" / "xpra.asc" source = tmp_path / "sources.list.d" / "xpra.sources" - monkeypatch.setattr(hooks.os, "geteuid", lambda: 0) + monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0) monkeypatch.setattr( - hooks.shutil, + desktop_hooks.shutil, "which", lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "", ) - monkeypatch.setattr(hooks, "RUNTIME_PACKAGES", ("xpra-server", "xpra-x11", "xpra-html5")) - monkeypatch.setattr(hooks, "XPRA_KEYRING_FILE", keyring) - monkeypatch.setattr(hooks, "XPRA_SOURCE_FILE", source) - monkeypatch.setattr(hooks, "_download", lambda url: b"xpra-key") - monkeypatch.setattr(hooks, "_read_os_release", lambda: {"ID": "kali", "VERSION_CODENAME": "kali-rolling"}) - monkeypatch.setattr(hooks, "_dpkg_architecture", lambda: "arm64") - monkeypatch.setattr(hooks, "_package_installed", lambda package: installed_state.get(package, False)) + monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", ("xpra-server", "xpra-x11", "xpra-html5")) + monkeypatch.setattr(desktop_hooks, "XPRA_KEYRING_FILE", keyring) + monkeypatch.setattr(desktop_hooks, "XPRA_SOURCE_FILE", source) + monkeypatch.setattr(desktop_hooks, "_download", lambda url: b"xpra-key") + monkeypatch.setattr(desktop_hooks, "_read_os_release", lambda: {"ID": "kali", "VERSION_CODENAME": "kali-rolling"}) + monkeypatch.setattr(desktop_hooks, "_dpkg_architecture", lambda: "arm64") + monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False)) def fake_run(command, **kwargs): calls.append(command) @@ -1250,11 +1433,11 @@ def test_cleanup_hook_uses_trixie_xpra_components_for_kali_arm64(tmp_path, monke installed_state[package] = True return types.SimpleNamespace(returncode=0, stdout="", stderr="") - monkeypatch.setattr(hooks.subprocess, "run", fake_run) + monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run) installed = [] errors = [] - hooks._ensure_runtime_dependencies(installed, errors) + desktop_hooks._ensure_runtime_dependencies(installed, errors) assert errors == [] assert installed == ["xpra-server", "xpra-x11", "xpra-html5"] @@ -1281,18 +1464,18 @@ def test_cleanup_hook_skips_optional_xpra_client_codec_conflict(monkeypatch): " but none of the choices are installable: [no choices]" ) - monkeypatch.setattr(hooks.os, "geteuid", lambda: 0) + monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0) monkeypatch.setattr( - hooks.shutil, + desktop_hooks.shutil, "which", lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "", ) monkeypatch.setattr( - hooks, + desktop_hooks, "RUNTIME_PACKAGES", ("xpra-server", "xpra-client", "xpra-client-gtk3", "xpra-x11", "xpra-html5"), ) - monkeypatch.setattr(hooks, "_package_installed", lambda package: installed_state.get(package, False)) + monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False)) def fake_run(command, **kwargs): calls.append(command) @@ -1302,11 +1485,11 @@ def test_cleanup_hook_skips_optional_xpra_client_codec_conflict(monkeypatch): return types.SimpleNamespace(returncode=100, stdout="", stderr=codec_error) return types.SimpleNamespace(returncode=0, stdout="", stderr="") - monkeypatch.setattr(hooks.subprocess, "run", fake_run) + monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run) installed = [] errors = [] - hooks._ensure_runtime_dependencies(installed, errors) + desktop_hooks._ensure_runtime_dependencies(installed, errors) assert installed == [] assert errors == [] @@ -1321,14 +1504,14 @@ def test_cleanup_hook_reports_required_xpra_codec_conflict(monkeypatch): " but none of the choices are installable: [no choices]" ) - monkeypatch.setattr(hooks.os, "geteuid", lambda: 0) + monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0) monkeypatch.setattr( - hooks.shutil, + desktop_hooks.shutil, "which", lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "", ) - monkeypatch.setattr(hooks, "RUNTIME_PACKAGES", ("xpra-server",)) - monkeypatch.setattr(hooks, "_package_installed", lambda package: False) + monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", ("xpra-server",)) + monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: False) def fake_run(command, **kwargs): if command[:2] == ["apt-cache", "policy"]: @@ -1337,11 +1520,11 @@ def test_cleanup_hook_reports_required_xpra_codec_conflict(monkeypatch): return types.SimpleNamespace(returncode=100, stdout="", stderr=codec_error) return types.SimpleNamespace(returncode=0, stdout="", stderr="") - monkeypatch.setattr(hooks.subprocess, "run", fake_run) + monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run) installed = [] errors = [] - hooks._ensure_runtime_dependencies(installed, errors) + desktop_hooks._ensure_runtime_dependencies(installed, errors) assert installed == [] assert errors == [codec_error] diff --git a/tests/test_skills_runtime.py b/tests/test_skills_runtime.py index 8a56906fa..d6c31e605 100644 --- a/tests/test_skills_runtime.py +++ b/tests/test_skills_runtime.py @@ -8,6 +8,15 @@ import pytest PROJECT_ROOT = Path(__file__).resolve().parents[1] SKILLS_HELPER_PATH = PROJECT_ROOT / "helpers" / "skills.py" +HELPER_STUB_MODULES = ( + "helpers", + "helpers.files", + "helpers.projects", + "helpers.plugins", + "helpers.subagents", + "helpers.file_tree", + "helpers.runtime", +) def _register_helpers_stubs(): @@ -61,13 +70,22 @@ def _register_helpers_stubs(): def _load_skills_helper_module(): + missing = object() + original_modules = {name: sys.modules.get(name, missing) for name in HELPER_STUB_MODULES} _register_helpers_stubs() - spec = importlib.util.spec_from_file_location("test_skills_helper_module", SKILLS_HELPER_PATH) - module = importlib.util.module_from_spec(spec) - assert spec and spec.loader - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module + try: + spec = importlib.util.spec_from_file_location("test_skills_helper_module", SKILLS_HELPER_PATH) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + finally: + for name, original in original_modules.items(): + if original is missing: + sys.modules.pop(name, None) + else: + sys.modules[name] = original runtime = _load_skills_helper_module() @@ -191,6 +209,73 @@ def test_loaded_skill_entries_come_from_agent_data(): ] +def test_skill_runtime_does_not_alias_old_office_skill_references(): + entries = runtime.normalize_active_skills( + [ + "office-artifacts", + {"name": "word-documents"}, + {"path": "/a0/plugins/_office/skills/excel-workbooks"}, + {"name": "Desktop", "path": "/a0/plugins/_office/skills/linux-desktop"}, + "presentation-decks", + ] + ) + + assert entries == [ + {"name": "office-artifacts"}, + {"name": "word-documents"}, + {"path": "/a0/plugins/_office/skills/excel-workbooks"}, + {"name": "Desktop", "path": "/a0/plugins/_office/skills/linux-desktop"}, + {"name": "presentation-decks"}, + ] + + agent = DummyAgent() + agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] = [ + "office-artifacts", + "word-documents", + "excel-workbooks", + "presentation-decks", + ] + + assert runtime.get_loaded_skill_entries(agent) == [ + {"name": "office-artifacts"}, + {"name": "word-documents"}, + {"name": "excel-workbooks"}, + {"name": "presentation-decks"}, + ] + + assert runtime.unload_agent_skill(agent, {"name": "office-artifacts"}) is True + assert agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] == [ + "word-documents", + "excel-workbooks", + "presentation-decks", + ] + + +def test_builtin_plugin_skill_delete_is_rejected_before_filesystem_delete(): + with pytest.raises(PermissionError, match="Built-in plugin skills cannot be deleted"): + runtime.delete_skill("/a0/plugins/_office/skills/document-artifacts") + + +def test_invalid_skill_frontmatter_reports_yaml_errors(): + frontmatter, errors = runtime.parse_frontmatter("name: [unterminated\n") + + assert frontmatter == {} + assert errors + assert errors[0].startswith("Invalid YAML frontmatter") + + +def test_a0_manage_plugin_skill_frontmatter_is_valid_yaml(): + text = (PROJECT_ROOT / "skills" / "a0-manage-plugin" / "SKILL.md").read_text( + encoding="utf-8" + ) + + frontmatter, body, errors = runtime.split_frontmatter(text) + + assert errors == [] + assert frontmatter["name"] == "a0-manage-plugin" + assert "Agent Zero Plugin Management" in body + + def test_unload_agent_skill_removes_loaded_skill_by_name(): agent = DummyAgent() agent.data[runtime.AGENT_DATA_NAME_LOADED_SKILLS] = [