2026-07-02 18:28:07 +02:00
|
|
|
"""e2e lifecycle smoke harness — collection gate + the stack fixture.
|
|
|
|
|
|
|
|
|
|
Scripted-agent smoke: an in-process RoboCo API (real routers, real
|
|
|
|
|
middleware, real gateway/choreographer/services) over the ephemeral test
|
|
|
|
|
Postgres, a local bare git origin standing in for GitHub, and a fake
|
|
|
|
|
GitHub REST layer whose merges are REAL git merges on that origin. A
|
|
|
|
|
deterministic driver calls the REAL MCP flow/do tool functions — no LLM
|
|
|
|
|
anywhere — so seam bugs (tool↔gate schema drift, squash merges, stale
|
|
|
|
|
refs, workspace routing) die here instead of in a live run.
|
|
|
|
|
|
|
|
|
|
Gating: excluded from the default suite (`make quality`); runs via
|
|
|
|
|
`make e2e-smoke` (sets ROBOCO_E2E_SMOKE=1). Needs the test Postgres
|
|
|
|
|
reachable and git on PATH, nothing else.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from tests.e2e_smoke.harness import build_e2e_stack
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from collections.abc import Iterator
|
|
|
|
|
|
2026-07-07 10:09:23 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2026-07-02 18:28:07 +02:00
|
|
|
from tests.e2e_smoke.harness import E2EStack
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def pytest_collection_modifyitems(
|
|
|
|
|
config: pytest.Config, items: list[pytest.Item]
|
|
|
|
|
) -> None:
|
|
|
|
|
if os.environ.get("ROBOCO_E2E_SMOKE") == "1":
|
|
|
|
|
return
|
|
|
|
|
skip = pytest.mark.skip(reason="e2e smoke runs via `make e2e-smoke` only")
|
|
|
|
|
for item in items:
|
|
|
|
|
if "tests/e2e_smoke" in str(item.path):
|
|
|
|
|
item.add_marker(skip)
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 10:09:23 +02:00
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _reset_lazy_db_holder() -> Iterator[None]:
|
|
|
|
|
"""Drop the app's lazy ``_DbHolder`` engine/factory between e2e tests.
|
|
|
|
|
|
|
|
|
|
Async smoke tests that drive production code using the global lazy engine
|
|
|
|
|
(``get_db_context`` / ``get_session_factory``) rebind ``_DbHolder`` to
|
|
|
|
|
their own function-scoped event loop. If a test leaves the holder bound to
|
|
|
|
|
its (now-dead) loop, the next consumer on a different loop — the uvicorn
|
|
|
|
|
server thread handling the next test's API call, or the next test itself —
|
|
|
|
|
reuses a factory bound to a dead loop and asyncpg raises
|
|
|
|
|
``RuntimeError: ... Future ... attached to a different loop``. Resetting
|
|
|
|
|
to ``None`` here makes every test start unbound so the first consumer
|
|
|
|
|
rebinds to its own loop. Disposal is intentionally not done here: an
|
|
|
|
|
engine bound to the uvicorn thread's loop cannot be awaited away from it.
|
|
|
|
|
"""
|
|
|
|
|
yield
|
|
|
|
|
from roboco.db import base as db_base
|
|
|
|
|
|
|
|
|
|
db_base._DbHolder.engine = None
|
|
|
|
|
db_base._DbHolder.session_factory = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _isolate_per_test_tables(e2e_stack: E2EStack) -> Iterator[None]:
|
|
|
|
|
"""Wipe the e2e DB between tests so per-test seeds never leak across tests.
|
|
|
|
|
|
|
|
|
|
``seed_company`` is session-cached and the canonical dev/qa/pm/... agent
|
|
|
|
|
rows are shared across every test by deterministic slug. A task any test
|
|
|
|
|
leaves ``pending`` assigned to that shared dev leaks into the session DB,
|
|
|
|
|
and the next test's ``give_me_work`` returns the leaked task instead of its
|
|
|
|
|
just-seeded one (the state-machine cascade: 8 tests got stale task "A"
|
|
|
|
|
from a prior test). The e2e app has no startup seed data, so the only
|
|
|
|
|
persisted state is what each test seeds. Truncating every table (agents
|
|
|
|
|
included — ``agents.current_task_id`` FK-references ``tasks``, so the
|
|
|
|
|
cycle means CASCADE on a partial set would wipe agents anyway) and
|
|
|
|
|
invalidating ``_COMPANY_CACHE`` gives complete per-test isolation: each
|
|
|
|
|
test re-seeds fresh canonical agents with new UUIDs against a clean DB.
|
|
|
|
|
"""
|
|
|
|
|
yield
|
|
|
|
|
|
|
|
|
|
from roboco.db.base import Base
|
|
|
|
|
from sqlalchemy import text
|
|
|
|
|
from tests.e2e_smoke.arcs import _COMPANY_CACHE
|
|
|
|
|
|
|
|
|
|
names = [t.name for t in Base.metadata.sorted_tables]
|
|
|
|
|
stmt = text(f"TRUNCATE TABLE {', '.join(names)} RESTART IDENTITY CASCADE")
|
|
|
|
|
|
|
|
|
|
async def _truncate(session: AsyncSession) -> None:
|
|
|
|
|
await session.execute(stmt)
|
|
|
|
|
|
|
|
|
|
e2e_stack.run_db(_truncate)
|
|
|
|
|
_COMPANY_CACHE.clear()
|
|
|
|
|
|
|
|
|
|
|
2026-07-02 18:28:07 +02:00
|
|
|
@pytest.fixture(scope="session")
|
|
|
|
|
def e2e_stack(
|
|
|
|
|
_test_database_url: str, tmp_path_factory: pytest.TempPathFactory
|
|
|
|
|
) -> Iterator[E2EStack]:
|
|
|
|
|
yield from build_e2e_stack(_test_database_url, tmp_path_factory)
|