From f67d37e91d86cf2ac5cea315a37864695f80b984 Mon Sep 17 00:00:00 2001 From: Zenetusken Date: Sat, 1 Aug 2026 19:31:00 -0400 Subject: [PATCH 1/4] test(suite): bootstrap conftest and stop tests writing the live usr tree tests/test_time_travel.py used PROJECT_ROOT/usr as scratch space. That is harmless on a dev checkout, but inside the deployed Docker container /usr is the persistent volume holding real chats, model presets, .env secrets and time-travel history - a plain pytest run destroyed user data (observed: model presets replaced with fixture content, all scoped model selections reset to Default on the next boot migration). - tests/test_time_travel.py: redirect helpers.files._base_dir to tmp_path in the workspace fixture and the symlink-alias test, so /a0/usr display paths resolve into tmp while display semantics stay identical. The module now passes with /usr mounted read-only. - tests/conftest.py (new): session bootstrap that * fails any test writing under the real /usr via the helpers.files write/delete functions or the dotenv/localization save path, turning silent live-data corruption into loud failures * bootstraps the telegram plugin's lazy aiogram dependency before collection, excluding test_telegram_* loudly when offline * excludes two legacy manual scripts that error at collection --- tests/conftest.py | 116 ++++++++++++++++++++++++++++++++++++++ tests/test_time_travel.py | 14 +++-- 2 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..6f30a3a8fe --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,116 @@ +"""Pytest session bootstrap for the framework test suite. + +- Triggers the Telegram plugin's lazy runtime dependency install (aiogram) + before collection so test_telegram_* modules import cleanly. If the + install is not possible (e.g. offline environment), the telegram modules + are excluded from collection instead of erroring the whole suite. +- Excludes legacy manual scripts that are not automated tests. +- Guards the live usr tree: any test that tries to write under /usr + fails loudly. Inside the deployed container that path is the persistent + volume holding real chats, model presets, .env secrets and time-travel + history, and suite runs have corrupted it in the past. Tests that need a + usr tree must redirect helpers.files._base_dir to tmp_path. +""" + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from helpers import dotenv as a0_dotenv +from helpers import files as a0_files + +_REAL_USR_DIR = os.path.realpath(os.path.join(a0_files.get_base_dir(), "usr")) + + +def _live_usr_path(path: object) -> str | None: + """Return the resolved live-usr path a write would hit, or None.""" + try: + candidate = str(path) + except Exception: + return None + if not os.path.isabs(candidate): + candidate = os.path.join(a0_files.get_base_dir(), candidate) + real = os.path.realpath(candidate) + if real == _REAL_USR_DIR or real.startswith(_REAL_USR_DIR + os.sep): + return real + return None + + +@pytest.fixture(autouse=True) +def _guard_live_usr_writes(monkeypatch): + """Fail any test that writes into the live Agent Zero usr tree.""" + + def guard_write(original): + def wrapper(relative_path, *args, **kwargs): + hit = _live_usr_path(relative_path) + if hit is not None: + raise RuntimeError( + f"test attempted to write live Agent Zero usr path: {hit}" + ) + return original(relative_path, *args, **kwargs) + + return wrapper + + for name in ( + "write_file", + "write_file_bin", + "write_file_base64", + "delete_file", + "delete_dir", + ): + monkeypatch.setattr(a0_files, name, guard_write(getattr(a0_files, name))) + + original_save = a0_dotenv.save_dotenv_value + + def guarded_save_dotenv_value(key, value): + hit = _live_usr_path(a0_dotenv.get_dotenv_file_path()) + if hit is not None: + raise RuntimeError( + f"test attempted to write live Agent Zero env file: {hit}" + ) + return original_save(key, value) + + monkeypatch.setattr(a0_dotenv, "save_dotenv_value", guarded_save_dotenv_value) + # helpers.localization imported save_dotenv_value by value, so it needs + # its own patch; tests that stub it themselves simply replace this one. + from helpers import localization as a0_localization + + monkeypatch.setattr( + a0_localization, "save_dotenv_value", guarded_save_dotenv_value + ) + yield + +collect_ignore = [ + # Legacy manual smoke scripts, not automated tests. email_parser_test.py + # imports helpers.email_client.read_messages, which no longer exists (the + # module is fully commented out), and its only test is marked skip with a + # note asking to move it to a script. rate_limiter_test.py performs a real + # LLM API call at import time. + "email_parser_test.py", + "rate_limiter_test.py", +] + +try: + from plugins._telegram_integration.helpers.dependencies import ( + ensure_dependencies, + ) + + ensure_dependencies() +except (RuntimeError, subprocess.CalledProcessError) as exc: + # Dependency installation failed (e.g. offline environment): exclude the + # telegram tests at collection time rather than failing the whole suite, + # but say so loudly - silently erasing five test files would mask real + # plugin regressions in CI. Import errors from plugin code itself are + # intentionally NOT caught here and will fail the run. + print( + f"WARNING: telegram dependencies unavailable ({exc}); " + "excluding test_telegram_* from collection." + ) + collect_ignore_glob = ["test_telegram_*.py"] diff --git a/tests/test_time_travel.py b/tests/test_time_travel.py index bd1d6552ab..abd96b4e53 100644 --- a/tests/test_time_travel.py +++ b/tests/test_time_travel.py @@ -24,6 +24,7 @@ _workspace_from_display, resolve_workspace, ) +from helpers import files as a0_files def run_git(repo_dir: Path, *args: str, check: bool = True) -> str: @@ -37,9 +38,13 @@ def run_git(repo_dir: Path, *args: str, check: bool = True) -> str: @pytest.fixture -def workspace(): +def workspace(tmp_path, monkeypatch): + # Redirect the framework base dir so /a0/usr display paths resolve into + # tmp_path instead of the live usr tree. Inside the deployed container + # the live tree is the persistent volume with real user data. + monkeypatch.setattr(a0_files, "_base_dir", str(tmp_path)) name = f"tt-{uuid.uuid4().hex}" - root = PROJECT_ROOT / "usr" / "time-travel-tests" / name + root = tmp_path / "usr" / "time-travel-tests" / name root.mkdir(parents=True) service = TimeTravelService(_workspace_from_display(f"/a0/usr/time-travel-tests/{name}")) try: @@ -169,9 +174,10 @@ def test_shadow_repo_empty_head_is_repaired_without_losing_history(workspace): ] -def test_workspace_identity_canonicalizes_symlink_aliases(): +def test_workspace_identity_canonicalizes_symlink_aliases(tmp_path, monkeypatch): + monkeypatch.setattr(a0_files, "_base_dir", str(tmp_path)) name = f"tt-{uuid.uuid4().hex}" - root = PROJECT_ROOT / "usr" / "time-travel-tests" / name + root = tmp_path / "usr" / "time-travel-tests" / name target = root / "target" alias = root / "alias" target.mkdir(parents=True) From 1c9c3855104327312710ed4234b8aa3b19805c33 Mon Sep 17 00:00:00 2001 From: Zenetusken Date: Sat, 1 Aug 2026 23:02:08 -0400 Subject: [PATCH 2/4] test(suite): keep tests away from live /a0/usr tree Bootstrap conftest, add helpers package init, and redirect regression test temp writes so the framework suite never corrupts the persistent usr volume. --- helpers/__init__.py | 8 +++++ tests/test_browser_agent_regressions.py | 42 +++++++++++++++++++--- tests/test_docker_release_plan.py | 8 +++-- tests/test_model_config_project_presets.py | 12 +++++++ 4 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 helpers/__init__.py diff --git a/helpers/__init__.py b/helpers/__init__.py new file mode 100644 index 0000000000..98a39f3e2d --- /dev/null +++ b/helpers/__init__.py @@ -0,0 +1,8 @@ +# This file must exist. Without it, `helpers` is imported as a namespace +# package (no __file__), which makes test/module cleanup helpers that purge +# `sys.modules` treat it as a stub and delete every loaded `helpers.*` +# module. The subsequent re-imports then split extension-registry state +# between stale and fresh module copies (observed as `Agent` instances +# losing extension-initialized attributes such as `loop_data` depending on +# test import order). Keeping `helpers` a regular package prevents that +# entire pollution class. diff --git a/tests/test_browser_agent_regressions.py b/tests/test_browser_agent_regressions.py index eaa49ef4a2..22978477b8 100644 --- a/tests/test_browser_agent_regressions.py +++ b/tests/test_browser_agent_regressions.py @@ -67,10 +67,24 @@ def error(code="", message="", correlation_id=None): ) -sys.modules.setdefault("agent", SimpleNamespace(AgentContext=_TestAgentContext)) -sys.modules.setdefault("helpers.tool", SimpleNamespace(Response=_TestResponse, Tool=_TestTool)) -sys.modules.setdefault("helpers.ws", SimpleNamespace(WsHandler=_TestWsHandler)) -sys.modules.setdefault("helpers.ws_manager", SimpleNamespace(WsResult=_TestWsResult)) +def _import_or_stub(module_name: str, stub: object) -> None: + """Use the real module when it imports cleanly; only fall back to a stub + in environments where the real import chain is unavailable. + + Unconditional sys.modules stubs poison every test module collected after + this one: they see the stub instead of the real module, and whether the + stub or the real module wins depends on collection order. + """ + try: + __import__(module_name) + except Exception: + sys.modules.setdefault(module_name, stub) + + +_import_or_stub("agent", SimpleNamespace(AgentContext=_TestAgentContext)) +_import_or_stub("helpers.tool", SimpleNamespace(Response=_TestResponse, Tool=_TestTool)) +_import_or_stub("helpers.ws", SimpleNamespace(WsHandler=_TestWsHandler)) +_import_or_stub("helpers.ws_manager", SimpleNamespace(WsResult=_TestWsResult)) _model_config_stub = ModuleType("plugins._model_config.helpers.model_config") _model_config_stub.get_presets = lambda: [] _model_config_stub.get_preset_by_name = lambda name: None @@ -2902,6 +2916,16 @@ async def fake_get_runtime(context_id, create=True): manager=None, ) + # emit_to requires a bound WsManager when the real helpers.ws module is + # in use (instead of the lightweight stub above); shadow it on the + # instance so the test behaves identically in both regimes. + emissions = [] + + async def _record_emit(sid, event, data, correlation_id=None): + emissions.append((sid, event, data)) + + handler.emit_to = _record_emit + result = await handler.process( "browser_viewer_command", {"context_id": "ctx-a", "command": "list"}, @@ -2953,6 +2977,16 @@ async def fake_list_runtime_sessions(): manager=None, ) + # emit_to requires a bound WsManager when the real helpers.ws module is + # in use (instead of the lightweight stub above); shadow it on the + # instance so the test behaves identically in both regimes. + emissions = [] + + async def _record_emit(sid, event, data, correlation_id=None): + emissions.append((sid, event, data)) + + handler.emit_to = _record_emit + result = await handler.process( "browser_viewer_command", {"context_id": "ctx-a", "command": "list"}, diff --git a/tests/test_docker_release_plan.py b/tests/test_docker_release_plan.py index c0fa6a589d..721e1a3ae3 100644 --- a/tests/test_docker_release_plan.py +++ b/tests/test_docker_release_plan.py @@ -44,12 +44,16 @@ def test_docker_publish_workflow_tracks_branch_promotions(): workflow_path = PROJECT_ROOT / ".github" / "workflows" / "docker-publish.yml" content = workflow_path.read_text(encoding="utf-8") - assert 'branches:\n - "testing"\n - "main"' in content + assert 'branches:\n - "testing"\n - "ready"\n - "main"' in content assert 'tags:\n - "v*"' in content assert "workflow_dispatch:" in content assert "inputs:" in content assert "tag:" in content - assert 'ref: ${{ matrix.source_tag }}' in content + # The build job no longer checks out the tag ref directly; it re-resolves + # the source tag through the plan script's TARGET_TAG env var instead, + # in both the resolve-build and resolve-release steps. + assert content.count("TARGET_TAG: ${{ matrix.source_tag }}") == 2 + assert 'ALLOWED_BRANCHES: "testing ready main"' in content assert "SOURCE_REF_TYPE: ${{ github.ref_type }}" in content assert "BEFORE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }}" in content diff --git a/tests/test_model_config_project_presets.py b/tests/test_model_config_project_presets.py index bb8c670454..bf4799c2b4 100644 --- a/tests/test_model_config_project_presets.py +++ b/tests/test_model_config_project_presets.py @@ -64,6 +64,18 @@ def _clear_runtime_caches(): modules.purge_namespace("usr.plugins") +@pytest.fixture(autouse=True) +def _restore_runtime_caches_after_test(): + # Tests in this module point helpers.files._base_dir at a tmp dir, so + # extension/plugin scans during the test repopulate the runtime caches + # with tmp-dir results. monkeypatch restores _base_dir at teardown but + # leaves the poisoned caches behind, which breaks subsequently collected + # modules (e.g. test_default_prompt_budget, test_browser_agent_regressions) + # whenever this module runs in the same process. + yield + _clear_runtime_caches() + + def _prepare_a0_tree(monkeypatch, tmp_path: Path): from helpers import files, plugins From 78c4e2b923b705d0d83776fccc8043703d7dd00c Mon Sep 17 00:00:00 2001 From: Zenetusken Date: Sat, 1 Aug 2026 23:37:40 -0400 Subject: [PATCH 3/4] test(suite): close guard bypass and redirect snapshot .env writes Adversarial review follow-ups to the live-usr guard: - helpers.task_scheduler imports write_file by value at module import time, so modules imported at collection time (test_task_scheduler_ timezone.py, test_timezone_regressions.py) bypassed the monkeypatched helpers.files guard and could write usr/scheduler/tasks.json straight through. The guard fixture now re-binds by-value imports in already imported modules (data-driven list; helpers.localization's save_dotenv_value folded into the same loop). - test_snapshot_parity.py / test_snapshot_schema_v1.py: build_snapshot applies the request timezone via Localization.set_timezone, which persists DEFAULT_USER_TIMEZONE into usr/.env whenever the persisted timezone or offset differs (latent live-volume write, e.g. after load_dotenv or DST drift). Stub save_dotenv_value in both modules; assertions unchanged. - conftest docstring: state the real guard scope (helpers.files and helpers.dotenv write paths) and note direct open()/Path/shutil/os. makedirs/subprocess writes are not intercepted. - Telegram dependency bootstrap: warn via warnings.warn (surfaces in the pytest summary) instead of print, catch OSError for a missing/broken uv binary, and comment the intentional collection-time install. --- tests/conftest.py | 58 +++++++++++++++++++++----------- tests/test_snapshot_parity.py | 11 ++++++ tests/test_snapshot_schema_v1.py | 11 ++++++ 3 files changed, 61 insertions(+), 19 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 6f30a3a8fe..0eb9fd4088 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,15 +6,20 @@ are excluded from collection instead of erroring the whole suite. - Excludes legacy manual scripts that are not automated tests. - Guards the live usr tree: any test that tries to write under /usr - fails loudly. Inside the deployed container that path is the persistent - volume holding real chats, model presets, .env secrets and time-travel - history, and suite runs have corrupted it in the past. Tests that need a - usr tree must redirect helpers.files._base_dir to tmp_path. + via the helpers.files write/delete functions or the helpers.dotenv save + path fails loudly. Inside the deployed container that path is the + persistent volume holding real chats, model presets, .env secrets and + time-travel history, and suite runs have corrupted it in the past. Tests + that need a usr tree must redirect helpers.files._base_dir to tmp_path. + Scope note: only those helpers.files / helpers.dotenv write paths are + intercepted. Direct open(), Path.write_text(), shutil, os.makedirs() or + subprocess writes from test or helper code are NOT guarded. """ import os import subprocess import sys +import warnings from pathlib import Path import pytest @@ -78,13 +83,23 @@ def guarded_save_dotenv_value(key, value): return original_save(key, value) monkeypatch.setattr(a0_dotenv, "save_dotenv_value", guarded_save_dotenv_value) - # helpers.localization imported save_dotenv_value by value, so it needs - # its own patch; tests that stub it themselves simply replace this one. - from helpers import localization as a0_localization - monkeypatch.setattr( - a0_localization, "save_dotenv_value", guarded_save_dotenv_value - ) + # Modules that did `from helpers.files/dotenv import ...` at their own + # import time (i.e. before this fixture ran) hold the ORIGINAL unguarded + # function object, bypassing the guard - e.g. helpers.task_scheduler + # imported at collection time by test_task_scheduler_timezone.py would + # write usr/scheduler/tasks.json straight through. Re-bind those names + # to the guarded versions. Modules not imported yet are skipped: a later + # `from helpers.files import write_file` picks up the already-guarded + # attribute. Tests that stub one of these names themselves simply + # replace this patch. + for module_name, attr, source in ( + ("helpers.localization", "save_dotenv_value", a0_dotenv), + ("helpers.task_scheduler", "write_file", a0_files), + ): + module = sys.modules.get(module_name) + if module is not None: + monkeypatch.setattr(module, attr, getattr(source, attr)) yield collect_ignore = [ @@ -102,15 +117,20 @@ def guarded_save_dotenv_value(key, value): ensure_dependencies, ) + # Intentional collection-time side effect: the telegram plugin installs + # its runtime dependencies lazily (uv pip install), so doing it up front + # here lets the test_telegram_* modules import cleanly. ensure_dependencies() -except (RuntimeError, subprocess.CalledProcessError) as exc: - # Dependency installation failed (e.g. offline environment): exclude the - # telegram tests at collection time rather than failing the whole suite, - # but say so loudly - silently erasing five test files would mask real - # plugin regressions in CI. Import errors from plugin code itself are - # intentionally NOT caught here and will fail the run. - print( - f"WARNING: telegram dependencies unavailable ({exc}); " - "excluding test_telegram_* from collection." +except (RuntimeError, subprocess.CalledProcessError, OSError) as exc: + # Dependency installation failed (e.g. offline environment, or a + # missing/broken uv binary): exclude the telegram tests at collection + # time rather than failing the whole suite, but say so loudly in the + # pytest warnings summary - silently erasing five test files would mask + # real plugin regressions in CI. Import errors from plugin code itself + # are intentionally NOT caught here and will fail the run. + warnings.warn( + f"telegram dependencies unavailable ({exc}); " + "excluding test_telegram_* from collection.", + stacklevel=2, ) collect_ignore_glob = ["test_telegram_*.py"] diff --git a/tests/test_snapshot_parity.py b/tests/test_snapshot_parity.py index ddb4336097..898f43c5d7 100644 --- a/tests/test_snapshot_parity.py +++ b/tests/test_snapshot_parity.py @@ -14,6 +14,17 @@ from api.poll import Poll +@pytest.fixture(autouse=True) +def _stub_env_persistence(monkeypatch): + """build_snapshot applies the request timezone via Localization.set_timezone, + which persists DEFAULT_USER_TIMEZONE into usr/.env. That file is the live + container volume, so stub the persistence call out; the snapshot payloads + under comparison are unaffected.""" + from helpers import localization + + monkeypatch.setattr(localization, "save_dotenv_value", lambda key, value: None) + + @pytest.mark.asyncio async def test_snapshot_builder_matches_poll_output_for_null_context(): app = Flask("snapshot-parity-test") diff --git a/tests/test_snapshot_schema_v1.py b/tests/test_snapshot_schema_v1.py index a72496f7b5..c8ec7df576 100644 --- a/tests/test_snapshot_schema_v1.py +++ b/tests/test_snapshot_schema_v1.py @@ -12,6 +12,17 @@ from api.poll import Poll +@pytest.fixture(autouse=True) +def _stub_env_persistence(monkeypatch): + """build_snapshot applies the request timezone via Localization.set_timezone, + which persists DEFAULT_USER_TIMEZONE into usr/.env. That file is the live + container volume, so stub the persistence call out; the schema under test + is unaffected.""" + from helpers import localization + + monkeypatch.setattr(localization, "save_dotenv_value", lambda key, value: None) + + EXPECTED_SNAPSHOT_KEYS = { "deselect_chat", "context", From 187faf84563ead9122e20d3d6da1778cc2433d99 Mon Sep 17 00:00:00 2001 From: Zenetusken Date: Sun, 2 Aug 2026 12:15:28 -0400 Subject: [PATCH 4/4] test(suite): align stale static-marker assertions with deliberate WebUI source changes - test_speech_plugin_split: send-button icon migrated from x-text to in upstream 93d1131c (Unify WebUI icons) - test_parallel_tool: .chat-list-button padding is now 8px 6px (same commit) - test_welcome_composer_static: drop background assertion; upstream bebe6826 deliberately removed the flat background from .welcome-container (radial-gradient guard retained) --- tests/test_parallel_tool.py | 2 +- tests/test_speech_plugin_split.py | 2 +- tests/test_welcome_composer_static.py | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_parallel_tool.py b/tests/test_parallel_tool.py index b6cadf0010..5e92ae4ebf 100644 --- a/tests/test_parallel_tool.py +++ b/tests/test_parallel_tool.py @@ -704,4 +704,4 @@ def test_chats_sidebar_projects_parallel_children_as_indented_accordion() -> Non assert "left: 2px" in html assert "padding-left: 24px" in html assert "color: var(--color-text-muted)" in html - assert "padding: 8px;" in html + assert "padding: 8px 6px;" in html diff --git a/tests/test_speech_plugin_split.py b/tests/test_speech_plugin_split.py index 624b4a348f..9041b431c1 100644 --- a/tests/test_speech_plugin_split.py +++ b/tests/test_speech_plugin_split.py @@ -183,7 +183,7 @@ def test_chat_bar_keeps_existing_send_and_mic_icon_contract() -> None: ).read_text(encoding="utf-8") assert 'id="send-button"' in chat_bar - assert 'x-text="$store.chatInput.sendButtonIcon"' in chat_bar + assert '' in chat_bar assert ':class="$store.chatInput.sendButtonClass"' in chat_bar assert ':title="$store.chatInput.sendButtonTitle"' in chat_bar diff --git a/tests/test_welcome_composer_static.py b/tests/test_welcome_composer_static.py index 6c7eed1cef..99261ae202 100644 --- a/tests/test_welcome_composer_static.py +++ b/tests/test_welcome_composer_static.py @@ -82,7 +82,6 @@ def test_welcome_screen_embeds_shared_new_chat_composer() -> None: assert "discovery-account-card" in discovery_cards assert "topHeroCards" not in discovery_cards assert "bottomHeroCards" not in discovery_cards - assert "background: var(--color-background);" in welcome assert "radial-gradient" not in welcome