mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
The decomposition lanes have two safety nets and neither covers the product. The checkpoint benchmark measures the agent runtime; the computed-style snapshot pins the CSS. Nothing checked that Notes, Calendar, Documents, Email, Memory, Cookbook or Settings still worked after a route package moved or a 17,000-line module was split - and the unit suite does not, since a byte-identical file move can break tests that pass on the base branch with CI green throughout. The 28 Playwright specs we do have are all under tests/e2e/photo-editor/ and no workflow runs them. scripts/odysseus-smoke boots this worktree through `odysseus dev` and runs tests/smoke/: one scenario per area, each asserting a user-visible outcome rather than a status code. Models come from a deterministic OpenAI-compatible stub on an ephemeral loopback port; email reuses the existing ODYSSEUS_EMAIL_FIXTURE path rather than inventing a second mechanism. No scenario touches a live endpoint or the network. The report is a per-area table that prints the areas the suite does not cover next to the ones it does, and builds its rows from the registry rather than from what happened to run, so an area cannot go missing by having its module deleted or renamed. Under a plain pytest with nothing booted every scenario skips with the reason, so the full suite stays green.
249 lines
9.0 KiB
Python
249 lines
9.0 KiB
Python
"""Session wiring for the release smoke suite.
|
|
|
|
The suite drives a real instance over HTTP. It never starts one: that is
|
|
`scripts/odysseus-smoke`'s job, which boots the worktree through
|
|
`scripts/odysseus-dev` and hands the details over in the environment.
|
|
Run under a plain `pytest` with no instance up, every scenario skips
|
|
with the reason rather than failing, so the full suite stays green.
|
|
|
|
Three environment values form the contract, and they are exactly what
|
|
`odysseus dev env` prints plus the dev admin account:
|
|
|
|
APP_PORT - which instance, read through
|
|
`internal_api_base()`
|
|
ODYSSEUS_ADMIN_USER - the account to authenticate as
|
|
ODYSSEUS_ADMIN_PASSWORD
|
|
ODYSSEUS_DATA_DIR - where the email fixture file goes, read
|
|
through `src.constants.DATA_DIR`
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from src.constants import internal_api_base
|
|
from tests.helpers.cli_loader import load_script
|
|
from tests.smoke import areas
|
|
from tests.smoke.stub_provider import MODEL_PRIMARY, StubProvider
|
|
|
|
# How long a smoke request may take. Generous: the first turn through a
|
|
# cold agent path does real work, and a timeout here reads as a product
|
|
# failure, which is the one thing this suite must not get wrong.
|
|
REQUEST_TIMEOUT_SECONDS = 120.0
|
|
|
|
# Auth and endpoint routes the suite drives directly. Kept here so a
|
|
# route rename shows up in one place rather than twelve.
|
|
LOGIN_PATH = "/api/auth/login"
|
|
HEALTH_PATH = "/api/health"
|
|
ENDPOINTS_PATH = "/api/model-endpoints"
|
|
SESSION_PATH = "/api/session"
|
|
|
|
_NO_PORT = (
|
|
"APP_PORT is not set, so there is no instance to drive. Run the suite "
|
|
"with `scripts/odysseus-smoke`, which boots this worktree and exports it."
|
|
)
|
|
|
|
|
|
def _reserved_ports() -> dict:
|
|
"""`odysseus dev`'s own refuse-list, read from the launcher.
|
|
|
|
The smoke suite writes and deletes real records, so pointing it at a
|
|
port that means something - a normal launch of this checkout, the
|
|
machine's production instance - has to be impossible rather than
|
|
merely discouraged. Reusing the launcher's table keeps one source of
|
|
truth instead of a second copy that can drift.
|
|
"""
|
|
try:
|
|
return dict(load_script("odysseus-dev").RESERVED_PORTS)
|
|
except Exception: # pragma: no cover - launcher absent or unloadable
|
|
return {}
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def base_url() -> str:
|
|
"""The instance this run drives, or a skip explaining why there is none."""
|
|
port = (os.environ.get("APP_PORT") or "").strip()
|
|
if not port:
|
|
pytest.skip(_NO_PORT)
|
|
reason = _reserved_ports().get(int(port)) if port.isdigit() else None
|
|
if reason:
|
|
pytest.skip(
|
|
f"APP_PORT={port} is {reason}. The smoke suite creates and deletes "
|
|
f"real records, so it refuses to run against that instance."
|
|
)
|
|
return internal_api_base()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def account() -> dict:
|
|
user = (os.environ.get("ODYSSEUS_ADMIN_USER") or "").strip()
|
|
password = os.environ.get("ODYSSEUS_ADMIN_PASSWORD") or ""
|
|
if not user or not password:
|
|
pytest.skip(
|
|
"ODYSSEUS_ADMIN_USER / ODYSSEUS_ADMIN_PASSWORD are not set, so the "
|
|
"suite cannot authenticate. Run it with `scripts/odysseus-smoke`."
|
|
)
|
|
return {"username": user, "password": password}
|
|
|
|
|
|
def _new_client(base_url: str, account: dict) -> httpx.Client:
|
|
"""An authenticated client, or a skip naming what the instance said."""
|
|
client = httpx.Client(base_url=base_url, timeout=REQUEST_TIMEOUT_SECONDS,
|
|
follow_redirects=True)
|
|
try:
|
|
client.get(HEALTH_PATH)
|
|
except httpx.HTTPError as exc:
|
|
client.close()
|
|
pytest.skip(f"no instance answering at {base_url} ({exc}). Boot one with "
|
|
f"`odysseus dev up`, or run `scripts/odysseus-smoke`.")
|
|
response = client.post(LOGIN_PATH, json=account)
|
|
if response.status_code != 200:
|
|
client.close()
|
|
pytest.skip(
|
|
f"could not log in as {account['username']} at {base_url}: "
|
|
f"HTTP {response.status_code}. The recorded credentials may not "
|
|
f"match this instance's data dir."
|
|
)
|
|
return client
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def client(base_url, account):
|
|
"""One authenticated session shared by every scenario."""
|
|
handle = _new_client(base_url, account)
|
|
yield handle
|
|
handle.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def fresh_client(base_url, account):
|
|
"""A second authenticated session, for asserting something persisted.
|
|
|
|
Reading a value back on the same cookie proves the request handler
|
|
returned it. Reading it back on a new login is the closest a test can
|
|
get to the user reloading the page.
|
|
"""
|
|
handle = _new_client(base_url, account)
|
|
yield handle
|
|
handle.close()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def stub_provider():
|
|
"""The deterministic provider every model-backed scenario talks to."""
|
|
with StubProvider() as provider:
|
|
yield provider
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def stub_endpoint(client, stub_provider) -> str:
|
|
"""Register the stub as a model endpoint and return its id.
|
|
|
|
Registered as `endpoint_kind=local` so the app treats it the way it
|
|
treats a Cookbook-served model rather than probing it as a hosted
|
|
API, and removed afterwards so a `--keep-up` instance is not left
|
|
pointing at a port that has gone away.
|
|
"""
|
|
response = client.post(ENDPOINTS_PATH, data={
|
|
"name": "odysseus-smoke-stub",
|
|
"base_url": stub_provider.base_url,
|
|
"endpoint_kind": "local",
|
|
})
|
|
if response.status_code != 200:
|
|
pytest.skip(
|
|
f"the instance would not register the stub provider at "
|
|
f"{stub_provider.base_url}: HTTP {response.status_code} "
|
|
f"{response.text[:200]}"
|
|
)
|
|
body = response.json()
|
|
endpoint_id = str(body.get("id") or "")
|
|
if not endpoint_id:
|
|
pytest.skip(f"the endpoint the instance registered has no id: {body}")
|
|
if MODEL_PRIMARY not in (body.get("models") or []):
|
|
pytest.skip(
|
|
f"the instance did not discover {MODEL_PRIMARY} on the stub "
|
|
f"provider; it saw {body.get('models')}"
|
|
)
|
|
yield endpoint_id
|
|
client.delete(f"{ENDPOINTS_PATH}/{endpoint_id}")
|
|
|
|
|
|
@pytest.fixture
|
|
def chat_session(client, stub_endpoint):
|
|
"""A chat session bound to the stub provider, deleted afterwards."""
|
|
response = client.post(SESSION_PATH, data={
|
|
"name": "odysseus-smoke",
|
|
"endpoint_id": stub_endpoint,
|
|
"model": MODEL_PRIMARY,
|
|
})
|
|
assert response.status_code == 200, response.text
|
|
session_id = response.json()["id"]
|
|
yield session_id
|
|
client.delete(f"{SESSION_PATH}/{session_id}")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The per-area table
|
|
# --------------------------------------------------------------------------
|
|
# One row per area in `areas.COVERED`, built from the outcomes pytest
|
|
# reports rather than from anything a test asserts about itself, so a
|
|
# module that never ran cannot report a pass.
|
|
|
|
_outcomes: dict[str, list[str]] = {}
|
|
_details: dict[str, str] = {}
|
|
_checks: dict[str, int] = {}
|
|
|
|
|
|
def _skip_reason(report) -> str:
|
|
"""The reason text out of a skip report, best effort."""
|
|
longrepr = getattr(report, "longrepr", None)
|
|
if isinstance(longrepr, tuple) and len(longrepr) == 3:
|
|
reason = str(longrepr[2] or "")
|
|
return reason.removeprefix("Skipped: ").strip()
|
|
return str(longrepr or "").strip()
|
|
|
|
|
|
def pytest_runtest_logreport(report):
|
|
key = areas.area_for_module(os.path.basename(str(report.fspath)))
|
|
if key is None:
|
|
return
|
|
if report.skipped:
|
|
_outcomes.setdefault(key, []).append(areas.SKIP)
|
|
_details.setdefault(key, _skip_reason(report))
|
|
return
|
|
if report.failed:
|
|
_outcomes.setdefault(key, []).append(areas.FAIL)
|
|
_details[key] = f"{report.when} failed: {report.nodeid.split('::')[-1]}"
|
|
return
|
|
if report.when == "call" and report.passed:
|
|
_outcomes.setdefault(key, []).append(areas.PASS)
|
|
_checks[key] = _checks.get(key, 0) + 1
|
|
|
|
|
|
def pytest_terminal_summary(terminalreporter, exitstatus, config):
|
|
if not _outcomes:
|
|
return
|
|
results = {}
|
|
for key, outcomes in _outcomes.items():
|
|
results[key] = {
|
|
"result": areas.resolve(outcomes),
|
|
"checks": _checks.get(key, 0),
|
|
"detail": _details.get(key, ""),
|
|
}
|
|
|
|
if all(entry["result"] == areas.SKIP for entry in results.values()):
|
|
reasons = {entry["detail"] for entry in results.values() if entry["detail"]}
|
|
terminalreporter.write_line("")
|
|
terminalreporter.write_line(
|
|
"release smoke suite skipped: " + (
|
|
reasons.pop() if len(reasons) == 1 else "; ".join(sorted(reasons))
|
|
)
|
|
)
|
|
return
|
|
|
|
header = f"Odysseus release smoke - {internal_api_base()}"
|
|
terminalreporter.write_line("")
|
|
terminalreporter.write_line(areas.render_table(results, header=header))
|