| 1 | from __future__ import annotations |
| 2 | |
| 3 | import asyncio |
| 4 | import importlib.util |
| 5 | import json |
| 6 | import os |
| 7 | import subprocess |
| 8 | import sys |
| 9 | import types |
| 10 | import zipfile |
| 11 | import xml.etree.ElementTree as ET |
| 12 | from pathlib import Path |
| 13 | |
| 14 | import pytest |
| 15 | |
| 16 | PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| 17 | if str(PROJECT_ROOT) not in sys.path: |
| 18 | sys.path.insert(0, str(PROJECT_ROOT)) |
| 19 | |
| 20 | from helpers import system_packages |
| 21 | from plugins._office import hooks |
| 22 | from plugins._desktop import hooks as desktop_hooks |
| 23 | from plugins._desktop.helpers import desktop_session |
| 24 | from plugins._editor.helpers import ( |
| 25 | markdown_sessions as editor_markdown_sessions, |
| 26 | open_files_context, |
| 27 | ) |
| 28 | from plugins._editor.api.editor_session import EditorSession |
| 29 | from plugins._office.helpers import ( |
| 30 | artifact_editor, |
| 31 | canvas_context, |
| 32 | document_store, |
| 33 | libreoffice, |
| 34 | ) |
| 35 | |
| 36 | |
| 37 | @pytest.fixture |
| 38 | def office_state(tmp_path, monkeypatch): |
| 39 | state = tmp_path / "state" |
| 40 | backups = state / "backups" |
| 41 | workdir = tmp_path / "workdir" |
| 42 | documents = workdir / "documents" |
| 43 | projects_parent = tmp_path / "projects" |
| 44 | |
| 45 | monkeypatch.setattr(document_store, "STATE_DIR", state) |
| 46 | monkeypatch.setattr(document_store, "DB_PATH", state / "documents.sqlite3") |
| 47 | monkeypatch.setattr(document_store, "BACKUP_DIR", backups) |
| 48 | monkeypatch.setattr(document_store, "WORKDIR", workdir) |
| 49 | monkeypatch.setattr(document_store, "DOCUMENTS_DIR", documents) |
| 50 | settings_helpers = types.SimpleNamespace(get_settings=lambda: {"workdir_path": str(workdir)}) |
| 51 | project_helpers = types.SimpleNamespace( |
| 52 | get_context_project_name=lambda context: None, |
| 53 | get_project_folder=lambda name: str(projects_parent / name), |
| 54 | get_projects_parent_folder=lambda: str(projects_parent), |
| 55 | ) |
| 56 | monkeypatch.setattr(document_store, "_settings", lambda: settings_helpers) |
| 57 | monkeypatch.setattr(document_store, "_projects", lambda: project_helpers) |
| 58 | monkeypatch.setattr( |
| 59 | editor_markdown_sessions, |
| 60 | "_manager", |
| 61 | editor_markdown_sessions.MarkdownSessionManager(), |
| 62 | raising=False, |
| 63 | ) |
| 64 | |
| 65 | workdir.mkdir(parents=True, exist_ok=True) |
| 66 | documents.mkdir(parents=True, exist_ok=True) |
| 67 | projects_parent.mkdir(parents=True, exist_ok=True) |
| 68 | document_store.ensure_dirs() |
| 69 | return types.SimpleNamespace( |
| 70 | state=state, |
| 71 | backups=backups, |
| 72 | workdir=workdir, |
| 73 | documents=documents, |
| 74 | projects_parent=projects_parent, |
| 75 | project_helpers=project_helpers, |
| 76 | ) |
| 77 | |
| 78 | |
| 79 | def test_document_store_create_defaults_to_markdown(office_state): |
| 80 | doc = document_store.create_document("document", "Research Note", content="A precise note.") |
| 81 | |
| 82 | assert doc["extension"] == "md" |
| 83 | assert Path(doc["path"]).parent == office_state.workdir |
| 84 | assert Path(doc["path"]).read_text(encoding="utf-8").startswith("# Research Note") |
| 85 | |
| 86 | |
| 87 | def test_text_files_register_as_editor_documents(office_state): |
| 88 | path = office_state.workdir / "plain-note.txt" |
| 89 | path.write_text("Plain text belongs in the Editor surface.\n", encoding="utf-8") |
| 90 | |
| 91 | doc = document_store.register_document(path) |
| 92 | |
| 93 | assert doc["extension"] == "txt" |
| 94 | assert "txt" in document_store.EDITOR_TEXT_EXTENSIONS |
| 95 | assert "txt" not in desktop_session.OFFICIAL_EXTENSIONS |
| 96 | |
| 97 | |
| 98 | def test_file_browser_can_register_runtime_root_markdown(office_state, monkeypatch): |
| 99 | runtime_root = office_state.state.parent / "runtime-root" |
| 100 | runtime_root.mkdir() |
| 101 | path = runtime_root / "AGENTS.md" |
| 102 | path.write_text("# Runtime Instructions\n", encoding="utf-8") |
| 103 | monkeypatch.setattr(document_store.files, "get_base_dir", lambda: str(runtime_root)) |
| 104 | |
| 105 | with pytest.raises(PermissionError, match="active project or workdir"): |
| 106 | document_store.register_document(path) |
| 107 | |
| 108 | doc = document_store.register_document(path, allow_base_dir=True) |
| 109 | |
| 110 | assert doc["basename"] == "AGENTS.md" |
| 111 | assert doc["path"] == str(path) |
| 112 | |
| 113 | |
| 114 | def test_editor_file_browser_source_opens_runtime_root_markdown(office_state, monkeypatch): |
| 115 | runtime_root = office_state.state.parent / "runtime-root" |
| 116 | runtime_root.mkdir() |
| 117 | path = runtime_root / "AGENTS.md" |
| 118 | path.write_text("# Runtime Instructions\n", encoding="utf-8") |
| 119 | monkeypatch.setattr(document_store.files, "get_base_dir", lambda: str(runtime_root)) |
| 120 | handler = EditorSession(app=None, thread_lock=None) |
| 121 | request = types.SimpleNamespace(headers={}, host_url="http://localhost/") |
| 122 | |
| 123 | blocked = asyncio.run(handler.process({"action": "open", "path": str(path)}, request)) |
| 124 | opened = asyncio.run(handler.process({ |
| 125 | "action": "open", |
| 126 | "path": str(path), |
| 127 | "source": "file-browser", |
| 128 | }, request)) |
| 129 | |
| 130 | assert blocked["ok"] is False |
| 131 | assert "active project or workdir" in blocked["error"] |
| 132 | assert opened["ok"] is True |
| 133 | assert opened["title"] == "AGENTS.md" |
| 134 | assert opened["text"] == "# Runtime Instructions\n" |
| 135 | |
| 136 | |
| 137 | @pytest.mark.parametrize( |
| 138 | ("kind", "title", "fmt", "expected_name"), |
| 139 | [ |
| 140 | ("document", "real-chat-canvas-smoke.md", "md", "real-chat-canvas-smoke.md"), |
| 141 | ("document", "Board Memo.ODT", "odt", "Board Memo.odt"), |
| 142 | ("spreadsheet", "Budget.ods", "ods", "Budget.ods"), |
| 143 | ("presentation", "Roadmap.odp", "odp", "Roadmap.odp"), |
| 144 | ], |
| 145 | ) |
| 146 | def test_create_document_does_not_duplicate_matching_extension(office_state, kind, title, fmt, expected_name): |
| 147 | doc = document_store.create_document(kind, title, fmt, content="Smoke") |
| 148 | |
| 149 | assert Path(doc["path"]).name == expected_name |
| 150 | |
| 151 | |
| 152 | def test_explicit_docx_creates_valid_word_package(office_state): |
| 153 | doc = document_store.create_document("document", "Board Memo", "docx", "A careful memo.") |
| 154 | |
| 155 | assert doc["extension"] == "docx" |
| 156 | assert Path(doc["path"]).parent == office_state.documents |
| 157 | assert libreoffice.validate_docx(doc["path"])["ok"] is True |
| 158 | with zipfile.ZipFile(doc["path"]) as archive: |
| 159 | assert "word/document.xml" in archive.namelist() |
| 160 | |
| 161 | |
| 162 | def test_odf_formats_create_valid_libreoffice_packages(office_state): |
| 163 | writer = document_store.create_document("document", "Board Memo", "odt", "A careful memo.") |
| 164 | sheet = document_store.create_document("spreadsheet", "Budget", "ods", "Name,Amount\nPlatform,1000") |
| 165 | deck = document_store.create_document("presentation", "Roadmap", "odp", "Roadmap\nLaunch sequence") |
| 166 | |
| 167 | assert writer["extension"] == "odt" |
| 168 | assert sheet["extension"] == "ods" |
| 169 | assert deck["extension"] == "odp" |
| 170 | assert Path(writer["path"]).parent == office_state.documents |
| 171 | assert libreoffice.validate_odf(writer["path"])["ok"] is True |
| 172 | assert libreoffice.validate_odf(sheet["path"])["ok"] is True |
| 173 | assert libreoffice.validate_odf(deck["path"])["ok"] is True |
| 174 | assert artifact_editor.read_artifact(writer)["text"].startswith("Board Memo") |
| 175 | assert artifact_editor.read_artifact(sheet)["sheets"][0]["preview_rows"][1][1] == 1000 |
| 176 | assert artifact_editor.read_artifact(deck)["slides"][0]["title"] == "Roadmap" |
| 177 | |
| 178 | |
| 179 | def test_blank_docx_includes_editable_body_paragraph(office_state): |
| 180 | doc = document_store.create_document("document", "Blank Memo", "docx", "") |
| 181 | with zipfile.ZipFile(doc["path"]) as archive: |
| 182 | xml = archive.read("word/document.xml").decode("utf-8") |
| 183 | root = ET.fromstring(xml) |
| 184 | |
| 185 | word_ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" |
| 186 | assert len(list(root.iter(f"{{{word_ns}}}p"))) >= 2 |
| 187 | assert 'xml:space="preserve"> </w:t>' in xml |
| 188 | |
| 189 | |
| 190 | def test_odf_and_ooxml_creation_and_direct_edits_still_work(office_state): |
| 191 | odt = document_store.create_document("document", "Writer Memo", "odt", "Old phrase") |
| 192 | updated_odt, odt_payload = artifact_editor.edit_artifact( |
| 193 | odt, |
| 194 | operation="replace_text", |
| 195 | find="Old phrase", |
| 196 | replace="New phrase", |
| 197 | ) |
| 198 | odt_read = artifact_editor.read_artifact(updated_odt) |
| 199 | |
| 200 | assert odt_payload["changed"] is True |
| 201 | assert "New phrase" in odt_read["text"] |
| 202 | |
| 203 | ods = document_store.create_document( |
| 204 | "spreadsheet", |
| 205 | "Budget ODS", |
| 206 | "ods", |
| 207 | "Name,Amount\nPlatform,1000", |
| 208 | ) |
| 209 | updated_ods, ods_payload = artifact_editor.edit_artifact( |
| 210 | ods, |
| 211 | operation="set_cells", |
| 212 | cells={"Sheet1!B2": 12500, "Sheet1!A3": "Research", "Sheet1!B3": 4700}, |
| 213 | ) |
| 214 | ods_read = artifact_editor.read_artifact(updated_ods) |
| 215 | ods_rows = ods_read["sheets"][0]["preview_rows"] |
| 216 | |
| 217 | assert ods_payload["changed"] is True |
| 218 | assert ods_rows[1][1] == 12500 |
| 219 | assert ods_rows[2][0] == "Research" |
| 220 | |
| 221 | |
| 222 | def test_office_artifact_helpers_reject_markdown_inputs(office_state): |
| 223 | doc = document_store.create_document("document", "Append Shapes", "md", "# Title\n\nBase") |
| 224 | |
| 225 | with pytest.raises(ValueError, match="use text_editor"): |
| 226 | artifact_editor.read_artifact(doc) |
| 227 | with pytest.raises(ValueError, match="use text_editor"): |
| 228 | artifact_editor.edit_artifact(doc, operation="set_text", content="# Updated") |
| 229 | |
| 230 | |
| 231 | def test_office_artifact_direct_edits_cover_presentations_spreadsheets_and_decks(office_state): |
| 232 | |
| 233 | odp = document_store.create_document( |
| 234 | "presentation", |
| 235 | "Roadmap ODP", |
| 236 | "odp", |
| 237 | "Roadmap\nLaunch sequence\n\n---\n\nNext\nPolish rollout", |
| 238 | ) |
| 239 | updated_odp, odp_payload = artifact_editor.edit_artifact( |
| 240 | odp, |
| 241 | operation="set_slides", |
| 242 | slides=[ |
| 243 | {"title": "Now", "bullets": ["Stabilize"]}, |
| 244 | {"title": "Next", "bullets": ["Polish"]}, |
| 245 | ], |
| 246 | ) |
| 247 | odp_read = artifact_editor.read_artifact(updated_odp) |
| 248 | |
| 249 | assert odp_payload["changed"] is True |
| 250 | assert odp_read["slide_count"] == 2 |
| 251 | assert odp_read["slides"][1]["title"] == "Next" |
| 252 | |
| 253 | sheet = document_store.create_document( |
| 254 | "spreadsheet", |
| 255 | "Budget", |
| 256 | "xlsx", |
| 257 | "Name,Amount\nPlatform,1000", |
| 258 | ) |
| 259 | updated_sheet, sheet_payload = artifact_editor.edit_artifact( |
| 260 | sheet, |
| 261 | operation="set_cells", |
| 262 | cells={"Sheet1!B2": 12500, "Sheet1!A3": "Research", "Sheet1!B3": 4700}, |
| 263 | ) |
| 264 | sheet_read = artifact_editor.read_artifact(updated_sheet) |
| 265 | rows = sheet_read["sheets"][0]["preview_rows"] |
| 266 | |
| 267 | assert sheet_payload["changed"] is True |
| 268 | assert rows[1][1] == 12500 |
| 269 | assert rows[2][0] == "Research" |
| 270 | |
| 271 | deck = document_store.create_document( |
| 272 | "presentation", |
| 273 | "Roadmap", |
| 274 | "pptx", |
| 275 | "Roadmap\nLaunch sequence\n\n---\n\nNext\nPolish rollout", |
| 276 | ) |
| 277 | created_deck_read = artifact_editor.read_artifact(deck) |
| 278 | with zipfile.ZipFile(deck["path"]) as archive: |
| 279 | created_slide_names = [name for name in archive.namelist() if name.startswith("ppt/slides/slide") and name.endswith(".xml")] |
| 280 | |
| 281 | assert created_deck_read["slide_count"] == 2 |
| 282 | assert created_deck_read["slides"][0]["title"] == "Roadmap" |
| 283 | assert created_deck_read["slides"][1]["title"] == "Next" |
| 284 | assert len(created_slide_names) == 2 |
| 285 | |
| 286 | updated_deck, deck_payload = artifact_editor.edit_artifact( |
| 287 | deck, |
| 288 | operation="set_slides", |
| 289 | slides=[ |
| 290 | {"title": "Now", "bullets": ["Stabilize"]}, |
| 291 | {"title": "Next", "bullets": ["Polish"]}, |
| 292 | ], |
| 293 | ) |
| 294 | deck_read = artifact_editor.read_artifact(updated_deck) |
| 295 | |
| 296 | assert deck_payload["changed"] is True |
| 297 | assert deck_read["slide_count"] == 2 |
| 298 | assert deck_read["slides"][1]["title"] == "Next" |
| 299 | |
| 300 | |
| 301 | def test_ods_direct_edit_preserves_rows_beyond_preview_window_and_blank_separators(office_state): |
| 302 | rows = [["Row", "Value"], ["alpha", 1], [], ["separator-survives", 2]] |
| 303 | rows.extend([[f"item-{index}", index] for index in range(4, 96)]) |
| 304 | doc = document_store.create_document("spreadsheet", "Long ODS", "ods", "") |
| 305 | updated, payload = artifact_editor.edit_artifact( |
| 306 | doc, |
| 307 | operation="set_rows", |
| 308 | rows=rows, |
| 309 | ) |
| 310 | updated, payload = artifact_editor.edit_artifact( |
| 311 | updated, |
| 312 | operation="set_cells", |
| 313 | cells={"Sheet1!B90": 9000}, |
| 314 | ) |
| 315 | parsed = artifact_editor._ods_sheets_from_bytes(Path(updated["path"]).read_bytes(), max_rows=120, max_cols=10) |
| 316 | |
| 317 | assert payload["changed"] is True |
| 318 | assert parsed[0]["rows"][2] == [] |
| 319 | assert parsed[0]["rows"][3][0] == "separator-survives" |
| 320 | assert parsed[0]["rows"][89][1] == 9000 |
| 321 | |
| 322 | |
| 323 | def test_office_artifact_creates_ods_with_action_contract(office_state, monkeypatch): |
| 324 | tool_module = types.ModuleType("helpers.tool") |
| 325 | |
| 326 | class Response: |
| 327 | def __init__(self, message, break_loop, additional=None): |
| 328 | self.message = message |
| 329 | self.break_loop = break_loop |
| 330 | self.additional = additional |
| 331 | |
| 332 | class Tool: |
| 333 | def __init__(self, agent, name, method, args, message, loop_data, **kwargs): |
| 334 | self.agent = agent |
| 335 | self.name = name |
| 336 | self.method = method |
| 337 | self.args = args |
| 338 | self.message = message |
| 339 | self.loop_data = loop_data |
| 340 | |
| 341 | tool_module.Response = Response |
| 342 | tool_module.Tool = Tool |
| 343 | monkeypatch.setitem(sys.modules, "helpers.tool", tool_module) |
| 344 | spec = importlib.util.spec_from_file_location( |
| 345 | "test_office_artifact_tool", |
| 346 | PROJECT_ROOT / "plugins" / "_office" / "tools" / "office_artifact.py", |
| 347 | ) |
| 348 | office_artifact_module = importlib.util.module_from_spec(spec) |
| 349 | assert spec and spec.loader |
| 350 | spec.loader.exec_module(office_artifact_module) |
| 351 | OfficeArtifact = office_artifact_module.OfficeArtifact |
| 352 | |
| 353 | tool = OfficeArtifact( |
| 354 | agent=None, |
| 355 | name="office_artifact", |
| 356 | method=None, |
| 357 | args={}, |
| 358 | message="", |
| 359 | loop_data=None, |
| 360 | ) |
| 361 | |
| 362 | response = asyncio.run( |
| 363 | tool.execute( |
| 364 | action="create", |
| 365 | kind="document", |
| 366 | title="New Calc Workbook", |
| 367 | format="ods", |
| 368 | content="Sheet1\n", |
| 369 | ) |
| 370 | ) |
| 371 | payload = json.loads(response.message) |
| 372 | |
| 373 | assert payload["action"] == "create" |
| 374 | assert payload["document"]["extension"] == "ods" |
| 375 | assert Path(payload["document"]["path"]).name == "New Calc Workbook.ods" |
| 376 | assert Path(document_store._path_from_a0(payload["document"]["path"])).exists() |
| 377 | |
| 378 | |
| 379 | def test_odf_is_advertised_and_docx_remains_explicit_compatibility(office_state): |
| 380 | prompt = (PROJECT_ROOT / "plugins" / "_office" / "prompts" / "agent.system.tool.office_artifact.md").read_text( |
| 381 | encoding="utf-8", |
| 382 | ) |
| 383 | |
| 384 | assert "formats: odt ods odp docx xlsx pptx" in prompt |
| 385 | assert "use `text_editor` for Markdown and plain text files" in prompt |
| 386 | assert "ODF is first-class for LibreOffice" in prompt |
| 387 | assert "DOCX/XLSX/PPTX are compatibility formats" in prompt |
| 388 | assert "`method` is accepted as an alias for action" not in prompt |
| 389 | assert "they do not open a surface automatically" in prompt |
| 390 | assert "do not write faux UI action labels" in prompt |
| 391 | assert '"Open document" or "Download file"' in prompt |
| 392 | assert "explicit Download, Open Document, or Desktop edit message actions" not in prompt |
| 393 | doc = document_store.create_document("document", "Use ODT", "odt", "") |
| 394 | assert doc["extension"] == "odt" |
| 395 | |
| 396 | |
| 397 | def test_project_scoped_creation_uses_active_project_root(office_state, monkeypatch): |
| 398 | project_root = office_state.projects_parent / "apollo" |
| 399 | project_root.mkdir(parents=True, exist_ok=True) |
| 400 | context = object() |
| 401 | agent_module = types.SimpleNamespace( |
| 402 | AgentContext=types.SimpleNamespace(get=staticmethod(lambda context_id: context)) |
| 403 | ) |
| 404 | |
| 405 | monkeypatch.setitem(sys.modules, "agent", agent_module) |
| 406 | monkeypatch.setattr(office_state.project_helpers, "get_context_project_name", lambda active_context: "apollo") |
| 407 | monkeypatch.setattr(office_state.project_helpers, "get_project_folder", lambda name: str(project_root)) |
| 408 | |
| 409 | markdown = document_store.create_document("document", "Project Note", "md", "Scoped.", context_id="ctx-project") |
| 410 | odt = document_store.create_document("document", "Project Memo", "odt", "Scoped.", context_id="ctx-project") |
| 411 | |
| 412 | assert Path(markdown["path"]).parent == project_root |
| 413 | assert Path(odt["path"]).parent == project_root / "documents" |
| 414 | |
| 415 | |
| 416 | def test_non_project_creation_uses_configured_workdir(office_state): |
| 417 | markdown = document_store.create_document("document", "Workdir Note", content="Plain.") |
| 418 | spreadsheet = document_store.create_document("spreadsheet", "Workdir Sheet", "ods", "Name,Value") |
| 419 | |
| 420 | assert markdown["extension"] == "md" |
| 421 | assert Path(markdown["path"]).parent == office_state.workdir |
| 422 | assert Path(spreadsheet["path"]).parent == office_state.documents |
| 423 | |
| 424 | |
| 425 | def test_sessions_and_canvas_context_are_neutral(office_state): |
| 426 | doc = document_store.create_document("document", "Canvas Context", "odt", "Private body text.") |
| 427 | session = document_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080") |
| 428 | |
| 429 | open_docs = document_store.get_open_documents() |
| 430 | context = canvas_context.build_context() |
| 431 | |
| 432 | assert open_docs[0]["file_id"] == doc["file_id"] |
| 433 | assert "Office artifacts" in context |
| 434 | assert "Private body text" not in context |
| 435 | assert document_store.close_session(session_id=session["session_id"]) == 1 |
| 436 | assert document_store.get_open_documents() == [] |
| 437 | |
| 438 | |
| 439 | def test_editor_open_files_are_scoped_to_active_context(office_state): |
| 440 | first = document_store.create_document("document", "First Editor Note", "md", "First private body.") |
| 441 | second = document_store.create_document("document", "Second Editor Note", "md", "Second private body.") |
| 442 | manager = editor_markdown_sessions.get_manager() |
| 443 | |
| 444 | first_session = manager.open(first, context_id="ctx-a") |
| 445 | second_session = manager.open(second, context_id="ctx-b") |
| 446 | manager.input(first_session["session_id"], text="Unsaved ctx-a text") |
| 447 | reopened_first = manager.open(first, context_id="ctx-a") |
| 448 | |
| 449 | ctx_a_files = manager.list_open("ctx-a") |
| 450 | ctx_b_files = manager.list_open("ctx-b") |
| 451 | prompt_context = open_files_context.build_context("ctx-a") |
| 452 | |
| 453 | assert reopened_first["session_id"] == first_session["session_id"] |
| 454 | assert reopened_first["text"] == "Unsaved ctx-a text" |
| 455 | assert [item["file_id"] for item in ctx_a_files] == [first["file_id"]] |
| 456 | assert [item["file_id"] for item in ctx_b_files] == [second["file_id"]] |
| 457 | assert ctx_a_files[0]["dirty"] is True |
| 458 | assert ctx_a_files[0]["active"] is True |
| 459 | assert ctx_a_files[0]["open_sessions"] == 1 |
| 460 | assert "First Editor Note.md" in prompt_context |
| 461 | assert "Second Editor Note.md" not in prompt_context |
| 462 | assert "First private body" not in prompt_context |
| 463 | assert "Unsaved ctx-a text" not in prompt_context |
| 464 | |
| 465 | |
| 466 | def test_markdown_save_tracks_version_history(office_state): |
| 467 | doc = document_store.create_document("document", "Versioned", "md", "First") |
| 468 | updated = document_store.write_markdown(doc["file_id"], "# Versioned\n\nSecond\n") |
| 469 | history = document_store.version_history(doc["file_id"]) |
| 470 | |
| 471 | assert updated["version"] == 2 |
| 472 | assert history |
| 473 | assert Path(updated["path"]).read_text(encoding="utf-8").endswith("Second\n") |
| 474 | |
| 475 | |
| 476 | def test_document_path_update_preserves_file_id_after_rename(office_state): |
| 477 | doc = document_store.create_document("document", "Rename Me", "md", "Body") |
| 478 | original = Path(doc["path"]) |
| 479 | renamed = original.with_name("Renamed.md") |
| 480 | original.rename(renamed) |
| 481 | |
| 482 | updated = document_store.update_document_path(doc["file_id"], renamed) |
| 483 | |
| 484 | assert updated["file_id"] == doc["file_id"] |
| 485 | assert updated["basename"] == "Renamed.md" |
| 486 | assert updated["path"] == str(renamed) |
| 487 | assert document_store.get_document(doc["file_id"])["path"] == str(renamed) |
| 488 | |
| 489 | |
| 490 | def test_document_rename_materializes_missing_markdown_with_editor_text(office_state): |
| 491 | doc = document_store.create_document("document", "Unsaved Draft", "md", "Seed") |
| 492 | original = Path(doc["path"]) |
| 493 | original.unlink() |
| 494 | renamed = original.with_name("Renamed Draft.md") |
| 495 | |
| 496 | updated = document_store.rename_document( |
| 497 | doc["file_id"], |
| 498 | renamed, |
| 499 | content="# Renamed Draft\n\nCanvas text", |
| 500 | ) |
| 501 | |
| 502 | assert updated["file_id"] == doc["file_id"] |
| 503 | assert updated["basename"] == "Renamed Draft.md" |
| 504 | assert updated["path"] == str(renamed) |
| 505 | assert renamed.read_text(encoding="utf-8") == "# Renamed Draft\n\nCanvas text" |
| 506 | |
| 507 | |
| 508 | def test_document_rename_saves_dirty_markdown_and_removes_original(office_state): |
| 509 | doc = document_store.create_document("document", "Dirty Rename", "md", "Old") |
| 510 | original = Path(doc["path"]) |
| 511 | renamed = original.with_name("Clean Rename.md") |
| 512 | |
| 513 | updated = document_store.rename_document( |
| 514 | doc["file_id"], |
| 515 | renamed, |
| 516 | content="# Clean Rename\n\nFresh text", |
| 517 | ) |
| 518 | |
| 519 | assert updated["version"] == 2 |
| 520 | assert not original.exists() |
| 521 | assert renamed.read_text(encoding="utf-8") == "# Clean Rename\n\nFresh text" |
| 522 | |
| 523 | |
| 524 | def test_text_session_save_as_creates_new_file_without_mutating_original(office_state): |
| 525 | manager = editor_markdown_sessions.MarkdownSessionManager() |
| 526 | doc = document_store.create_document("document", "Original Note", "md", "# Original Note\n") |
| 527 | original = Path(doc["path"]) |
| 528 | session = manager.open(doc, context_id="ctx-a") |
| 529 | target = office_state.workdir / "notes" / "Saved Copy.txt" |
| 530 | |
| 531 | result = manager.save_as( |
| 532 | session["session_id"], |
| 533 | str(target), |
| 534 | text="Saved Copy\n\nExact body\n", |
| 535 | ) |
| 536 | |
| 537 | saved_session = manager._sessions[session["session_id"]] |
| 538 | assert result["ok"] is True |
| 539 | assert result["document"]["file_id"] != doc["file_id"] |
| 540 | assert result["previous_file_id"] == doc["file_id"] |
| 541 | assert saved_session.file_id == result["document"]["file_id"] |
| 542 | assert saved_session.path == str(target) |
| 543 | assert saved_session.extension == "txt" |
| 544 | assert saved_session.dirty is False |
| 545 | assert original.read_text(encoding="utf-8") == "# Original Note" |
| 546 | assert target.read_text(encoding="utf-8") == "Saved Copy\n\nExact body\n" |
| 547 | |
| 548 | |
| 549 | def test_editor_session_opens_and_saves_txt_documents(office_state): |
| 550 | manager = editor_markdown_sessions.MarkdownSessionManager() |
| 551 | doc = document_store.create_document("document", "Plain Note", "txt", "First line") |
| 552 | session = manager.open(doc, context_id="ctx-a") |
| 553 | |
| 554 | assert session["extension"] == "txt" |
| 555 | assert session["text"] == "First line" |
| 556 | |
| 557 | result = manager.save(session["session_id"], text="Second line\n") |
| 558 | |
| 559 | assert result["ok"] is True |
| 560 | assert result["document"]["extension"] == "txt" |
| 561 | assert Path(result["document"]["path"]).read_text(encoding="utf-8") == "Second line\n" |
| 562 | |
| 563 | |
| 564 | def test_refresh_open_markdown_session_reloads_external_file_edits(office_state): |
| 565 | manager = editor_markdown_sessions.MarkdownSessionManager() |
| 566 | doc = document_store.create_document("document", "External Refresh", "md", "First") |
| 567 | session = manager.open(doc, context_id="ctx-a") |
| 568 | |
| 569 | Path(doc["path"]).write_text("# External Refresh\n\nSecond\n", encoding="utf-8") |
| 570 | refreshed = manager.open(doc, context_id="ctx-a", refresh=True) |
| 571 | |
| 572 | assert refreshed["session_id"] == session["session_id"] |
| 573 | assert refreshed["text"] == "# External Refresh\n\nSecond\n" |
| 574 | assert manager._sessions[session["session_id"]].dirty is False |
| 575 | |
| 576 | |
| 577 | def test_refresh_open_markdown_session_preserves_dirty_editor_text(office_state): |
| 578 | manager = editor_markdown_sessions.MarkdownSessionManager() |
| 579 | doc = document_store.create_document("document", "Dirty External Refresh", "md", "First") |
| 580 | session = manager.open(doc, context_id="ctx-a") |
| 581 | manager.input(session["session_id"], text="Unsaved editor text") |
| 582 | |
| 583 | Path(doc["path"]).write_text("External disk text\n", encoding="utf-8") |
| 584 | refreshed = manager.open(doc, context_id="ctx-a", refresh=True) |
| 585 | |
| 586 | assert refreshed["text"] == "Unsaved editor text" |
| 587 | assert manager._sessions[session["session_id"]].dirty is True |
| 588 | |
| 589 | |
| 590 | def test_external_text_editor_mutation_refreshes_clean_open_markdown_session(office_state): |
| 591 | manager = editor_markdown_sessions.MarkdownSessionManager() |
| 592 | doc = document_store.create_document("document", "Synced External Edit", "md", "First") |
| 593 | session = manager.open(doc, context_id="ctx-a") |
| 594 | |
| 595 | Path(doc["path"]).write_text("# Synced External Edit\n\nSecond\n", encoding="utf-8") |
| 596 | result = manager.sync_external_file_mutations([doc["path"]]) |
| 597 | |
| 598 | assert result["matched"] == 1 |
| 599 | assert manager._sessions[session["session_id"]].text == "# Synced External Edit\n\nSecond\n" |
| 600 | assert manager._sessions[session["session_id"]].dirty is False |
| 601 | assert manager._sessions[session["session_id"]].external_modified is False |
| 602 | |
| 603 | |
| 604 | def test_external_text_editor_mutation_marks_dirty_markdown_session_pending(office_state): |
| 605 | manager = editor_markdown_sessions.MarkdownSessionManager() |
| 606 | doc = document_store.create_document("document", "Dirty Synced External Edit", "md", "First") |
| 607 | session = manager.open(doc, context_id="ctx-a") |
| 608 | manager.input(session["session_id"], text="Unsaved editor text") |
| 609 | |
| 610 | Path(doc["path"]).write_text("External disk text\n", encoding="utf-8") |
| 611 | result = manager.sync_external_file_mutations([doc["path"]]) |
| 612 | |
| 613 | dirty_session = manager._sessions[session["session_id"]] |
| 614 | assert result["matched"] == 1 |
| 615 | assert dirty_session.text == "Unsaved editor text" |
| 616 | assert dirty_session.dirty is True |
| 617 | assert dirty_session.external_modified is True |
| 618 | |
| 619 | |
| 620 | def test_markdown_editor_save_rejects_stale_canvas_overwrite(office_state): |
| 621 | manager = editor_markdown_sessions.MarkdownSessionManager() |
| 622 | doc = document_store.create_document("document", "Stale Save Guard", "md", "First") |
| 623 | session = manager.open(doc, context_id="ctx-a") |
| 624 | |
| 625 | Path(doc["path"]).write_text("# Stale Save Guard\n\nExternal newer text\n", encoding="utf-8") |
| 626 | result = manager.save(session["session_id"], text="# Stale Save Guard\n\nOlder canvas text\n") |
| 627 | |
| 628 | assert result["ok"] is False |
| 629 | assert result["code"] == "external_change_conflict" |
| 630 | assert "External newer text" in Path(doc["path"]).read_text(encoding="utf-8") |
| 631 | assert "Older canvas text" not in Path(doc["path"]).read_text(encoding="utf-8") |
| 632 | |
| 633 | |
| 634 | def test_markdown_session_rejects_office_binaries(office_state): |
| 635 | manager = editor_markdown_sessions.MarkdownSessionManager() |
| 636 | doc = document_store.create_document("document", "Desktop Only", "odt", "Native text") |
| 637 | |
| 638 | with pytest.raises(ValueError, match="Open .odt files in the Desktop"): |
| 639 | manager.open(doc) |
| 640 | |
| 641 | |
| 642 | def test_thunar_defaults_preserve_existing_profile_settings(tmp_path): |
| 643 | thunar_xml = tmp_path / "thunar.xml" |
| 644 | thunar_xml.write_text( |
| 645 | """<?xml version="1.1" encoding="UTF-8"?> |
| 646 | <channel name="thunar" version="1.0"> |
| 647 | <property name="last-view" type="string" value="ThunarDetailsView"/> |
| 648 | <property name="last-window-width" type="int" value="900"/> |
| 649 | <property name="last-show-hidden" type="bool" value="false"/> |
| 650 | </channel> |
| 651 | """, |
| 652 | encoding="utf-8", |
| 653 | ) |
| 654 | |
| 655 | desktop_session._write_thunar_defaults(thunar_xml) |
| 656 | |
| 657 | root = ET.parse(thunar_xml).getroot() |
| 658 | values = {child.get("name"): child.get("value") for child in root.findall("property")} |
| 659 | assert values["last-view"] == "ThunarDetailsView" |
| 660 | assert values["last-window-width"] == "900" |
| 661 | assert values["last-show-hidden"] == "true" |
| 662 | |
| 663 | |
| 664 | def test_official_desktop_session_status_and_url_contract(tmp_path, monkeypatch): |
| 665 | xpra_html = tmp_path / "xpra" / "www" |
| 666 | xpra_html.mkdir(parents=True) |
| 667 | (xpra_html / "index.html").write_text("xpra", encoding="utf-8") |
| 668 | |
| 669 | monkeypatch.setattr(desktop_session.libreoffice, "find_soffice", lambda: "/usr/bin/soffice") |
| 670 | monkeypatch.setattr( |
| 671 | desktop_session.shutil, |
| 672 | "which", |
| 673 | lambda name: f"/usr/bin/{name}" |
| 674 | if name |
| 675 | in { |
| 676 | "xpra", |
| 677 | "Xvfb", |
| 678 | "xfce4-session", |
| 679 | "dbus-launch", |
| 680 | "xrandr", |
| 681 | "xdotool", |
| 682 | "thunar", |
| 683 | "xfce4-terminal", |
| 684 | "xfce4-settings-manager", |
| 685 | "gio", |
| 686 | } |
| 687 | else "", |
| 688 | ) |
| 689 | monkeypatch.setattr(desktop_session.virtual_desktop, "XPRA_HTML_ROOT_CANDIDATES", (xpra_html,)) |
| 690 | monkeypatch.setattr(desktop_session.virtual_desktop, "_package_installed", lambda package: True) |
| 691 | |
| 692 | status = desktop_session.collect_desktop_status() |
| 693 | url = desktop_session._xpra_url("abc123") |
| 694 | |
| 695 | assert status["healthy"] is True |
| 696 | assert status["xpra_html_root"] == str(xpra_html) |
| 697 | assert url.startswith("/desktop/session/abc123/index.html?") |
| 698 | assert "path=%2Fdesktop%2Fsession%2Fabc123%2F" in url |
| 699 | assert "xpramenu=false" in url |
| 700 | assert "floating_menu=false" in url |
| 701 | assert "file_transfer=true" in url |
| 702 | assert "sound=false" in url |
| 703 | assert "encoding=jpeg" in url |
| 704 | assert "quality=85" in url |
| 705 | assert "speed=80" in url |
| 706 | assert "printing=true" in url |
| 707 | |
| 708 | |
| 709 | def test_desktop_status_reports_installing_during_runtime_preparation(monkeypatch): |
| 710 | monkeypatch.setattr( |
| 711 | desktop_session.virtual_desktop, |
| 712 | "collect_status", |
| 713 | lambda: { |
| 714 | "binaries": {}, |
| 715 | "packages": {}, |
| 716 | "xpra_html_root": "", |
| 717 | }, |
| 718 | ) |
| 719 | monkeypatch.setattr(desktop_session.libreoffice, "find_soffice", lambda: "") |
| 720 | monkeypatch.setattr(desktop_session.shutil, "which", lambda _name: "") |
| 721 | monkeypatch.setattr( |
| 722 | desktop_session, |
| 723 | "_runtime_preparation_status", |
| 724 | lambda: {"preparing": True, "active_count": 1, "started_at": 123.0}, |
| 725 | ) |
| 726 | |
| 727 | status = desktop_session.collect_desktop_status() |
| 728 | |
| 729 | assert status["healthy"] is False |
| 730 | assert status["installing"] is True |
| 731 | assert status["state"] == "installing" |
| 732 | assert status["message"].startswith("Installing Agent Zero Desktop runtime dependencies") |
| 733 | assert "soffice" in status["missing"] |
| 734 | |
| 735 | |
| 736 | def test_desktop_gateway_patches_xpra_menu_script(): |
| 737 | source = (PROJECT_ROOT / "helpers" / "virtual_desktop_routes.py").read_text(encoding="utf-8") |
| 738 | |
| 739 | assert "XPRA_MENU_CUSTOM_PATCH" in source |
| 740 | assert 'upstream_path.endswith("/js/MenuCustom.js")' in source |
| 741 | assert "window.noWindowList" in source |
| 742 | assert "__a0SafeWindowList" in source |
| 743 | assert "XPRA_WINDOW_OFFSET_WARNING_PATCH" in source |
| 744 | assert "XPRA_WINDOW_SCRIPT_PATCH" in source |
| 745 | assert "a0_desktop_patch=20260506" in source |
| 746 | assert 'upstream_path.endswith("/index.html")' in source |
| 747 | assert 'upstream_path.endswith("/js/Window.js")' in source |
| 748 | assert "window does not fit in canvas, offsets:" in source |
| 749 | |
| 750 | |
| 751 | def test_office_binary_open_requires_explicit_desktop_without_cold_session(office_state, monkeypatch): |
| 752 | api_module = types.ModuleType("helpers.api") |
| 753 | |
| 754 | class ApiHandler: |
| 755 | def __init__(self, app=None, thread_lock=None): |
| 756 | self.app = app |
| 757 | self.thread_lock = thread_lock |
| 758 | |
| 759 | api_module.ApiHandler = ApiHandler |
| 760 | api_module.Request = object |
| 761 | monkeypatch.setitem(sys.modules, "helpers.api", api_module) |
| 762 | monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False) |
| 763 | |
| 764 | from plugins._office.api import office_session |
| 765 | |
| 766 | doc = document_store.create_document("document", "Cold Memo", "odt", "No surprise Desktop.") |
| 767 | |
| 768 | def forbidden_session(*_args, **_kwargs): |
| 769 | raise AssertionError("cold binary open must not create a store session") |
| 770 | |
| 771 | class ForbiddenManager: |
| 772 | def open(self, *_args, **_kwargs): |
| 773 | raise AssertionError("cold binary open must not open Desktop") |
| 774 | |
| 775 | monkeypatch.setattr(office_session.document_store, "create_session", forbidden_session) |
| 776 | monkeypatch.setattr(office_session.desktop_session, "get_manager", lambda: ForbiddenManager()) |
| 777 | |
| 778 | handler = office_session.OfficeSession(app=None, thread_lock=None) |
| 779 | request = types.SimpleNamespace(headers={}, host_url="http://localhost:32080") |
| 780 | result = asyncio.run(handler.process({"action": "open", "file_id": doc["file_id"]}, request)) |
| 781 | |
| 782 | assert result["ok"] is True |
| 783 | assert result["requires_desktop"] is True |
| 784 | assert result["file_id"] == doc["file_id"] |
| 785 | assert "session_id" not in result |
| 786 | assert "store_session_id" not in result |
| 787 | |
| 788 | monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False) |
| 789 | api_package = sys.modules.get("plugins._office.api") |
| 790 | if api_package is not None: |
| 791 | monkeypatch.delattr(api_package, "office_session", raising=False) |
| 792 | |
| 793 | |
| 794 | def test_official_desktop_session_manager_opens_binary_session(office_state, tmp_path, monkeypatch): |
| 795 | class FakeProcess: |
| 796 | pid = 4242 |
| 797 | |
| 798 | def poll(self): |
| 799 | return None |
| 800 | |
| 801 | def terminate(self): |
| 802 | return None |
| 803 | |
| 804 | def wait(self, timeout=None): |
| 805 | return 0 |
| 806 | |
| 807 | def kill(self): |
| 808 | return None |
| 809 | |
| 810 | monkeypatch.setattr(desktop_session, "STATE_DIR", tmp_path / "desktop") |
| 811 | monkeypatch.setattr(desktop_session, "SESSION_DIR", tmp_path / "desktop" / "sessions") |
| 812 | monkeypatch.setattr(desktop_session, "PROFILE_DIR", tmp_path / "desktop" / "profiles") |
| 813 | monkeypatch.setattr(desktop_session, "collect_desktop_status", lambda: {"healthy": True, "message": "ok"}) |
| 814 | monkeypatch.setattr(desktop_session.libreoffice, "find_soffice", lambda: "/usr/bin/soffice") |
| 815 | monkeypatch.setattr(desktop_session, "_port_is_free", lambda port: True) |
| 816 | monkeypatch.setattr(desktop_session.virtual_desktop, "has_window", lambda **kwargs: True) |
| 817 | real_get_abs_path = desktop_session.files.get_abs_path |
| 818 | |
| 819 | def fake_get_abs_path(*parts): |
| 820 | if parts and parts[0] == "usr": |
| 821 | return str(tmp_path.joinpath(*parts)) |
| 822 | return real_get_abs_path(*parts) |
| 823 | |
| 824 | monkeypatch.setattr(desktop_session.files, "get_abs_path", fake_get_abs_path) |
| 825 | |
| 826 | def fake_spawn(self, session): |
| 827 | session.profile_dir.mkdir(parents=True, exist_ok=True) |
| 828 | session.processes["xpra"] = FakeProcess() |
| 829 | |
| 830 | def fake_open_document(self, session, doc): |
| 831 | session.processes[f"soffice-{doc['file_id']}"] = FakeProcess() |
| 832 | |
| 833 | monkeypatch.setattr(desktop_session.DesktopSessionManager, "_spawn_desktop_locked", fake_spawn) |
| 834 | monkeypatch.setattr(desktop_session.DesktopSessionManager, "_open_document_locked", fake_open_document) |
| 835 | |
| 836 | doc = document_store.create_document("spreadsheet", "Official Sheet", "ods", "Name,Value\nA,1") |
| 837 | manager = desktop_session.DesktopSessionManager() |
| 838 | payload = manager.open(doc) |
| 839 | |
| 840 | assert payload["available"] is True |
| 841 | assert payload["extension"] == "ods" |
| 842 | assert payload["url"].startswith("/desktop/session/") |
| 843 | registry = tmp_path / "desktop" / "profiles" / payload["session_id"] / "user" / "registrymodifications.xcu" |
| 844 | registry_text = registry.read_text(encoding="utf-8") |
| 845 | assert "ooSetupInstCompleted" in registry_text |
| 846 | assert "FirstRun" in registry_text |
| 847 | assert "Office.Paths/Variables" in registry_text |
| 848 | assert "Office.Paths:NamedPath['Work']" in registry_text |
| 849 | assert office_state.workdir.as_uri() in registry_text |
| 850 | writer_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "LibreOffice Writer.desktop" |
| 851 | writer_text = writer_launcher.read_text(encoding="utf-8") |
| 852 | assert "--writer" in writer_text |
| 853 | assert f"Path={office_state.workdir}" in writer_text |
| 854 | assert "X-XFCE-Trusted=true" in writer_text |
| 855 | terminal_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Terminal.desktop" |
| 856 | files_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Files.desktop" |
| 857 | settings_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Settings.desktop" |
| 858 | terminal_text = terminal_launcher.read_text(encoding="utf-8") |
| 859 | settings_text = settings_launcher.read_text(encoding="utf-8") |
| 860 | browser_launcher = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Browser.desktop" |
| 861 | browser_text = browser_launcher.read_text(encoding="utf-8") |
| 862 | assert "xfce4-terminal" in terminal_text |
| 863 | assert "org.xfce.terminal" in terminal_text |
| 864 | assert not files_launcher.exists() |
| 865 | assert "open-url" in browser_text |
| 866 | assert "firefox" not in browser_text.lower() |
| 867 | assert "xfce4-settings-manager" in settings_text |
| 868 | assert "org.xfce.settings.manager" in settings_text |
| 869 | link_targets = { |
| 870 | "Projects": "usr/projects", |
| 871 | "Skills": "usr/skills", |
| 872 | "Agents": "usr/agents", |
| 873 | "Downloads": "usr/downloads", |
| 874 | } |
| 875 | workdir_link = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / "Workdir" |
| 876 | assert workdir_link.is_symlink() |
| 877 | assert workdir_link.resolve() == office_state.workdir |
| 878 | for link_name, target in link_targets.items(): |
| 879 | link = tmp_path / "desktop" / "profiles" / payload["session_id"] / "Desktop" / link_name |
| 880 | assert link.is_symlink() |
| 881 | assert str(link.resolve()).endswith(target) |
| 882 | xpra_override = ( |
| 883 | tmp_path |
| 884 | / "desktop" |
| 885 | / "profiles" |
| 886 | / payload["session_id"] |
| 887 | / ".local" |
| 888 | / "share" |
| 889 | / "applications" |
| 890 | / "xpra-gui.desktop" |
| 891 | ) |
| 892 | assert "Hidden=true" in xpra_override.read_text(encoding="utf-8") |
| 893 | desktop_profile = ( |
| 894 | tmp_path |
| 895 | / "desktop" |
| 896 | / "profiles" |
| 897 | / payload["session_id"] |
| 898 | / ".config" |
| 899 | / "xfce4" |
| 900 | / "xfconf" |
| 901 | / "xfce-perchannel-xml" |
| 902 | / "xfce4-desktop.xml" |
| 903 | ) |
| 904 | desktop_profile_text = desktop_profile.read_text(encoding="utf-8") |
| 905 | assert "desktop-icons" in desktop_profile_text |
| 906 | assert "image-path" in desktop_profile_text |
| 907 | assert "usr/downloads" in desktop_profile_text |
| 908 | thunar_profile = ( |
| 909 | tmp_path |
| 910 | / "desktop" |
| 911 | / "profiles" |
| 912 | / payload["session_id"] |
| 913 | / ".config" |
| 914 | / "xfce4" |
| 915 | / "xfconf" |
| 916 | / "xfce-perchannel-xml" |
| 917 | / "thunar.xml" |
| 918 | ).read_text(encoding="utf-8") |
| 919 | assert 'name="last-show-hidden" type="bool" value="true"' in thunar_profile |
| 920 | user_dirs = ( |
| 921 | tmp_path |
| 922 | / "desktop" |
| 923 | / "profiles" |
| 924 | / payload["session_id"] |
| 925 | / ".config" |
| 926 | / "user-dirs.dirs" |
| 927 | ).read_text(encoding="utf-8") |
| 928 | assert 'XDG_PICTURES_DIR="' in user_dirs |
| 929 | assert "usr/downloads" in user_dirs |
| 930 | assert f'XDG_DOCUMENTS_DIR="{office_state.workdir}"' in user_dirs |
| 931 | panel_profile = ( |
| 932 | tmp_path |
| 933 | / "desktop" |
| 934 | / "profiles" |
| 935 | / payload["session_id"] |
| 936 | / ".config" |
| 937 | / "xfce4" |
| 938 | / "xfconf" |
| 939 | / "xfce-perchannel-xml" |
| 940 | / "xfce4-panel.xml" |
| 941 | ).read_text(encoding="utf-8") |
| 942 | assert "panel-1" in panel_profile |
| 943 | assert "panel-2" not in panel_profile |
| 944 | assert 'value="actions"' not in panel_profile |
| 945 | assert 'value="launcher"' in panel_profile |
| 946 | assert "agent-zero-shutdown.desktop" in panel_profile |
| 947 | shutdown_app = ( |
| 948 | tmp_path |
| 949 | / "desktop" |
| 950 | / "profiles" |
| 951 | / payload["session_id"] |
| 952 | / ".local" |
| 953 | / "share" |
| 954 | / "applications" |
| 955 | / "agent-zero-shutdown.desktop" |
| 956 | ).read_text(encoding="utf-8") |
| 957 | assert "Shutdown Desktop" in shutdown_app |
| 958 | assert "shutdown-desktop" in shutdown_app |
| 959 | shutdown_panel_launcher = ( |
| 960 | tmp_path |
| 961 | / "desktop" |
| 962 | / "profiles" |
| 963 | / payload["session_id"] |
| 964 | / ".config" |
| 965 | / "xfce4" |
| 966 | / "panel" |
| 967 | / "launcher-9" |
| 968 | / "agent-zero-shutdown.desktop" |
| 969 | ).read_text(encoding="utf-8") |
| 970 | assert "Shutdown Desktop" in shutdown_panel_launcher |
| 971 | assert "shutdown-desktop" in shutdown_panel_launcher |
| 972 | shutdown_script = ( |
| 973 | tmp_path |
| 974 | / "desktop" |
| 975 | / "profiles" |
| 976 | / payload["session_id"] |
| 977 | / ".agent-zero" |
| 978 | / "shutdown-desktop" |
| 979 | ).read_text(encoding="utf-8") |
| 980 | assert "CONFIRM_SECONDS" in shutdown_script |
| 981 | assert "ARM_PATH" in shutdown_script |
| 982 | assert "Click Shutdown Desktop again" in shutdown_script |
| 983 | assert "xmessage" in shutdown_script |
| 984 | assert '"-buttons",' in shutdown_script |
| 985 | desktop_helper = ( |
| 986 | PROJECT_ROOT / "plugins" / "_desktop" / "helpers" / "desktop_session.py" |
| 987 | ).read_text(encoding="utf-8") |
| 988 | assert "_refresh_xfce_desktop" in desktop_helper |
| 989 | assert "DBUS_SESSION_BUS_ADDRESS" in desktop_helper |
| 990 | autostart = ( |
| 991 | tmp_path |
| 992 | / "desktop" |
| 993 | / "profiles" |
| 994 | / payload["session_id"] |
| 995 | / ".config" |
| 996 | / "autostart" |
| 997 | / "agent-zero-desktop.desktop" |
| 998 | ) |
| 999 | assert "prepare-xfce-profile.sh" in autostart.read_text(encoding="utf-8") |
| 1000 | profile_script = ( |
| 1001 | tmp_path |
| 1002 | / "desktop" |
| 1003 | / "profiles" |
| 1004 | / payload["session_id"] |
| 1005 | / "prepare-xfce-profile.sh" |
| 1006 | ).read_text(encoding="utf-8") |
| 1007 | assert '"$HOME"/Desktop/*.desktop' in profile_script |
| 1008 | assert "agent-zero-settings.desktop" not in profile_script |
| 1009 | assert "metadata::xfce-exe-checksum" in profile_script |
| 1010 | assert "xfconf-query -c thunar -p /last-show-hidden" in profile_script |
| 1011 | assert "xfconf-query -c xfce4-panel" not in profile_script |
| 1012 | assert "launcher-*" not in profile_script |
| 1013 | for filename in ( |
| 1014 | "exo-mail-reader.desktop", |
| 1015 | "exo-web-browser.desktop", |
| 1016 | "xfce4-mail-reader.desktop", |
| 1017 | "xfce4-web-browser.desktop", |
| 1018 | "xfce4-session-logout.desktop", |
| 1019 | "xfce4-lock-screen.desktop", |
| 1020 | "xflock4.desktop", |
| 1021 | "xfce4-switch-user.desktop", |
| 1022 | ): |
| 1023 | entry = ( |
| 1024 | tmp_path |
| 1025 | / "desktop" |
| 1026 | / "profiles" |
| 1027 | / payload["session_id"] |
| 1028 | / ".local" |
| 1029 | / "share" |
| 1030 | / "applications" |
| 1031 | / filename |
| 1032 | ).read_text(encoding="utf-8") |
| 1033 | assert "NoDisplay=true" in entry |
| 1034 | assert "Hidden=true" in entry |
| 1035 | assert manager.proxy_for_token(payload["token"]) == ("127.0.0.1", desktop_session.XPRA_PORT_BASE) |
| 1036 | assert manager.close(payload["session_id"], save_first=False)["closed"] == 0 |
| 1037 | assert manager.close(payload["session_id"], save_first=False)["persistent"] is True |
| 1038 | |
| 1039 | |
| 1040 | def test_desktop_save_targets_requested_libreoffice_window(office_state, tmp_path, monkeypatch): |
| 1041 | doc = document_store.create_document("spreadsheet", "Targeted Save", "ods", "Name,Value\nA,1") |
| 1042 | session = desktop_session.DesktopSession( |
| 1043 | session_id=desktop_session.SYSTEM_SESSION_ID, |
| 1044 | file_id=doc["file_id"], |
| 1045 | extension=doc["extension"], |
| 1046 | path=doc["path"], |
| 1047 | title=doc["basename"], |
| 1048 | display=desktop_session.DISPLAY_BASE, |
| 1049 | xpra_port=desktop_session.XPRA_PORT_BASE, |
| 1050 | token=desktop_session.SYSTEM_SESSION_ID, |
| 1051 | url="/desktop/session/agent-zero-desktop/index.html", |
| 1052 | profile_dir=tmp_path / "profile", |
| 1053 | processes={"xpra": types.SimpleNamespace(poll=lambda: None)}, |
| 1054 | ) |
| 1055 | manager = desktop_session.DesktopSessionManager() |
| 1056 | manager._sessions[session.session_id] = session |
| 1057 | commands = [] |
| 1058 | |
| 1059 | monkeypatch.setattr(desktop_session.shutil, "which", lambda name: f"/usr/bin/{name}") |
| 1060 | monkeypatch.setattr(desktop_session.time, "sleep", lambda _seconds: None) |
| 1061 | |
| 1062 | def fake_run(command, **_kwargs): |
| 1063 | commands.append(command) |
| 1064 | if command[1] == "search": |
| 1065 | assert command == ["/usr/bin/xdotool", "search", "--onlyvisible", "--class", "libreoffice"] |
| 1066 | return subprocess.CompletedProcess(command, 0, "222\n", "") |
| 1067 | if command[1] == "getwindowname": |
| 1068 | return subprocess.CompletedProcess(command, 0, f"{doc['basename']} — LibreOffice Calc\n", "") |
| 1069 | Path(doc["path"]).write_bytes(Path(doc["path"]).read_bytes() + b"changed") |
| 1070 | return subprocess.CompletedProcess(command, 0, "", "") |
| 1071 | |
| 1072 | monkeypatch.setattr(desktop_session.subprocess, "run", fake_run) |
| 1073 | |
| 1074 | result = manager.save(session.session_id, doc["file_id"]) |
| 1075 | |
| 1076 | assert result["ok"] is True |
| 1077 | assert result["changed"] is True |
| 1078 | assert commands[-1] == [ |
| 1079 | "/usr/bin/xdotool", |
| 1080 | "windowactivate", |
| 1081 | "--sync", |
| 1082 | "222", |
| 1083 | "key", |
| 1084 | "--clearmodifiers", |
| 1085 | "ctrl+s", |
| 1086 | ] |
| 1087 | |
| 1088 | session.file_id = desktop_session.SYSTEM_FILE_ID |
| 1089 | command_count = len(commands) |
| 1090 | assert manager.save(session.session_id)["changed"] is False |
| 1091 | assert len(commands) == command_count |
| 1092 | |
| 1093 | |
| 1094 | def test_desktop_startup_waiters_probe_display_and_reject_dead_xfce(tmp_path, monkeypatch): |
| 1095 | session = desktop_session.DesktopSession( |
| 1096 | session_id=desktop_session.SYSTEM_SESSION_ID, |
| 1097 | file_id=desktop_session.SYSTEM_FILE_ID, |
| 1098 | extension="desktop", |
| 1099 | path=str(tmp_path), |
| 1100 | title=desktop_session.SYSTEM_TITLE, |
| 1101 | display=desktop_session.DISPLAY_BASE, |
| 1102 | xpra_port=desktop_session.XPRA_PORT_BASE, |
| 1103 | token=desktop_session.SYSTEM_SESSION_ID, |
| 1104 | url="/desktop/session/agent-zero-desktop/index.html", |
| 1105 | profile_dir=tmp_path / "profile", |
| 1106 | processes={"xvfb": types.SimpleNamespace(poll=lambda: None)}, |
| 1107 | ) |
| 1108 | manager = desktop_session.DesktopSessionManager() |
| 1109 | probes = iter((None, (1920, 1080))) |
| 1110 | monkeypatch.setattr( |
| 1111 | desktop_session.virtual_desktop, |
| 1112 | "current_display_size", |
| 1113 | lambda *_args, **_kwargs: next(probes), |
| 1114 | ) |
| 1115 | monkeypatch.setattr(desktop_session.time, "sleep", lambda _seconds: None) |
| 1116 | |
| 1117 | manager._wait_for_display(session) |
| 1118 | |
| 1119 | session.processes = {"xfce": types.SimpleNamespace(poll=lambda: 1)} |
| 1120 | with pytest.raises(RuntimeError, match="XFCE desktop session exited"): |
| 1121 | manager._wait_for_xfce(session) |
| 1122 | |
| 1123 | |
| 1124 | def test_desktop_manifest_is_replaced_atomically(tmp_path, monkeypatch): |
| 1125 | session_dir = tmp_path / "sessions" |
| 1126 | session_dir.mkdir() |
| 1127 | manifest = session_dir / f"{desktop_session.SYSTEM_SESSION_ID}.json" |
| 1128 | manifest.write_text('{"old": true}', encoding="utf-8") |
| 1129 | session = desktop_session.DesktopSession( |
| 1130 | session_id=desktop_session.SYSTEM_SESSION_ID, |
| 1131 | file_id=desktop_session.SYSTEM_FILE_ID, |
| 1132 | extension="desktop", |
| 1133 | path=str(tmp_path), |
| 1134 | title=desktop_session.SYSTEM_TITLE, |
| 1135 | display=desktop_session.DISPLAY_BASE, |
| 1136 | xpra_port=desktop_session.XPRA_PORT_BASE, |
| 1137 | token=desktop_session.SYSTEM_SESSION_ID, |
| 1138 | url="/desktop/session/agent-zero-desktop/index.html", |
| 1139 | profile_dir=tmp_path / "profile", |
| 1140 | ) |
| 1141 | real_replace = os.replace |
| 1142 | |
| 1143 | def assert_atomic_replace(source, destination): |
| 1144 | assert json.loads(Path(destination).read_text(encoding="utf-8")) == {"old": True} |
| 1145 | assert json.loads(Path(source).read_text(encoding="utf-8"))["display"] == session.display |
| 1146 | real_replace(source, destination) |
| 1147 | |
| 1148 | monkeypatch.setattr(desktop_session, "SESSION_DIR", session_dir) |
| 1149 | monkeypatch.setattr(desktop_session.os, "replace", assert_atomic_replace) |
| 1150 | |
| 1151 | desktop_session.DesktopSessionManager()._write_manifest(session) |
| 1152 | |
| 1153 | assert json.loads(manifest.read_text(encoding="utf-8"))["session_id"] == session.session_id |
| 1154 | assert list(session_dir.glob(".*.tmp")) == [] |
| 1155 | |
| 1156 | |
| 1157 | def test_shutdown_panel_launcher_requires_second_click(tmp_path): |
| 1158 | profile_dir = tmp_path / "desktop" / "profiles" / desktop_session.SYSTEM_SESSION_ID |
| 1159 | profile_dir.mkdir(parents=True) |
| 1160 | desktop_path = tmp_path / "workdir" |
| 1161 | desktop_path.mkdir() |
| 1162 | session = desktop_session.DesktopSession( |
| 1163 | session_id=desktop_session.SYSTEM_SESSION_ID, |
| 1164 | file_id=desktop_session.SYSTEM_FILE_ID, |
| 1165 | extension="desktop", |
| 1166 | path=str(desktop_path), |
| 1167 | title=desktop_session.SYSTEM_TITLE, |
| 1168 | display=desktop_session.DISPLAY_BASE, |
| 1169 | xpra_port=desktop_session.XPRA_PORT_BASE, |
| 1170 | token=desktop_session.SYSTEM_SESSION_ID, |
| 1171 | url="/desktop/session/agent-zero-desktop/index.html", |
| 1172 | profile_dir=profile_dir, |
| 1173 | ) |
| 1174 | script = desktop_session._write_shutdown_bridge_script(session) |
| 1175 | request = desktop_session._shutdown_request_path(session) |
| 1176 | arm = desktop_session._shutdown_arm_path(session) |
| 1177 | env = dict(os.environ) |
| 1178 | env.pop("DISPLAY", None) |
| 1179 | |
| 1180 | subprocess.run([sys.executable, str(script)], check=True, env=env) |
| 1181 | |
| 1182 | assert arm.exists() |
| 1183 | assert not request.exists() |
| 1184 | |
| 1185 | subprocess.run([sys.executable, str(script)], check=True, env=env) |
| 1186 | |
| 1187 | payload = json.loads(request.read_text(encoding="utf-8")) |
| 1188 | assert payload["source"] == "tray" |
| 1189 | assert payload["armed_at"] <= payload["created_at"] |
| 1190 | assert not arm.exists() |
| 1191 | |
| 1192 | |
| 1193 | def test_desktop_session_sync_consumes_shutdown_marker(tmp_path, monkeypatch): |
| 1194 | class FakeProcess: |
| 1195 | pid = 5252 |
| 1196 | terminated = False |
| 1197 | |
| 1198 | def poll(self): |
| 1199 | return None if not self.terminated else 0 |
| 1200 | |
| 1201 | def terminate(self): |
| 1202 | self.terminated = True |
| 1203 | |
| 1204 | def wait(self, timeout=None): |
| 1205 | self.terminated = True |
| 1206 | return 0 |
| 1207 | |
| 1208 | def kill(self): |
| 1209 | self.terminated = True |
| 1210 | |
| 1211 | monkeypatch.setattr(desktop_session, "STATE_DIR", tmp_path / "desktop") |
| 1212 | monkeypatch.setattr(desktop_session, "SESSION_DIR", tmp_path / "desktop" / "sessions") |
| 1213 | monkeypatch.setattr(desktop_session, "PROFILE_DIR", tmp_path / "desktop" / "profiles") |
| 1214 | |
| 1215 | profile_dir = tmp_path / "desktop" / "profiles" / desktop_session.SYSTEM_SESSION_ID |
| 1216 | profile_dir.mkdir(parents=True) |
| 1217 | desktop_path = tmp_path / "workdir" |
| 1218 | desktop_path.mkdir() |
| 1219 | session = desktop_session.DesktopSession( |
| 1220 | session_id=desktop_session.SYSTEM_SESSION_ID, |
| 1221 | file_id=desktop_session.SYSTEM_FILE_ID, |
| 1222 | extension="desktop", |
| 1223 | path=str(desktop_path), |
| 1224 | title=desktop_session.SYSTEM_TITLE, |
| 1225 | display=desktop_session.DISPLAY_BASE, |
| 1226 | xpra_port=desktop_session.XPRA_PORT_BASE, |
| 1227 | token=desktop_session.SYSTEM_SESSION_ID, |
| 1228 | url="/desktop/session/agent-zero-desktop/index.html", |
| 1229 | profile_dir=profile_dir, |
| 1230 | processes={"xpra": FakeProcess()}, |
| 1231 | ) |
| 1232 | manager = desktop_session.DesktopSessionManager() |
| 1233 | manager._sessions[session.session_id] = session |
| 1234 | manager._write_manifest(session) |
| 1235 | desktop_session._write_url_bridge_script(session) |
| 1236 | shutdown_request = desktop_session._shutdown_request_path(session) |
| 1237 | shutdown_request.write_text('{"source": "tray", "created_at": 123.0}\n', encoding="utf-8") |
| 1238 | save_calls = [] |
| 1239 | monkeypatch.setattr( |
| 1240 | manager, |
| 1241 | "save", |
| 1242 | lambda session_id, file_id="": save_calls.append((session_id, file_id)) or {"ok": True}, |
| 1243 | ) |
| 1244 | |
| 1245 | result = manager.sync(session_id=session.session_id) |
| 1246 | |
| 1247 | assert result["ok"] is True |
| 1248 | assert result["intentional_shutdown"] is True |
| 1249 | assert result["source"] == "tray" |
| 1250 | assert result["closed"] == 1 |
| 1251 | assert save_calls == [(desktop_session.SYSTEM_SESSION_ID, "")] |
| 1252 | assert not shutdown_request.exists() |
| 1253 | assert not (desktop_session.SESSION_DIR / f"{session.session_id}.json").exists() |
| 1254 | assert manager.get(session.session_id) is None |
| 1255 | |
| 1256 | |
| 1257 | def test_desktop_session_cleanup_preserves_live_owner_manifest(tmp_path, monkeypatch): |
| 1258 | session_dir = tmp_path / "sessions" |
| 1259 | legacy_session_dir = tmp_path / "legacy-sessions" |
| 1260 | session_dir.mkdir() |
| 1261 | legacy_session_dir.mkdir() |
| 1262 | manifest = session_dir / "live.json" |
| 1263 | manifest.write_text( |
| 1264 | json.dumps({"owner_pid": os.getpid(), "pids": {"xpra": 987654}}), |
| 1265 | encoding="utf-8", |
| 1266 | ) |
| 1267 | legacy_manifest = legacy_session_dir / "stale.json" |
| 1268 | legacy_manifest.write_text( |
| 1269 | json.dumps({"owner_pid": 987650, "pids": {"xpra": 987651, "xfce": 987652}}), |
| 1270 | encoding="utf-8", |
| 1271 | ) |
| 1272 | monkeypatch.setattr(desktop_session, "SESSION_DIR", session_dir) |
| 1273 | monkeypatch.setattr(desktop_session, "LEGACY_SESSION_DIRS", (legacy_session_dir,)) |
| 1274 | killed = [] |
| 1275 | |
| 1276 | def fake_kill_pid(pid): |
| 1277 | killed.append(pid) |
| 1278 | return True |
| 1279 | |
| 1280 | monkeypatch.setattr( |
| 1281 | desktop_session, |
| 1282 | "_kill_pid", |
| 1283 | fake_kill_pid, |
| 1284 | ) |
| 1285 | |
| 1286 | result = desktop_session.cleanup_stale_runtime_state() |
| 1287 | |
| 1288 | assert result["killed"] == [987651, 987652] |
| 1289 | assert killed == [987651, 987652] |
| 1290 | assert manifest.exists() |
| 1291 | assert not legacy_manifest.exists() |
| 1292 | |
| 1293 | |
| 1294 | def test_desktop_session_removes_stale_lock_file(tmp_path): |
| 1295 | doc_path = tmp_path / "Deck.pptx" |
| 1296 | doc_path.write_text("pptx", encoding="utf-8") |
| 1297 | lock_path = tmp_path / ".~lock.Deck.pptx#" |
| 1298 | lock_path.write_text("stale", encoding="utf-8") |
| 1299 | session = desktop_session.DesktopSession( |
| 1300 | session_id="session", |
| 1301 | file_id="file", |
| 1302 | extension="pptx", |
| 1303 | path=str(doc_path), |
| 1304 | title=doc_path.name, |
| 1305 | display=desktop_session.DISPLAY_BASE, |
| 1306 | xpra_port=desktop_session.XPRA_PORT_BASE, |
| 1307 | token="token", |
| 1308 | url="/desktop/session/token/index.html", |
| 1309 | profile_dir=tmp_path / "profile", |
| 1310 | ) |
| 1311 | |
| 1312 | desktop_session.DesktopSessionManager()._remove_stale_lock_file(session) |
| 1313 | |
| 1314 | assert not lock_path.exists() |
| 1315 | |
| 1316 | |
| 1317 | def _isolate_office_cleanup_hook(monkeypatch, tmp_path): |
| 1318 | state_dir = tmp_path / "usr" / "plugins" / "_office" |
| 1319 | retired_state_dir = tmp_path / "usr" / "_office" |
| 1320 | monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", tmp_path / "missing.sources") |
| 1321 | monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", tmp_path / "missing.gpg") |
| 1322 | monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf") |
| 1323 | monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", []) |
| 1324 | monkeypatch.setattr(hooks, "STATE_DIR", state_dir) |
| 1325 | monkeypatch.setattr(hooks, "RETIRED_STATE_DIR", retired_state_dir) |
| 1326 | monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", state_dir / "documents") |
| 1327 | monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", []) |
| 1328 | monkeypatch.setattr(hooks, "CLEANUP_MARKER", state_dir / "cleanup.done") |
| 1329 | monkeypatch.setattr(hooks, "_installed_retired_web_packages", lambda: []) |
| 1330 | monkeypatch.setattr(hooks, "_installed_packages", lambda packages: []) |
| 1331 | monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None) |
| 1332 | monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None) |
| 1333 | monkeypatch.setattr(hooks, "_purge_packages", lambda removed, errors, **kwargs: None) |
| 1334 | monkeypatch.setattr(hooks.shutil, "which", lambda name: "") |
| 1335 | |
| 1336 | |
| 1337 | def test_cleanup_hook_moves_retired_office_state_to_plugin_state(tmp_path, monkeypatch): |
| 1338 | _isolate_office_cleanup_hook(monkeypatch, tmp_path) |
| 1339 | retired_state = tmp_path / "usr" / "_office" |
| 1340 | plugin_state = tmp_path / "usr" / "plugins" / "_office" |
| 1341 | (retired_state / "documents" / "backups").mkdir(parents=True) |
| 1342 | (retired_state / "documents" / "documents.sqlite3").write_text("db\n", encoding="utf-8") |
| 1343 | (retired_state / "documents" / "backups" / "draft.md").write_text("backup\n", encoding="utf-8") |
| 1344 | (retired_state / "stale-cleanup-v3.done").write_text("ok\n", encoding="utf-8") |
| 1345 | plugin_state.mkdir(parents=True) |
| 1346 | |
| 1347 | result = hooks.cleanup_stale_runtime_state(force=True) |
| 1348 | |
| 1349 | assert result["ok"] is True |
| 1350 | assert (plugin_state / "documents" / "documents.sqlite3").read_text(encoding="utf-8") == "db\n" |
| 1351 | assert (plugin_state / "documents" / "backups" / "draft.md").read_text(encoding="utf-8") == "backup\n" |
| 1352 | assert (plugin_state / "stale-cleanup-v3.done").read_text(encoding="utf-8") == "ok\n" |
| 1353 | assert not retired_state.exists() |
| 1354 | |
| 1355 | |
| 1356 | def test_cleanup_hook_migrates_legacy_document_state_without_removing_source(tmp_path, monkeypatch): |
| 1357 | _isolate_office_cleanup_hook(monkeypatch, tmp_path) |
| 1358 | legacy_documents = tmp_path / "usr" / "state" / "_office" / "documents" |
| 1359 | document_state = tmp_path / "usr" / "plugins" / "_office" / "documents" |
| 1360 | legacy_documents.mkdir(parents=True) |
| 1361 | (legacy_documents / "documents.sqlite3").write_text("legacy-db\n", encoding="utf-8") |
| 1362 | (legacy_documents / "backups").mkdir() |
| 1363 | (legacy_documents / "backups" / "draft.md").write_text("backup\n", encoding="utf-8") |
| 1364 | |
| 1365 | monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", document_state) |
| 1366 | monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [legacy_documents]) |
| 1367 | result = hooks.cleanup_stale_runtime_state(force=True) |
| 1368 | |
| 1369 | assert result["ok"] is True |
| 1370 | assert result["migrated"] == [f"{legacy_documents} -> {document_state}"] |
| 1371 | assert (legacy_documents / "documents.sqlite3").exists() |
| 1372 | assert (document_state / "documents.sqlite3").read_text(encoding="utf-8") == "legacy-db\n" |
| 1373 | assert (document_state / "backups" / "draft.md").read_text(encoding="utf-8") == "backup\n" |
| 1374 | |
| 1375 | |
| 1376 | def test_cleanup_hook_prefers_existing_new_document_state_without_merge(tmp_path, monkeypatch): |
| 1377 | _isolate_office_cleanup_hook(monkeypatch, tmp_path) |
| 1378 | legacy_documents = tmp_path / "legacy-documents" |
| 1379 | document_state = tmp_path / "usr" / "plugins" / "_office" / "documents" |
| 1380 | legacy_documents.mkdir(parents=True) |
| 1381 | document_state.mkdir(parents=True) |
| 1382 | (legacy_documents / "documents.sqlite3").write_text("legacy-db\n", encoding="utf-8") |
| 1383 | (document_state / "documents.sqlite3").write_text("new-db\n", encoding="utf-8") |
| 1384 | |
| 1385 | monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", document_state) |
| 1386 | monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [legacy_documents]) |
| 1387 | result = hooks.cleanup_stale_runtime_state(force=True) |
| 1388 | |
| 1389 | assert result["ok"] is True |
| 1390 | assert result["migrated"] == [] |
| 1391 | assert result["warnings"] == [ |
| 1392 | f"Legacy Office document state left in place because {document_state} already exists: {legacy_documents}" |
| 1393 | ] |
| 1394 | assert (legacy_documents / "documents.sqlite3").read_text(encoding="utf-8") == "legacy-db\n" |
| 1395 | assert (document_state / "documents.sqlite3").read_text(encoding="utf-8") == "new-db\n" |
| 1396 | |
| 1397 | |
| 1398 | def test_cleanup_hook_targets_legacy_collabora_runtime_artifacts(): |
| 1399 | assert Path("/opt/cool") in hooks.RETIRED_WEB_RUNTIME_DIRS |
| 1400 | assert Path("/opt/collaboraoffice") in hooks.RETIRED_WEB_RUNTIME_DIRS |
| 1401 | assert { |
| 1402 | "collaboraoffice-ure", |
| 1403 | "collaboraofficebasis-core", |
| 1404 | "collaboraofficebasis-ooofonts", |
| 1405 | }.issubset(hooks.RETIRED_WEB_PACKAGES) |
| 1406 | |
| 1407 | |
| 1408 | def test_installed_retired_web_packages_discovers_collabora_split_packages(monkeypatch): |
| 1409 | monkeypatch.setattr( |
| 1410 | hooks.shutil, |
| 1411 | "which", |
| 1412 | lambda name: "/usr/bin/dpkg-query" if name == "dpkg-query" else "", |
| 1413 | ) |
| 1414 | monkeypatch.setattr( |
| 1415 | hooks, |
| 1416 | "_package_installed", |
| 1417 | lambda package: package in {"coolwsd", "collaboraofficebasis-core"}, |
| 1418 | ) |
| 1419 | |
| 1420 | def fake_run(command, **kwargs): |
| 1421 | assert command == ["dpkg-query", "-W", "-f=${binary:Package}\t${Status}\n", "collaboraoffice*"] |
| 1422 | return types.SimpleNamespace( |
| 1423 | returncode=0, |
| 1424 | stdout=( |
| 1425 | "collaboraofficebasis-core\tinstall ok installed\n" |
| 1426 | "collaboraofficebasis-extra-future\tinstall ok installed\n" |
| 1427 | "collaboraofficebasis-config-files\tdeinstall ok config-files\n" |
| 1428 | "notcollaboraoffice\tinstall ok installed\n" |
| 1429 | ), |
| 1430 | stderr="", |
| 1431 | ) |
| 1432 | |
| 1433 | monkeypatch.setattr(hooks.subprocess, "run", fake_run) |
| 1434 | |
| 1435 | packages = hooks._installed_retired_web_packages() |
| 1436 | |
| 1437 | assert packages == [ |
| 1438 | "coolwsd", |
| 1439 | "collaboraofficebasis-core", |
| 1440 | "collaboraofficebasis-extra-future", |
| 1441 | ] |
| 1442 | |
| 1443 | |
| 1444 | def test_cleanup_hook_removes_stale_runtime_state_idempotently(tmp_path, monkeypatch): |
| 1445 | source = tmp_path / "sources.list.d" / "retired.sources" |
| 1446 | keyring = tmp_path / "keyrings" / "retired.gpg" |
| 1447 | supervisor = tmp_path / "supervisor" / "retired.conf" |
| 1448 | runtime_dir = tmp_path / "runtime" |
| 1449 | legacy_cool_dir = tmp_path / "opt" / "cool" |
| 1450 | legacy_collabora_dir = tmp_path / "opt" / "collaboraoffice" |
| 1451 | marker = tmp_path / "state" / "cleanup.done" |
| 1452 | |
| 1453 | for path in (source, keyring, supervisor): |
| 1454 | path.parent.mkdir(parents=True, exist_ok=True) |
| 1455 | path.write_text("old\n", encoding="utf-8") |
| 1456 | (runtime_dir / "nested").mkdir(parents=True, exist_ok=True) |
| 1457 | (runtime_dir / "nested" / "state.txt").write_text("old\n", encoding="utf-8") |
| 1458 | (legacy_cool_dir / "state").mkdir(parents=True, exist_ok=True) |
| 1459 | (legacy_cool_dir / "state" / "cool.txt").write_text("old\n", encoding="utf-8") |
| 1460 | (legacy_collabora_dir / "program").mkdir(parents=True, exist_ok=True) |
| 1461 | (legacy_collabora_dir / "program" / "office.txt").write_text("old\n", encoding="utf-8") |
| 1462 | |
| 1463 | monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", source) |
| 1464 | monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", keyring) |
| 1465 | monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", supervisor) |
| 1466 | monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [runtime_dir, legacy_cool_dir, legacy_collabora_dir]) |
| 1467 | monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker) |
| 1468 | monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "plugins" / "_office" / "documents") |
| 1469 | monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", []) |
| 1470 | monkeypatch.setattr(hooks, "_installed_retired_web_packages", lambda: []) |
| 1471 | monkeypatch.setattr(hooks, "_installed_packages", lambda packages: []) |
| 1472 | monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None) |
| 1473 | def fake_ensure(installed, errors): |
| 1474 | assert not source.exists() |
| 1475 | installed.append("libreoffice-core") |
| 1476 | |
| 1477 | def fake_purge(removed, errors, **kwargs): |
| 1478 | return None |
| 1479 | |
| 1480 | monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", fake_ensure) |
| 1481 | monkeypatch.setattr(hooks, "_purge_packages", fake_purge) |
| 1482 | |
| 1483 | first = hooks.cleanup_stale_runtime_state(force=True) |
| 1484 | second = hooks.cleanup_stale_runtime_state(force=True) |
| 1485 | skipped = hooks.cleanup_stale_runtime_state() |
| 1486 | |
| 1487 | assert first["ok"] is True |
| 1488 | assert first["installed"] == ["libreoffice-core"] |
| 1489 | assert second["ok"] is True |
| 1490 | assert skipped["skipped"] is True |
| 1491 | assert not source.exists() |
| 1492 | assert not keyring.exists() |
| 1493 | assert not supervisor.exists() |
| 1494 | assert not runtime_dir.exists() |
| 1495 | assert not legacy_cool_dir.exists() |
| 1496 | assert not legacy_collabora_dir.exists() |
| 1497 | assert marker.exists() |
| 1498 | |
| 1499 | |
| 1500 | def test_office_startup_defers_persistent_desktop_runtime(monkeypatch): |
| 1501 | cleanup_calls = [] |
| 1502 | started_threads = [] |
| 1503 | monkeypatch.delitem( |
| 1504 | sys.modules, |
| 1505 | "plugins._office.extensions.python.startup_migration._20_office_routes", |
| 1506 | raising=False, |
| 1507 | ) |
| 1508 | |
| 1509 | from plugins._office.extensions.python.startup_migration import _20_office_routes as office_startup |
| 1510 | |
| 1511 | monkeypatch.setattr( |
| 1512 | office_startup.hooks, |
| 1513 | "cleanup_stale_runtime_state", |
| 1514 | lambda: cleanup_calls.append("cleanup") or {"ok": True, "errors": [], "installed": [], "removed": []}, |
| 1515 | ) |
| 1516 | |
| 1517 | class FakeThread: |
| 1518 | def __init__(self, *, target, name, daemon): |
| 1519 | self.target = target |
| 1520 | self.name = name |
| 1521 | self.daemon = daemon |
| 1522 | |
| 1523 | def is_alive(self): |
| 1524 | return False |
| 1525 | |
| 1526 | def start(self): |
| 1527 | started_threads.append(self) |
| 1528 | |
| 1529 | monkeypatch.setattr(office_startup.threading, "Thread", FakeThread) |
| 1530 | |
| 1531 | office_startup.OfficeStartupCleanup(agent=None).execute() |
| 1532 | |
| 1533 | assert cleanup_calls == [] |
| 1534 | assert len(started_threads) == 1 |
| 1535 | assert started_threads[0].name == "a0-office-document-runtime-preparation" |
| 1536 | assert started_threads[0].daemon is True |
| 1537 | assert not hasattr(office_startup, "desktop_session") |
| 1538 | |
| 1539 | started_threads[0].target() |
| 1540 | assert cleanup_calls == ["cleanup"] |
| 1541 | |
| 1542 | |
| 1543 | def test_cleanup_hook_reruns_when_stale_packages_exist_after_old_marker(tmp_path, monkeypatch): |
| 1544 | marker = tmp_path / "state" / "cleanup.done" |
| 1545 | marker.parent.mkdir(parents=True) |
| 1546 | marker.write_text("old\n", encoding="utf-8") |
| 1547 | |
| 1548 | monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", tmp_path / "missing.sources") |
| 1549 | monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", tmp_path / "missing.gpg") |
| 1550 | monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf") |
| 1551 | monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", []) |
| 1552 | monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker) |
| 1553 | monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "plugins" / "_office" / "documents") |
| 1554 | monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", []) |
| 1555 | retired_web_packages = [ |
| 1556 | "coolwsd", |
| 1557 | "collaboraoffice-ure", |
| 1558 | "collaboraofficebasis-core", |
| 1559 | "collaboraofficebasis-ooofonts", |
| 1560 | ] |
| 1561 | monkeypatch.setattr(hooks, "_installed_retired_web_packages", lambda: retired_web_packages) |
| 1562 | monkeypatch.setattr(hooks, "_installed_packages", lambda packages: []) |
| 1563 | monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None) |
| 1564 | monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None) |
| 1565 | def fake_purge(removed, errors, **kwargs): |
| 1566 | removed.extend(kwargs["installed_packages"]) |
| 1567 | |
| 1568 | monkeypatch.setattr(hooks, "_purge_packages", fake_purge) |
| 1569 | |
| 1570 | result = hooks.cleanup_stale_runtime_state() |
| 1571 | |
| 1572 | assert result["skipped"] is False |
| 1573 | assert result["removed"] == retired_web_packages |
| 1574 | |
| 1575 | |
| 1576 | def test_cleanup_hook_removes_retired_supervisor_program_after_marker(tmp_path, monkeypatch): |
| 1577 | marker = tmp_path / "state" / "cleanup.done" |
| 1578 | marker.parent.mkdir(parents=True) |
| 1579 | marker.write_text("ok\n", encoding="utf-8") |
| 1580 | calls = [] |
| 1581 | |
| 1582 | monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", tmp_path / "missing.sources") |
| 1583 | monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", tmp_path / "missing.gpg") |
| 1584 | monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf") |
| 1585 | monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", []) |
| 1586 | monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker) |
| 1587 | monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "plugins" / "_office" / "documents") |
| 1588 | monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", []) |
| 1589 | monkeypatch.setattr(hooks, "_installed_retired_web_packages", lambda: []) |
| 1590 | monkeypatch.setattr(hooks, "_installed_packages", lambda packages: []) |
| 1591 | monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None) |
| 1592 | monkeypatch.setattr(hooks.shutil, "which", lambda name: "/usr/bin/supervisorctl" if name == "supervisorctl" else "") |
| 1593 | |
| 1594 | def fake_supervisorctl(*args): |
| 1595 | calls.append(args) |
| 1596 | if args == ("status", hooks.RETIRED_WEB_SUPERVISOR_PROGRAM): |
| 1597 | return types.SimpleNamespace( |
| 1598 | returncode=0, |
| 1599 | stdout="a0_office_collabora BACKOFF can't find command\n", |
| 1600 | stderr="", |
| 1601 | ) |
| 1602 | return types.SimpleNamespace(returncode=0, stdout="", stderr="") |
| 1603 | |
| 1604 | monkeypatch.setattr(hooks, "_supervisorctl", fake_supervisorctl) |
| 1605 | |
| 1606 | result = hooks.cleanup_stale_runtime_state() |
| 1607 | |
| 1608 | assert result["ok"] is True |
| 1609 | assert result["skipped"] is True |
| 1610 | assert result["errors"] == [] |
| 1611 | assert calls == [ |
| 1612 | ("status", hooks.RETIRED_WEB_SUPERVISOR_PROGRAM), |
| 1613 | ("stop", hooks.RETIRED_WEB_SUPERVISOR_PROGRAM), |
| 1614 | ("remove", hooks.RETIRED_WEB_SUPERVISOR_PROGRAM), |
| 1615 | ("reread",), |
| 1616 | ("update",), |
| 1617 | ] |
| 1618 | |
| 1619 | |
| 1620 | def test_office_runtime_dependency_install_waits_out_apt_locks(monkeypatch): |
| 1621 | calls = [] |
| 1622 | installed_state = {"libreoffice-core": False} |
| 1623 | update_attempts = {"count": 0} |
| 1624 | |
| 1625 | monkeypatch.setattr(hooks.os, "geteuid", lambda: 0) |
| 1626 | monkeypatch.setattr( |
| 1627 | hooks.shutil, |
| 1628 | "which", |
| 1629 | lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query"} else "", |
| 1630 | ) |
| 1631 | monkeypatch.setattr(hooks, "RUNTIME_PACKAGES", ("libreoffice-core",)) |
| 1632 | monkeypatch.setattr(hooks, "_package_installed", lambda package: installed_state.get(package, False)) |
| 1633 | monkeypatch.setattr(system_packages.time, "sleep", lambda _seconds: None) |
| 1634 | |
| 1635 | def fake_run(command, **kwargs): |
| 1636 | calls.append(command) |
| 1637 | if command == ["apt-get", "update"]: |
| 1638 | update_attempts["count"] += 1 |
| 1639 | if update_attempts["count"] == 1: |
| 1640 | return types.SimpleNamespace( |
| 1641 | returncode=100, |
| 1642 | stdout="", |
| 1643 | stderr="E: Could not get lock /var/lib/apt/lists/lock. It is held by process 1829 (apt-get)", |
| 1644 | ) |
| 1645 | if command[:2] == ["apt-get", "install"]: |
| 1646 | installed_state["libreoffice-core"] = True |
| 1647 | return types.SimpleNamespace(returncode=0, stdout="", stderr="") |
| 1648 | |
| 1649 | monkeypatch.setattr(hooks.subprocess, "run", fake_run) |
| 1650 | installed = [] |
| 1651 | errors = [] |
| 1652 | |
| 1653 | hooks._ensure_runtime_dependencies(installed, errors) |
| 1654 | |
| 1655 | assert errors == [] |
| 1656 | assert installed == ["libreoffice-core"] |
| 1657 | assert calls[:2] == [["apt-get", "update"], ["apt-get", "update"]] |
| 1658 | assert calls[2][:4] == ["apt-get", "install", "-y", "--no-install-recommends"] |
| 1659 | |
| 1660 | |
| 1661 | def test_desktop_runtime_packages_include_libreoffice_for_desktop_status(): |
| 1662 | install_additional = ( |
| 1663 | PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_additional.sh" |
| 1664 | ).read_text(encoding="utf-8") |
| 1665 | |
| 1666 | assert set(desktop_hooks.LIBREOFFICE_RUNTIME_PACKAGES).issubset(desktop_hooks.RUNTIME_PACKAGES) |
| 1667 | assert "libreoffice-writer" in desktop_hooks.RUNTIME_PACKAGES |
| 1668 | assert "libreoffice-calc" in desktop_hooks.RUNTIME_PACKAGES |
| 1669 | assert "libreoffice-impress" in desktop_hooks.RUNTIME_PACKAGES |
| 1670 | assert desktop_hooks.GTK_RUNTIME_PACKAGE in desktop_hooks.RUNTIME_PACKAGES |
| 1671 | assert "xpra-client" in desktop_hooks.RUNTIME_PACKAGES |
| 1672 | assert "xpra-client-gtk3" in desktop_hooks.RUNTIME_PACKAGES |
| 1673 | assert f'XPRA_VERSION="{desktop_hooks.XPRA_VERSION}"' in install_additional |
| 1674 | |
| 1675 | |
| 1676 | def test_desktop_cleanup_moves_retired_state_to_plugin_state(tmp_path, monkeypatch): |
| 1677 | retired_state = tmp_path / "usr" / "_desktop" |
| 1678 | plugin_state = tmp_path / "usr" / "plugins" / "_desktop" |
| 1679 | (retired_state / "profiles" / "agent-zero-desktop").mkdir(parents=True) |
| 1680 | (retired_state / "profiles" / "agent-zero-desktop" / "profile.txt").write_text("profile\n", encoding="utf-8") |
| 1681 | (retired_state / "sessions").mkdir() |
| 1682 | (retired_state / "sessions" / "agent-zero-desktop.json").write_text("{}\n", encoding="utf-8") |
| 1683 | (retired_state / "screenshots").mkdir() |
| 1684 | (retired_state / "screenshots" / "desktop.png").write_bytes(b"png") |
| 1685 | plugin_state.mkdir(parents=True) |
| 1686 | |
| 1687 | monkeypatch.setattr(desktop_hooks, "STATE_DIR", plugin_state) |
| 1688 | monkeypatch.setattr(desktop_hooks, "RETIRED_STATE_DIR", retired_state) |
| 1689 | monkeypatch.setattr(desktop_hooks, "_installed_packages", lambda packages: []) |
| 1690 | monkeypatch.setattr(desktop_hooks, "_ensure_runtime_dependencies", lambda installed, errors: None) |
| 1691 | monkeypatch.setattr(desktop_hooks, "_cleanup_desktop_sessions", lambda errors: None) |
| 1692 | |
| 1693 | result = desktop_hooks.cleanup_stale_runtime_state(force=True) |
| 1694 | |
| 1695 | assert result["ok"] is True |
| 1696 | assert (plugin_state / "profiles" / "agent-zero-desktop" / "profile.txt").read_text(encoding="utf-8") == "profile\n" |
| 1697 | assert (plugin_state / "sessions" / "agent-zero-desktop.json").read_text(encoding="utf-8") == "{}\n" |
| 1698 | assert not (plugin_state / "screenshots").exists() |
| 1699 | assert any("Removed retired persistent Desktop screenshots" in warning for warning in result["warnings"]) |
| 1700 | assert not retired_state.exists() |
| 1701 | |
| 1702 | |
| 1703 | def test_cleanup_hook_installs_missing_desktop_session_dependencies(monkeypatch): |
| 1704 | calls = [] |
| 1705 | installed_state = {"xpra": False} |
| 1706 | |
| 1707 | monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0) |
| 1708 | monkeypatch.setattr(desktop_hooks.shutil, "which", lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query"} else "") |
| 1709 | monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", ("xpra",)) |
| 1710 | monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False)) |
| 1711 | |
| 1712 | def fake_run(command, **kwargs): |
| 1713 | calls.append(command) |
| 1714 | if command[:2] == ["apt-get", "install"]: |
| 1715 | installed_state["xpra"] = True |
| 1716 | return types.SimpleNamespace(returncode=0, stdout="", stderr="") |
| 1717 | |
| 1718 | monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run) |
| 1719 | installed = [] |
| 1720 | errors = [] |
| 1721 | |
| 1722 | desktop_hooks._ensure_runtime_dependencies(installed, errors) |
| 1723 | |
| 1724 | assert installed == ["xpra"] |
| 1725 | assert errors == [] |
| 1726 | assert calls[0] == ["apt-get", "update"] |
| 1727 | assert calls[1][:4] == ["apt-get", "install", "-y", "--no-install-recommends"] |
| 1728 | |
| 1729 | |
| 1730 | def test_cleanup_hook_enables_official_xpra_repo_when_kali_lacks_candidate(tmp_path, monkeypatch): |
| 1731 | calls = [] |
| 1732 | installed_state = {"xpra": False, "ca-certificates": True} |
| 1733 | keyring = tmp_path / "keyrings" / "xpra.asc" |
| 1734 | source = tmp_path / "sources.list.d" / "xpra.sources" |
| 1735 | |
| 1736 | monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0) |
| 1737 | monkeypatch.setattr( |
| 1738 | desktop_hooks.shutil, |
| 1739 | "which", |
| 1740 | lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "", |
| 1741 | ) |
| 1742 | monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", ("xpra",)) |
| 1743 | monkeypatch.setattr(desktop_hooks, "XPRA_KEYRING_FILE", keyring) |
| 1744 | monkeypatch.setattr(desktop_hooks, "XPRA_SOURCE_FILE", source) |
| 1745 | monkeypatch.setattr(desktop_hooks, "_download", lambda url: b"xpra-key") |
| 1746 | monkeypatch.setattr(desktop_hooks, "_read_os_release", lambda: {"ID": "kali", "VERSION_CODENAME": "kali-rolling"}) |
| 1747 | monkeypatch.setattr(desktop_hooks, "_dpkg_architecture", lambda: "amd64") |
| 1748 | monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False)) |
| 1749 | |
| 1750 | def fake_run(command, **kwargs): |
| 1751 | calls.append(command) |
| 1752 | if command[:2] == ["apt-cache", "policy"]: |
| 1753 | return types.SimpleNamespace(returncode=0, stdout="", stderr="") |
| 1754 | if command[:2] == ["apt-get", "install"]: |
| 1755 | installed_state["xpra"] = True |
| 1756 | return types.SimpleNamespace(returncode=0, stdout="", stderr="") |
| 1757 | |
| 1758 | monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run) |
| 1759 | installed = [] |
| 1760 | errors = [] |
| 1761 | |
| 1762 | desktop_hooks._ensure_runtime_dependencies(installed, errors) |
| 1763 | |
| 1764 | assert errors == [] |
| 1765 | assert installed == ["xpra"] |
| 1766 | assert keyring.read_bytes() == b"xpra-key" |
| 1767 | assert "URIs: https://xpra.org\n" in source.read_text(encoding="utf-8") |
| 1768 | assert "Suites: trixie" in source.read_text(encoding="utf-8") |
| 1769 | assert calls.count(["apt-get", "update"]) == 2 |
| 1770 | assert calls[-1][:4] == ["apt-get", "install", "-y", "--no-install-recommends"] |
| 1771 | |
| 1772 | |
| 1773 | def test_cleanup_hook_uses_trixie_xpra_components_for_kali_arm64(tmp_path, monkeypatch): |
| 1774 | calls = [] |
| 1775 | installed_state = {"xpra-server": False, "xpra-x11": False, "xpra-html5": False, "ca-certificates": True} |
| 1776 | keyring = tmp_path / "keyrings" / "xpra.asc" |
| 1777 | source = tmp_path / "sources.list.d" / "xpra.sources" |
| 1778 | |
| 1779 | monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0) |
| 1780 | monkeypatch.setattr( |
| 1781 | desktop_hooks.shutil, |
| 1782 | "which", |
| 1783 | lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "", |
| 1784 | ) |
| 1785 | monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", ("xpra-server", "xpra-x11", "xpra-html5")) |
| 1786 | monkeypatch.setattr(desktop_hooks, "XPRA_KEYRING_FILE", keyring) |
| 1787 | monkeypatch.setattr(desktop_hooks, "XPRA_SOURCE_FILE", source) |
| 1788 | monkeypatch.setattr(desktop_hooks, "_download", lambda url: b"xpra-key") |
| 1789 | monkeypatch.setattr(desktop_hooks, "_read_os_release", lambda: {"ID": "kali", "VERSION_CODENAME": "kali-rolling"}) |
| 1790 | monkeypatch.setattr(desktop_hooks, "_dpkg_architecture", lambda: "arm64") |
| 1791 | monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False)) |
| 1792 | |
| 1793 | def fake_run(command, **kwargs): |
| 1794 | calls.append(command) |
| 1795 | if command[:2] == ["apt-cache", "policy"]: |
| 1796 | return types.SimpleNamespace(returncode=0, stdout="", stderr="") |
| 1797 | if command[:2] == ["apt-get", "install"]: |
| 1798 | for package in command[4:]: |
| 1799 | installed_state[package] = True |
| 1800 | return types.SimpleNamespace(returncode=0, stdout="", stderr="") |
| 1801 | |
| 1802 | monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run) |
| 1803 | installed = [] |
| 1804 | errors = [] |
| 1805 | |
| 1806 | desktop_hooks._ensure_runtime_dependencies(installed, errors) |
| 1807 | |
| 1808 | assert errors == [] |
| 1809 | assert installed == ["xpra-server", "xpra-x11", "xpra-html5"] |
| 1810 | source_text = source.read_text(encoding="utf-8") |
| 1811 | assert "URIs: https://xpra.org\n" in source_text |
| 1812 | assert "Suites: trixie" in source_text |
| 1813 | assert "xpra" not in calls[-1] |
| 1814 | assert calls[-1][-3:] == [ |
| 1815 | f"xpra-server={desktop_hooks.XPRA_VERSION}", |
| 1816 | f"xpra-x11={desktop_hooks.XPRA_VERSION}", |
| 1817 | "xpra-html5", |
| 1818 | ] |
| 1819 | |
| 1820 | |
| 1821 | def test_cleanup_hook_installs_matching_xpra_client_stack(monkeypatch): |
| 1822 | calls = [] |
| 1823 | installed_state = { |
| 1824 | "xpra-client": False, |
| 1825 | "xpra-client-gtk3": False, |
| 1826 | } |
| 1827 | |
| 1828 | monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0) |
| 1829 | monkeypatch.setattr( |
| 1830 | desktop_hooks.shutil, |
| 1831 | "which", |
| 1832 | lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "", |
| 1833 | ) |
| 1834 | monkeypatch.setattr( |
| 1835 | desktop_hooks, |
| 1836 | "RUNTIME_PACKAGES", |
| 1837 | ("xpra-client", "xpra-client-gtk3"), |
| 1838 | ) |
| 1839 | monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False)) |
| 1840 | monkeypatch.setattr(desktop_hooks, "_package_version", lambda package: "6.5.2-r0-1") |
| 1841 | |
| 1842 | def fake_run(command, **kwargs): |
| 1843 | calls.append(command) |
| 1844 | if command[:2] == ["apt-cache", "policy"]: |
| 1845 | return types.SimpleNamespace(returncode=0, stdout="Candidate: 6.5.3-r0-1\n", stderr="") |
| 1846 | if command[:2] == ["apt-get", "install"]: |
| 1847 | installed_state["xpra-client"] = True |
| 1848 | installed_state["xpra-client-gtk3"] = True |
| 1849 | return types.SimpleNamespace(returncode=0, stdout="", stderr="") |
| 1850 | |
| 1851 | monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run) |
| 1852 | installed = [] |
| 1853 | errors = [] |
| 1854 | |
| 1855 | desktop_hooks._ensure_runtime_dependencies(installed, errors) |
| 1856 | |
| 1857 | assert installed == ["xpra-client", "xpra-client-gtk3"] |
| 1858 | assert errors == [] |
| 1859 | assert calls[-1][-2:] == ["xpra-client=6.5.2-r0-1", "xpra-client-gtk3=6.5.2-r0-1"] |
| 1860 | |
| 1861 | |
| 1862 | def test_cleanup_hook_repairs_kali_gtk_from_rolling_source(monkeypatch): |
| 1863 | calls = [] |
| 1864 | source_text = [] |
| 1865 | installed_state = {desktop_hooks.GTK_RUNTIME_PACKAGE: False} |
| 1866 | |
| 1867 | monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0) |
| 1868 | monkeypatch.setattr( |
| 1869 | desktop_hooks.shutil, |
| 1870 | "which", |
| 1871 | lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query"} else "", |
| 1872 | ) |
| 1873 | monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", (desktop_hooks.GTK_RUNTIME_PACKAGE,)) |
| 1874 | monkeypatch.setattr(desktop_hooks, "_read_os_release", lambda: {"ID": "kali"}) |
| 1875 | monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False)) |
| 1876 | |
| 1877 | def fake_run(command, **kwargs): |
| 1878 | calls.append(command) |
| 1879 | source_option = next( |
| 1880 | (item for item in command if item.startswith("Dir::Etc::sourcelist=")), |
| 1881 | "", |
| 1882 | ) |
| 1883 | if source_option: |
| 1884 | source_text.append(Path(source_option.split("=", 1)[1]).read_text(encoding="utf-8")) |
| 1885 | if "install" in command: |
| 1886 | installed_state[desktop_hooks.GTK_RUNTIME_PACKAGE] = True |
| 1887 | return types.SimpleNamespace(returncode=0, stdout="", stderr="") |
| 1888 | |
| 1889 | monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run) |
| 1890 | installed = [] |
| 1891 | errors = [] |
| 1892 | |
| 1893 | desktop_hooks._ensure_runtime_dependencies(installed, errors) |
| 1894 | |
| 1895 | assert installed == [desktop_hooks.GTK_RUNTIME_PACKAGE] |
| 1896 | assert errors == [] |
| 1897 | assert source_text == [desktop_hooks.KALI_ROLLING_SOURCE, desktop_hooks.KALI_ROLLING_SOURCE] |
| 1898 | assert calls[0][-1] == "update" |
| 1899 | assert calls[1][-2:] == ["--no-install-recommends", desktop_hooks.GTK_RUNTIME_PACKAGE] |
| 1900 | |
| 1901 | |
| 1902 | def test_cleanup_hook_reports_required_xpra_codec_conflict(monkeypatch): |
| 1903 | codec_error = ( |
| 1904 | "E: Unable to satisfy dependencies. Reached two conflicting assignments:\n" |
| 1905 | " 1. xpra-codecs:arm64=6.4.3-r0-1 is selected for install\n" |
| 1906 | " 2. xpra-codecs:arm64 Depends libvpx9 (>= 1.12.0)\n" |
| 1907 | " but none of the choices are installable: [no choices]" |
| 1908 | ) |
| 1909 | |
| 1910 | monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0) |
| 1911 | monkeypatch.setattr( |
| 1912 | desktop_hooks.shutil, |
| 1913 | "which", |
| 1914 | lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query", "apt-cache"} else "", |
| 1915 | ) |
| 1916 | monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", ("xpra-server",)) |
| 1917 | monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: False) |
| 1918 | |
| 1919 | def fake_run(command, **kwargs): |
| 1920 | if command[:2] == ["apt-cache", "policy"]: |
| 1921 | return types.SimpleNamespace(returncode=0, stdout="Candidate: 6.4.3-r0-1\n", stderr="") |
| 1922 | if command[:2] == ["apt-get", "install"]: |
| 1923 | return types.SimpleNamespace(returncode=100, stdout="", stderr=codec_error) |
| 1924 | return types.SimpleNamespace(returncode=0, stdout="", stderr="") |
| 1925 | |
| 1926 | monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run) |
| 1927 | installed = [] |
| 1928 | errors = [] |
| 1929 | |
| 1930 | desktop_hooks._ensure_runtime_dependencies(installed, errors) |
| 1931 | |
| 1932 | assert installed == [] |
| 1933 | assert errors == [codec_error] |
| 1934 | |
| 1935 | |
| 1936 | def test_self_update_launch_invokes_office_cleanup(monkeypatch, tmp_path): |
| 1937 | manager = load_self_update_manager() |
| 1938 | calls = [] |
| 1939 | |
| 1940 | class Logger: |
| 1941 | def log(self, message=""): |
| 1942 | return None |
| 1943 | |
| 1944 | class Process: |
| 1945 | pass |
| 1946 | |
| 1947 | monkeypatch.setattr(manager, "run_office_cleanup_hook", lambda repo_dir, logger: calls.append(repo_dir)) |
| 1948 | monkeypatch.setattr(manager, "run_command", lambda *args, **kwargs: None) |
| 1949 | monkeypatch.setattr(manager.subprocess, "Popen", lambda *args, **kwargs: Process()) |
| 1950 | |
| 1951 | repo = tmp_path / "repo" |
| 1952 | repo.mkdir() |
| 1953 | process = manager.launch_ui_process(repo, Logger()) |
| 1954 | |
| 1955 | assert isinstance(process, Process) |
| 1956 | assert calls == [repo] |
| 1957 | |
| 1958 | |
| 1959 | def load_self_update_manager(): |
| 1960 | manager_path = PROJECT_ROOT / "docker" / "run" / "fs" / "exe" / "self_update_manager.py" |
| 1961 | spec = importlib.util.spec_from_file_location("test_self_update_manager_office", manager_path) |
| 1962 | assert spec is not None and spec.loader is not None |
| 1963 | module = importlib.util.module_from_spec(spec) |
| 1964 | spec.loader.exec_module(module) |
| 1965 | return module |