Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions helpers/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
136 changes: 136 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""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 <repo>/usr
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

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)

# 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 = [
# 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,
)

# 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, 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"]
42 changes: 38 additions & 4 deletions tests/test_browser_agent_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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"},
Expand Down
8 changes: 6 additions & 2 deletions tests/test_docker_release_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions tests/test_model_config_project_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion tests/test_parallel_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions tests/test_snapshot_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
11 changes: 11 additions & 0 deletions tests/test_snapshot_schema_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion tests/test_speech_plugin_split.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<x-icon :name="$store.chatInput.sendButtonIcon"></x-icon>' in chat_bar
assert ':class="$store.chatInput.sendButtonClass"' in chat_bar
assert ':title="$store.chatInput.sendButtonTitle"' in chat_bar

Expand Down
14 changes: 10 additions & 4 deletions tests/test_time_travel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
1 change: 0 additions & 1 deletion tests/test_welcome_composer_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down