mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-04 04:02:21 +01:00
Merge pull request #29 from o3LL/test/95-release-smoke-suite
test(smoke): add a release smoke suite over every advertised feature area
This commit is contained in:
Executable
+209
@@ -0,0 +1,209 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""odysseus-smoke — boot this worktree and drive every advertised feature area once.
|
||||||
|
|
||||||
|
The decomposition work has two safety nets and neither one covers the
|
||||||
|
product: the checkpoint benchmark measures the agent runtime, and 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. This is
|
||||||
|
that check, and it is deliberately shallow: one scenario per area,
|
||||||
|
asserting a user-visible outcome rather than an HTTP 200.
|
||||||
|
|
||||||
|
It owns no instance logic. `odysseus dev` already isolates the ports,
|
||||||
|
the data dir and ChromaDB per worktree, so this boots through it, hands
|
||||||
|
the details to pytest in the environment, and stops what it started.
|
||||||
|
|
||||||
|
odysseus smoke # boot, run every area, stop again
|
||||||
|
odysseus smoke --keep-up # leave the instance running afterwards
|
||||||
|
odysseus smoke --no-boot # drive whatever is already up here
|
||||||
|
odysseus smoke --restart # stop a running instance and boot fresh
|
||||||
|
odysseus smoke --areas # print the coverage table without running
|
||||||
|
odysseus smoke -- -k notes # everything after -- goes to pytest
|
||||||
|
|
||||||
|
The report is a per-area table, printed by the suite itself, listing the
|
||||||
|
areas it does not cover next to the ones it does. An area with no
|
||||||
|
scenario shows up as NOT RUN rather than going missing.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib"))
|
||||||
|
from cli import quiet_logs, fail, common_parser, run # noqa: E402
|
||||||
|
|
||||||
|
quiet_logs()
|
||||||
|
|
||||||
|
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||||
|
REPO_ROOT = SCRIPTS_DIR.parent
|
||||||
|
|
||||||
|
# The launcher this tool delegates every instance decision to.
|
||||||
|
DEV_SCRIPT = "odysseus-dev"
|
||||||
|
|
||||||
|
# What pytest is pointed at, relative to the checkout root.
|
||||||
|
SMOKE_SUITE = "tests/smoke"
|
||||||
|
|
||||||
|
# Email is the one area with no reachable real backend, and the repo
|
||||||
|
# already has a deterministic path for it. Turning it on is the reason
|
||||||
|
# this tool owns the boot rather than leaving it to the caller: the flag
|
||||||
|
# is read inside the app's process, so it has to be in the environment
|
||||||
|
# the app is started with.
|
||||||
|
EMAIL_FIXTURE_ENV = "ODYSSEUS_EMAIL_FIXTURE"
|
||||||
|
|
||||||
|
|
||||||
|
def load_dev():
|
||||||
|
"""Import `scripts/odysseus-dev` as a module.
|
||||||
|
|
||||||
|
Same loader the CLI tests use. Delegating by import rather than by
|
||||||
|
parsing `odysseus dev env` output means the port derivation and the
|
||||||
|
credential handling have exactly one implementation.
|
||||||
|
"""
|
||||||
|
path = SCRIPTS_DIR / DEV_SCRIPT
|
||||||
|
if not path.exists():
|
||||||
|
fail(f"{path} is missing; this tool boots through it.", code=2)
|
||||||
|
loader = importlib.machinery.SourceFileLoader("odysseus_dev_cli", str(path))
|
||||||
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def suite_environment(dev, root, ports, account):
|
||||||
|
"""The environment the smoke suite reads its target instance from.
|
||||||
|
|
||||||
|
Deliberately the same values `odysseus dev env` prints, plus the dev
|
||||||
|
admin account, so a manual `pytest tests/smoke` under
|
||||||
|
`eval $(odysseus dev env)` behaves the way this tool does.
|
||||||
|
"""
|
||||||
|
data_dir = dev.dev_dir(root) / "data"
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.update({
|
||||||
|
"APP_PORT": str(ports["app"]),
|
||||||
|
"CHROMADB_PORT": str(ports["chroma"]),
|
||||||
|
"ODYSSEUS_TEST_STATIC_PORT": str(ports["test_static"]),
|
||||||
|
"ODYSSEUS_DATA_DIR": str(data_dir),
|
||||||
|
"DATABASE_URL": f"sqlite:///{data_dir / 'app.db'}",
|
||||||
|
"ODYSSEUS_ADMIN_USER": account["username"],
|
||||||
|
"ODYSSEUS_ADMIN_PASSWORD": account["password"],
|
||||||
|
})
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def boot(dev, root, args):
|
||||||
|
"""Start the instance, or adopt one already running in this worktree.
|
||||||
|
|
||||||
|
Returns (started_by_us, note). A reused instance is never restarted
|
||||||
|
without being asked: it may be someone's debugging session, and the
|
||||||
|
one thing it can cost us is the email fixture flag, which the suite
|
||||||
|
reports as a skip rather than a pass.
|
||||||
|
"""
|
||||||
|
already = dev.running_app(dev.read_state(root))
|
||||||
|
if already and args.restart:
|
||||||
|
subprocess.run([sys.executable, str(SCRIPTS_DIR / DEV_SCRIPT), "down"],
|
||||||
|
cwd=str(root), check=False)
|
||||||
|
already = None
|
||||||
|
if already:
|
||||||
|
return False, (
|
||||||
|
f"reusing the instance already up on port {already['port']} "
|
||||||
|
f"(pid {already['pid']}). If it was not booted with "
|
||||||
|
f"{EMAIL_FIXTURE_ENV}=1 the Email area will report a skip; "
|
||||||
|
f"re-run with --restart for a clean boot."
|
||||||
|
)
|
||||||
|
if args.no_boot:
|
||||||
|
fail(
|
||||||
|
"nothing is running in this worktree and --no-boot was passed.\n"
|
||||||
|
" boot it with `odysseus dev up`, or drop --no-boot.",
|
||||||
|
)
|
||||||
|
|
||||||
|
command = [sys.executable, str(SCRIPTS_DIR / DEV_SCRIPT), "up"]
|
||||||
|
if args.venv:
|
||||||
|
command += ["--venv", args.venv]
|
||||||
|
env = dict(os.environ)
|
||||||
|
env[EMAIL_FIXTURE_ENV] = "1"
|
||||||
|
result = subprocess.run(command, cwd=str(root), env=env, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
fail(f"`odysseus dev up` exited {result.returncode}; not running the suite.")
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def venv_python(dev, root, args):
|
||||||
|
"""The interpreter to run pytest with: the one the app runs under."""
|
||||||
|
recorded = (dev.read_state(root) or {}).get("venv")
|
||||||
|
for candidate in (Path(args.venv).expanduser() if args.venv else None,
|
||||||
|
Path(recorded) if recorded else None,
|
||||||
|
Path(root) / "venv"):
|
||||||
|
if candidate and (candidate / "bin" / "python").exists():
|
||||||
|
return candidate / "bin" / "python"
|
||||||
|
fail(
|
||||||
|
f"no interpreter found for the suite (looked at {Path(root) / 'venv'}).\n"
|
||||||
|
f" build one with ./start-macos.sh, or pass --venv."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_run(args):
|
||||||
|
dev = load_dev()
|
||||||
|
root = dev.find_repo_root(Path.cwd())
|
||||||
|
if root is None:
|
||||||
|
fail(f"not inside an Odysseus checkout (looked upwards from {Path.cwd()})", code=2)
|
||||||
|
|
||||||
|
if args.areas:
|
||||||
|
sys.path.insert(0, str(root))
|
||||||
|
from tests.smoke import areas
|
||||||
|
sys.stdout.write(areas.render_table({}, header="Odysseus release smoke - coverage") + "\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
ports = dev.derive_ports(root)
|
||||||
|
account = dev.credentials(root)
|
||||||
|
started_by_us, note = boot(dev, root, args)
|
||||||
|
if note:
|
||||||
|
sys.stdout.write(f" {note}\n")
|
||||||
|
|
||||||
|
python = venv_python(dev, root, args)
|
||||||
|
env = suite_environment(dev, root, ports, account)
|
||||||
|
command = [str(python), "-m", "pytest", SMOKE_SUITE, "-q"] + list(args.pytest_args)
|
||||||
|
sys.stdout.write(f"\n running {SMOKE_SUITE} against http://127.0.0.1:{ports['app']}\n\n")
|
||||||
|
# Flush before handing the terminal to pytest, or our own lines land
|
||||||
|
# after its output and the report reads out of order.
|
||||||
|
sys.stdout.flush()
|
||||||
|
result = subprocess.run(command, cwd=str(root), env=env, check=False)
|
||||||
|
|
||||||
|
if started_by_us and not args.keep_up:
|
||||||
|
subprocess.run([sys.executable, str(SCRIPTS_DIR / DEV_SCRIPT), "down"],
|
||||||
|
cwd=str(root), check=False)
|
||||||
|
elif started_by_us:
|
||||||
|
sys.stdout.write(
|
||||||
|
f"\n left running: http://127.0.0.1:{ports['app']} "
|
||||||
|
f"({account['username']} / {account['password']})\n"
|
||||||
|
f" stop it with `odysseus dev down`\n"
|
||||||
|
)
|
||||||
|
# `cli.run` discards a returned value but lets SystemExit through, and
|
||||||
|
# a smoke run's exit code is the whole point of having one command.
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise SystemExit(result.returncode)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser():
|
||||||
|
parser = common_parser("odysseus-smoke",
|
||||||
|
"Boot this worktree and run the release smoke suite.")
|
||||||
|
parser.add_argument("--keep-up", action="store_true",
|
||||||
|
help="leave the instance running after the suite finishes")
|
||||||
|
parser.add_argument("--no-boot", action="store_true",
|
||||||
|
help="require an instance already up in this worktree")
|
||||||
|
parser.add_argument("--restart", action="store_true",
|
||||||
|
help="stop a running instance and boot a fresh one")
|
||||||
|
parser.add_argument("--venv", help="use this venv instead of ./venv")
|
||||||
|
parser.add_argument("--areas", action="store_true",
|
||||||
|
help="print the coverage table and exit without booting")
|
||||||
|
parser.add_argument("pytest_args", nargs="*", metavar="-- PYTEST ARGS",
|
||||||
|
help="arguments forwarded to pytest after a literal --")
|
||||||
|
parser.set_defaults(func=cmd_run)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(run(build_parser()))
|
||||||
@@ -158,6 +158,31 @@ The inventory, the baseline and the capture live in `tests/css_snapshot/`;
|
|||||||
not, and how to find the property that moved when it fails. The run takes about
|
not, and how to find the property that moved when it fails. The run takes about
|
||||||
21 seconds and skips when `npm ci` has not been run.
|
21 seconds and skips when `npm ci` has not been run.
|
||||||
|
|
||||||
|
## Release smoke suite
|
||||||
|
|
||||||
|
`tests/smoke/` drives every advertised feature area once, end to end,
|
||||||
|
against a real instance - the safety net the unit suite does not provide
|
||||||
|
for a route move or a module split. One command boots the worktree and
|
||||||
|
runs it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/odysseus-smoke # boot, run every area, stop again
|
||||||
|
scripts/odysseus-smoke --keep-up # leave the instance running
|
||||||
|
scripts/odysseus-smoke --areas # the coverage table, without booting
|
||||||
|
```
|
||||||
|
|
||||||
|
It reads its target instance out of the environment (`APP_PORT` through
|
||||||
|
`internal_api_base()`, plus the dev admin account), so under a plain
|
||||||
|
`pytest` with nothing booted every scenario skips with the reason and
|
||||||
|
the full suite stays green. Models are served by a deterministic
|
||||||
|
loopback stub, never a live endpoint; email uses the repo's existing
|
||||||
|
`ODYSSEUS_EMAIL_FIXTURE` path.
|
||||||
|
|
||||||
|
The report is a per-area table that also prints the areas the suite
|
||||||
|
deliberately does not cover, so it cannot be read as coverage of
|
||||||
|
everything it omits. `tests/smoke/README.md` documents what is in each
|
||||||
|
list and why.
|
||||||
|
|
||||||
## Core principles
|
## Core principles
|
||||||
|
|
||||||
- Keep PRs small and homogeneous: one kind of change per PR.
|
- Keep PRs small and homogeneous: one kind of change per PR.
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Release smoke suite
|
||||||
|
|
||||||
|
One command that boots this worktree and drives every advertised feature
|
||||||
|
area once, end to end, against a real instance.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/odysseus-smoke # boot, run every area, stop again
|
||||||
|
scripts/odysseus-smoke --keep-up # leave the instance running afterwards
|
||||||
|
scripts/odysseus-smoke --no-boot # drive whatever is already up here
|
||||||
|
scripts/odysseus-smoke --areas # print the coverage table without booting
|
||||||
|
scripts/odysseus-smoke -- -k notes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why it exists
|
||||||
|
|
||||||
|
The decomposition work had two safety nets and neither covered the
|
||||||
|
product. The checkpoint benchmark measures the agent runtime. The
|
||||||
|
computed-style snapshot in `tests/test_css_computed_style_snapshot.py`
|
||||||
|
pins the rendered 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:
|
||||||
|
`StressTestor`'s review of #5898 is the worked proof that a
|
||||||
|
byte-identical file-for-file move can break eleven tests that pass on
|
||||||
|
the base branch, with CI green throughout.
|
||||||
|
|
||||||
|
## Where it lives and why
|
||||||
|
|
||||||
|
pytest, not Playwright. Both are in the repo, so this adds no third
|
||||||
|
harness, and the choice went to pytest because every scenario here is a
|
||||||
|
request/response round trip rather than a rendering assertion -
|
||||||
|
rendering is already covered by the computed-style snapshot, and the
|
||||||
|
28 Playwright specs under `tests/e2e/photo-editor/` are the one area
|
||||||
|
with browser coverage. A browser would have added flake and start-up
|
||||||
|
cost for no extra signal.
|
||||||
|
|
||||||
|
It owns no instance logic. `scripts/odysseus-dev` already derives ports
|
||||||
|
per worktree, keeps the data dir and ChromaDB out of `data/`, and waits
|
||||||
|
on `/api/ready` rather than a TCP accept, so `scripts/odysseus-smoke`
|
||||||
|
boots through it and only adds the scenarios and the report.
|
||||||
|
|
||||||
|
## The contract with the runner
|
||||||
|
|
||||||
|
Four environment values, which are what `odysseus dev env` prints plus
|
||||||
|
the dev admin account:
|
||||||
|
|
||||||
|
| Variable | Read through | Used for |
|
||||||
|
|---|---|---|
|
||||||
|
| `APP_PORT` | `src.constants.internal_api_base()` | which instance to drive |
|
||||||
|
| `ODYSSEUS_ADMIN_USER` | - | who to authenticate as |
|
||||||
|
| `ODYSSEUS_ADMIN_PASSWORD` | - | " |
|
||||||
|
| `ODYSSEUS_DATA_DIR` | `src.constants.DATA_DIR` | where the email fixture file goes |
|
||||||
|
|
||||||
|
Run under a plain `pytest` with none of them set, every scenario skips
|
||||||
|
with the reason and the full suite stays green. `APP_PORT` pointing at
|
||||||
|
one of `odysseus dev`'s reserved ports - a normal launch of this
|
||||||
|
checkout, the machine's own instance - is refused rather than driven,
|
||||||
|
because the scenarios create and delete real records.
|
||||||
|
|
||||||
|
## The deterministic provider
|
||||||
|
|
||||||
|
`stub_provider.py` is an OpenAI-compatible server on an ephemeral
|
||||||
|
loopback port: `GET /v1/models` and `POST /v1/chat/completions`, both
|
||||||
|
buffered and streamed. No scenario touches a live model endpoint or the
|
||||||
|
network. It serves two model ids so the Compare area has something to
|
||||||
|
reveal, and it records every request so a scenario can assert the user's
|
||||||
|
message actually reached the provider rather than only that some text
|
||||||
|
came back.
|
||||||
|
|
||||||
|
Email uses the repo's own deterministic path rather than a second
|
||||||
|
mechanism: `routes/email_routes.py` serves a fixture inbox when
|
||||||
|
`ODYSSEUS_EMAIL_FIXTURE=1` and a fixture file is in the data dir. The
|
||||||
|
suite writes the file and restores whatever was there; the flag is read
|
||||||
|
inside the app's process, which is why the runner owns the boot.
|
||||||
|
|
||||||
|
## What is covered
|
||||||
|
|
||||||
|
One scenario per area, each asserting a user-visible outcome rather than
|
||||||
|
a status code. `scripts/odysseus-smoke --areas` prints the current list.
|
||||||
|
|
||||||
|
| Area | What it asserts |
|
||||||
|
|---|---|
|
||||||
|
| Chat | a turn against the stub comes back rendered, on both the buffered and the streamed path, and is in the session history |
|
||||||
|
| Compare | a blind comparison streams both sides and the vote reveals which model produced which reply |
|
||||||
|
| Notes | a note is listed, read back, edited, and 404s after delete |
|
||||||
|
| Calendar | an event appears in the window the UI queries and is gone after delete |
|
||||||
|
| Tasks | a daily task is accepted with a computed next run, is listed, and pauses |
|
||||||
|
| Documents (editor) | an edit adds a version, both versions read back, and a restore returns the first |
|
||||||
|
| Documents (RAG) | an uploaded file is chunked, indexed and listed |
|
||||||
|
| Email | the fixture inbox lists, opens with its body, and the unread count drops on mark-read |
|
||||||
|
| Memory | a fact is listed, found by search, and gone after delete |
|
||||||
|
| Uploads | an attachment reads back byte for byte |
|
||||||
|
| Cookbook | hardware is detected and recommendations come back sized against it; state persists |
|
||||||
|
| Settings | a preference written on one session is still there after a new login |
|
||||||
|
|
||||||
|
## What is not covered, and why
|
||||||
|
|
||||||
|
Printed next to the results on every run, so a reader cannot mistake the
|
||||||
|
table for coverage of everything it does not mention. `DECLARED_GAPS` in
|
||||||
|
`areas.py` is the list; the short version:
|
||||||
|
|
||||||
|
- **Deep Research** and **Web Search** need live egress. A deterministic
|
||||||
|
stub for the crawler would be an application change, which this is
|
||||||
|
not.
|
||||||
|
- **Email over IMAP/SMTP** is covered only as far as the fixture path
|
||||||
|
goes. There is no local mail server, so real account sync and send are
|
||||||
|
untested.
|
||||||
|
- **Cookbook download and serve** needs tmux, a GPU runtime and a
|
||||||
|
multi-gigabyte download.
|
||||||
|
- **Gallery and the photo editor** already have the repo's only
|
||||||
|
Playwright specs.
|
||||||
|
- **The agent tool loop** is what the checkpoint benchmark measures.
|
||||||
|
- **MCP servers** are stdio subprocesses outside the app's readiness
|
||||||
|
contract.
|
||||||
|
- **Rendering and layout** are pinned by the computed-style snapshot.
|
||||||
|
|
||||||
|
`Documents (RAG)` is the one covered area that can report `SKIP` on a
|
||||||
|
clean checkout: `requirements.txt` pins `chromadb-client`, the HTTP
|
||||||
|
client, and the ChromaDB *server* is a separate install. Without one
|
||||||
|
reachable, the upload route returns a deliberate 503 and the row reads
|
||||||
|
`SKIP` with that reason. Install `chromadb` in the venv and it goes
|
||||||
|
green.
|
||||||
|
|
||||||
|
## Reading the report
|
||||||
|
|
||||||
|
The table has one row per area in `areas.COVERED`, built from what
|
||||||
|
pytest reported rather than from anything a scenario asserts about
|
||||||
|
itself. An area whose module never ran shows as `NOT RUN`, so deleting
|
||||||
|
or renaming a file cannot make a row disappear -
|
||||||
|
`tests/test_smoke_area_table.py` pins that, and that a module on disk
|
||||||
|
must be registered.
|
||||||
|
|
||||||
|
## What a run leaves behind
|
||||||
|
|
||||||
|
Every scenario deletes what it created, with two exceptions on the
|
||||||
|
scratch instance: the preference key `odysseus_smoke_preference`, which
|
||||||
|
has no delete route, and the uploaded attachment, which the app's own
|
||||||
|
upload cleanup owns. Both live in `.odysseus-dev/data/`, never in
|
||||||
|
`data/`.
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""The area registry and the result table for the release smoke suite.
|
||||||
|
|
||||||
|
Pure stdlib on purpose: this module is the one part of the suite that has
|
||||||
|
to be readable and testable without a running instance, because it is
|
||||||
|
what decides whether the suite's output is honest.
|
||||||
|
|
||||||
|
Two lists matter here and they are both deliberate:
|
||||||
|
|
||||||
|
``COVERED`` names every feature area the suite drives, and the test
|
||||||
|
module that drives it. A row appears in the table whether or not its
|
||||||
|
module ran, so an area cannot quietly vanish from the report by having
|
||||||
|
its file deleted or renamed - it shows up as ``NOT RUN`` instead.
|
||||||
|
|
||||||
|
``DECLARED_GAPS`` names the areas the suite does *not* cover, with the
|
||||||
|
reason. They are printed alongside the results rather than left out,
|
||||||
|
because a smoke report that lists only what it checked reads as
|
||||||
|
coverage of everything it does not mention.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import textwrap
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# Result labels. ASCII only - no Unicode status glyphs anywhere in the
|
||||||
|
# table (repo convention: no emoji in UI or code).
|
||||||
|
PASS = "PASS"
|
||||||
|
FAIL = "FAIL"
|
||||||
|
SKIP = "SKIP"
|
||||||
|
NOT_RUN = "NOT RUN"
|
||||||
|
NOT_COVERED = "NOT COVERED"
|
||||||
|
|
||||||
|
# Precedence when one area's module produces several outcomes: a single
|
||||||
|
# failure decides the row, then a skip, then pass.
|
||||||
|
_PRECEDENCE = (FAIL, SKIP, PASS)
|
||||||
|
|
||||||
|
# Table geometry. Wide enough for the longest gap reason to read as a
|
||||||
|
# sentence, narrow enough to survive a normal terminal.
|
||||||
|
TABLE_WIDTH = 100
|
||||||
|
MIN_DETAIL_WIDTH = 30
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Area:
|
||||||
|
"""One advertised feature area and the module that exercises it."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
module: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Gap:
|
||||||
|
"""An area this suite does not cover, and why it does not."""
|
||||||
|
|
||||||
|
label: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
# Order is the order the table prints in: the chat surface first, then
|
||||||
|
# the feature areas README.md advertises, then the setup surface.
|
||||||
|
COVERED = (
|
||||||
|
Area("chat", "Chat", "test_chat_smoke.py"),
|
||||||
|
Area("compare", "Compare", "test_compare_smoke.py"),
|
||||||
|
Area("notes", "Notes", "test_notes_smoke.py"),
|
||||||
|
Area("calendar", "Calendar", "test_calendar_smoke.py"),
|
||||||
|
Area("tasks", "Tasks (scheduled)", "test_tasks_smoke.py"),
|
||||||
|
Area("documents", "Documents (editor)", "test_documents_smoke.py"),
|
||||||
|
Area("documents_rag", "Documents (RAG)", "test_documents_rag_smoke.py"),
|
||||||
|
Area("email", "Email", "test_email_smoke.py"),
|
||||||
|
Area("memory", "Memory", "test_memory_smoke.py"),
|
||||||
|
Area("uploads", "Uploads", "test_uploads_smoke.py"),
|
||||||
|
Area("cookbook", "Cookbook", "test_cookbook_smoke.py"),
|
||||||
|
Area("settings", "Settings", "test_settings_smoke.py"),
|
||||||
|
)
|
||||||
|
|
||||||
|
DECLARED_GAPS = (
|
||||||
|
Gap(
|
||||||
|
"Deep Research",
|
||||||
|
"needs live web egress; the crawler has no deterministic stub and adding "
|
||||||
|
"one would be an application change",
|
||||||
|
),
|
||||||
|
Gap(
|
||||||
|
"Web Search",
|
||||||
|
"needs a reachable SearXNG or an external provider, so the result is not "
|
||||||
|
"reproducible from a clean checkout",
|
||||||
|
),
|
||||||
|
Gap(
|
||||||
|
"Email over IMAP/SMTP",
|
||||||
|
"covered through the existing ODYSSEUS_EMAIL_FIXTURE path only; no local "
|
||||||
|
"mail server, so real account sync and send are untested",
|
||||||
|
),
|
||||||
|
Gap(
|
||||||
|
"Cookbook download and serve",
|
||||||
|
"needs tmux, a GPU runtime and a multi-GB model download; only hardware "
|
||||||
|
"fit and state sync are checked",
|
||||||
|
),
|
||||||
|
Gap(
|
||||||
|
"Gallery and photo editor",
|
||||||
|
"already the one area with Playwright specs under tests/e2e/photo-editor/",
|
||||||
|
),
|
||||||
|
Gap(
|
||||||
|
"Agent tool loop",
|
||||||
|
"measured by the checkpoint benchmark, which is the safety net that does "
|
||||||
|
"cover the agent runtime",
|
||||||
|
),
|
||||||
|
Gap(
|
||||||
|
"MCP servers",
|
||||||
|
"the built-in servers are stdio subprocesses whose readiness is not part "
|
||||||
|
"of the app's own readiness contract",
|
||||||
|
),
|
||||||
|
Gap(
|
||||||
|
"Rendering and layout",
|
||||||
|
"pinned by the computed-style snapshot in "
|
||||||
|
"tests/test_css_computed_style_snapshot.py",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
_MODULE_TO_KEY = {area.module: area.key for area in COVERED}
|
||||||
|
|
||||||
|
|
||||||
|
def area_for_module(module_name: str) -> str | None:
|
||||||
|
"""Map a test module filename to its area key, or None."""
|
||||||
|
return _MODULE_TO_KEY.get(module_name)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(outcomes: list[str]) -> str:
|
||||||
|
"""Collapse one module's outcomes into the row's single result."""
|
||||||
|
if not outcomes:
|
||||||
|
return NOT_RUN
|
||||||
|
for label in _PRECEDENCE:
|
||||||
|
if label in outcomes:
|
||||||
|
return label
|
||||||
|
return NOT_RUN
|
||||||
|
|
||||||
|
|
||||||
|
def render_table(results, *, header="", areas=COVERED, gaps=DECLARED_GAPS,
|
||||||
|
width=TABLE_WIDTH) -> str:
|
||||||
|
"""Render the per-area table.
|
||||||
|
|
||||||
|
``results`` maps an area key to a mapping with ``result`` and,
|
||||||
|
optionally, ``checks`` and ``detail``. Unknown keys are ignored and
|
||||||
|
missing keys render as ``NOT RUN`` - the registry, not the run,
|
||||||
|
decides which rows exist.
|
||||||
|
"""
|
||||||
|
rows = []
|
||||||
|
for area in areas:
|
||||||
|
entry = results.get(area.key) or {}
|
||||||
|
result = entry.get("result") or NOT_RUN
|
||||||
|
checks = entry.get("checks")
|
||||||
|
detail = entry.get("detail") or ""
|
||||||
|
if result == NOT_RUN and not detail:
|
||||||
|
detail = "no test ran for this area"
|
||||||
|
rows.append((area.label, result,
|
||||||
|
"" if checks is None else str(checks), detail))
|
||||||
|
|
||||||
|
labels = [row[0] for row in rows] + [gap.label for gap in gaps] + ["AREA"]
|
||||||
|
label_width = max(len(label) for label in labels)
|
||||||
|
result_width = max([len(row[1]) for row in rows] + [len(NOT_COVERED), len("RESULT")])
|
||||||
|
checks_width = max([len(row[2]) for row in rows] + [len("CHECKS")])
|
||||||
|
# Indent + label + gap + result + gap + checks + gap, then the detail.
|
||||||
|
detail_indent = 2 + label_width + 2 + result_width + 2 + checks_width + 2
|
||||||
|
detail_width = max(width - detail_indent, MIN_DETAIL_WIDTH)
|
||||||
|
|
||||||
|
def row_lines(label, result, checks, detail):
|
||||||
|
first = (f" {label.ljust(label_width)} {result.ljust(result_width)} "
|
||||||
|
f"{checks.rjust(checks_width)} ")
|
||||||
|
wrapped = textwrap.wrap(detail, detail_width) or [""]
|
||||||
|
out = [(first + wrapped[0]).rstrip()]
|
||||||
|
out += [(" " * detail_indent + line).rstrip() for line in wrapped[1:]]
|
||||||
|
return out
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
if header:
|
||||||
|
lines.extend([header, ""])
|
||||||
|
lines.append(
|
||||||
|
f" {'AREA'.ljust(label_width)} {'RESULT'.ljust(result_width)} "
|
||||||
|
f"{'CHECKS'.rjust(checks_width)} DETAIL"
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
lines.extend(row_lines(*row))
|
||||||
|
|
||||||
|
if gaps:
|
||||||
|
lines.extend(["", " Not covered, deliberately:"])
|
||||||
|
for gap in gaps:
|
||||||
|
lines.extend(row_lines(gap.label, NOT_COVERED, "", gap.reason))
|
||||||
|
|
||||||
|
failed = [row for row in rows if row[1] == FAIL]
|
||||||
|
skipped = [row for row in rows if row[1] == SKIP]
|
||||||
|
not_run = [row for row in rows if row[1] == NOT_RUN]
|
||||||
|
passed = len(rows) - len(failed) - len(skipped) - len(not_run)
|
||||||
|
lines.extend(["", (
|
||||||
|
f" {passed} pass, {len(failed)} fail, {len(skipped)} skip, "
|
||||||
|
f"{len(not_run)} not run, {len(gaps)} declared gaps"
|
||||||
|
)])
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""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))
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""A deterministic OpenAI-compatible provider for the smoke suite.
|
||||||
|
|
||||||
|
Every scenario that needs a model talks to this instead of a real
|
||||||
|
endpoint. It binds an ephemeral port on loopback, so no scenario depends
|
||||||
|
on network egress, on a model being downloaded, or on two runs on the
|
||||||
|
same machine picking the same port.
|
||||||
|
|
||||||
|
It answers the two routes the app needs to treat it as a local
|
||||||
|
OpenAI-compatible server: ``GET /v1/models`` for discovery and probing,
|
||||||
|
and ``POST /v1/chat/completions`` for both the buffered and the streamed
|
||||||
|
turn. Each reply is a fixed marker plus the model id, so a test can tell
|
||||||
|
the two models apart in a blind comparison; every request is recorded so
|
||||||
|
a test can assert the user's message actually reached the provider
|
||||||
|
rather than only that some text came back.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
# Two model ids so the Compare area has something to reveal.
|
||||||
|
MODEL_PRIMARY = "odysseus-smoke-primary"
|
||||||
|
MODEL_SECONDARY = "odysseus-smoke-secondary"
|
||||||
|
MODELS = (MODEL_PRIMARY, MODEL_SECONDARY)
|
||||||
|
|
||||||
|
# The marker each reply starts with. Distinctive enough that finding it
|
||||||
|
# in a response body cannot be a coincidence, and short enough to read
|
||||||
|
# in a failure message.
|
||||||
|
REPLY_MARKER = "ODYSSEUS-SMOKE-REPLY"
|
||||||
|
|
||||||
|
# Bind on loopback, kernel-assigned port. No literal port anywhere.
|
||||||
|
BIND_HOST = "127.0.0.1"
|
||||||
|
BIND_PORT = 0
|
||||||
|
|
||||||
|
|
||||||
|
def reply_for(model: str) -> str:
|
||||||
|
"""The exact assistant text this provider returns for ``model``."""
|
||||||
|
return f"{REPLY_MARKER} {model}"
|
||||||
|
|
||||||
|
|
||||||
|
class _Recorder:
|
||||||
|
"""Requests the provider has served, for assertions after the fact."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._calls = []
|
||||||
|
|
||||||
|
def record(self, payload: dict) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._calls.append(payload)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def calls(self) -> list[dict]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._calls)
|
||||||
|
|
||||||
|
def prompts(self) -> list[str]:
|
||||||
|
"""Every user message this provider has been sent."""
|
||||||
|
out = []
|
||||||
|
for call in self.calls:
|
||||||
|
for message in call.get("messages") or []:
|
||||||
|
if message.get("role") == "user":
|
||||||
|
out.append(str(message.get("content") or ""))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._calls.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _handler_for(recorder: _Recorder):
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def log_message(self, *args): # noqa: D102 - silence stderr access log
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _send_json(self, status: int, body: dict) -> None:
|
||||||
|
raw = json.dumps(body).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(raw)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(raw)
|
||||||
|
|
||||||
|
def do_GET(self): # noqa: N802 - BaseHTTPRequestHandler's contract
|
||||||
|
if self.path.rstrip("/").endswith("/models"):
|
||||||
|
self._send_json(200, {
|
||||||
|
"object": "list",
|
||||||
|
"data": [{"id": name, "object": "model", "owned_by": "smoke"}
|
||||||
|
for name in MODELS],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
self._send_json(404, {"error": {"message": f"no route {self.path}"}})
|
||||||
|
|
||||||
|
def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler's contract
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
try:
|
||||||
|
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
except ValueError:
|
||||||
|
payload = {}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
recorder.record(payload)
|
||||||
|
|
||||||
|
model = str(payload.get("model") or MODEL_PRIMARY)
|
||||||
|
text = reply_for(model)
|
||||||
|
if payload.get("stream"):
|
||||||
|
self._send_stream(model, text)
|
||||||
|
return
|
||||||
|
self._send_json(200, {
|
||||||
|
"id": "smoke-completion",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"model": model,
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": text},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}],
|
||||||
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||||
|
})
|
||||||
|
|
||||||
|
def _send_stream(self, model: str, text: str) -> None:
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
for chunk in (
|
||||||
|
{"choices": [{"index": 0, "delta": {"content": text}}], "model": model},
|
||||||
|
{"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "model": model},
|
||||||
|
):
|
||||||
|
self.wfile.write(b"data: " + json.dumps(chunk).encode("utf-8") + b"\n\n")
|
||||||
|
self.wfile.write(b"data: [DONE]\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
|
||||||
|
return Handler
|
||||||
|
|
||||||
|
|
||||||
|
class StubProvider:
|
||||||
|
"""A running stub provider. Use as a context manager."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.recorder = _Recorder()
|
||||||
|
self._server = ThreadingHTTPServer((BIND_HOST, BIND_PORT), _handler_for(self.recorder))
|
||||||
|
self._server.daemon_threads = True
|
||||||
|
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def port(self) -> int:
|
||||||
|
return self._server.server_address[1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self) -> str:
|
||||||
|
"""The OpenAI-compatible base the app should be pointed at."""
|
||||||
|
return f"http://{BIND_HOST}:{self.port}/v1"
|
||||||
|
|
||||||
|
def start(self) -> "StubProvider":
|
||||||
|
self._thread.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._server.shutdown()
|
||||||
|
self._server.server_close()
|
||||||
|
self._thread.join(timeout=5)
|
||||||
|
|
||||||
|
def __enter__(self) -> "StubProvider":
|
||||||
|
return self.start()
|
||||||
|
|
||||||
|
def __exit__(self, *exc) -> None:
|
||||||
|
self.stop()
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Calendar: an event created through the API shows up in the range the UI asks for."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
CALENDARS_PATH = "/api/calendar/calendars"
|
||||||
|
EVENTS_PATH = "/api/calendar/events"
|
||||||
|
|
||||||
|
SUMMARY = "Odysseus smoke event"
|
||||||
|
# Far enough out that a real local calendar's own entries cannot collide
|
||||||
|
# with the assertion, and fixed relative to now so the window is never
|
||||||
|
# empty for date reasons.
|
||||||
|
DAYS_AHEAD = 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_event_round_trips(client):
|
||||||
|
listed_calendars = client.get(CALENDARS_PATH)
|
||||||
|
assert listed_calendars.status_code == 200, listed_calendars.text
|
||||||
|
assert listed_calendars.json().get("calendars"), "no calendar to write an event into"
|
||||||
|
|
||||||
|
start = (datetime.now() + timedelta(days=DAYS_AHEAD)).replace(
|
||||||
|
hour=10, minute=0, second=0, microsecond=0)
|
||||||
|
created = client.post(EVENTS_PATH, json={
|
||||||
|
"summary": SUMMARY,
|
||||||
|
"dtstart": start.isoformat(),
|
||||||
|
})
|
||||||
|
assert created.status_code == 200, created.text
|
||||||
|
uid = created.json()["uid"]
|
||||||
|
try:
|
||||||
|
window = client.get(EVENTS_PATH, params={
|
||||||
|
"start": (start - timedelta(days=1)).isoformat(),
|
||||||
|
"end": (start + timedelta(days=1)).isoformat(),
|
||||||
|
})
|
||||||
|
assert window.status_code == 200, window.text
|
||||||
|
events = window.json().get("events") or []
|
||||||
|
matching = [e for e in events if e.get("uid") == uid]
|
||||||
|
assert matching, [e.get("summary") for e in events]
|
||||||
|
assert matching[0].get("summary") == SUMMARY, matching[0]
|
||||||
|
|
||||||
|
read = client.get(f"{EVENTS_PATH}/{uid}")
|
||||||
|
assert read.status_code == 200, read.text
|
||||||
|
finally:
|
||||||
|
removed = client.delete(f"{EVENTS_PATH}/{uid}")
|
||||||
|
assert removed.status_code == 200, removed.text
|
||||||
|
|
||||||
|
after = client.get(EVENTS_PATH, params={
|
||||||
|
"start": (start - timedelta(days=1)).isoformat(),
|
||||||
|
"end": (start + timedelta(days=1)).isoformat(),
|
||||||
|
})
|
||||||
|
assert uid not in [e.get("uid") for e in after.json().get("events") or []]
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Chat: a turn against the stub provider comes back rendered and saved.
|
||||||
|
|
||||||
|
The buffered and the streamed path are both checked because the UI uses
|
||||||
|
the streamed one and the agent's own loop uses the buffered one, and a
|
||||||
|
decomposition can break either alone.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from tests.smoke.stub_provider import MODEL_PRIMARY, reply_for
|
||||||
|
|
||||||
|
CHAT_PATH = "/api/chat"
|
||||||
|
CHAT_STREAM_PATH = "/api/chat_stream"
|
||||||
|
HISTORY_PATH = "/api/history"
|
||||||
|
|
||||||
|
PROMPT = "Smoke check: reply with anything."
|
||||||
|
|
||||||
|
|
||||||
|
def test_buffered_turn_returns_the_provider_reply(client, chat_session, stub_provider):
|
||||||
|
response = client.post(CHAT_PATH, json={"message": PROMPT, "session": chat_session})
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body.get("response") == reply_for(MODEL_PRIMARY), body
|
||||||
|
assert body.get("model") == MODEL_PRIMARY, body
|
||||||
|
# The app prefaces the turn with its own date/time context block, so
|
||||||
|
# the prompt is contained in what the provider saw rather than equal
|
||||||
|
# to it.
|
||||||
|
assert any(PROMPT in seen for seen in stub_provider.recorder.prompts()), (
|
||||||
|
"the prompt never reached the provider, so the reply came from "
|
||||||
|
"somewhere other than the model path"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_streamed_turn_emits_the_reply_and_saves_the_message(client, chat_session):
|
||||||
|
deltas, saved = [], []
|
||||||
|
with client.stream("POST", CHAT_STREAM_PATH,
|
||||||
|
json={"message": PROMPT, "session": chat_session}) as response:
|
||||||
|
assert response.status_code == 200
|
||||||
|
for line in response.iter_lines():
|
||||||
|
if not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
payload = line[len("data: "):].strip()
|
||||||
|
if payload == "[DONE]":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
event = json.loads(payload)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if "delta" in event:
|
||||||
|
deltas.append(str(event["delta"]))
|
||||||
|
if event.get("type") == "message_saved":
|
||||||
|
saved.append(event.get("id"))
|
||||||
|
|
||||||
|
assert "".join(deltas) == reply_for(MODEL_PRIMARY), deltas
|
||||||
|
assert saved and saved[0], "the stream never reported the assistant turn as saved"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_turn_is_in_the_session_history(client, chat_session):
|
||||||
|
client.post(CHAT_PATH, json={"message": PROMPT, "session": chat_session})
|
||||||
|
response = client.get(f"{HISTORY_PATH}/{chat_session}")
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
messages = response.json().get("history") or []
|
||||||
|
rendered = [str(m.get("content") or "") for m in messages]
|
||||||
|
assert any(PROMPT in text for text in rendered), rendered
|
||||||
|
assert any(reply_for(MODEL_PRIMARY) in text for text in rendered), rendered
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Compare: a blind comparison streams both sides and reveals them on the vote.
|
||||||
|
|
||||||
|
Two model ids on the one stub provider is what makes this checkable
|
||||||
|
without a second endpoint: each returns a reply naming itself, so the
|
||||||
|
reveal can be matched against which text arrived on which side.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from tests.smoke.stub_provider import MODEL_PRIMARY, MODEL_SECONDARY, reply_for
|
||||||
|
|
||||||
|
COMPARE_PATH = "/api/compare"
|
||||||
|
CHAT_STREAM_PATH = "/api/chat_stream"
|
||||||
|
|
||||||
|
PROMPT = "Smoke check: compare two replies."
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_text(client, session_id: str) -> str:
|
||||||
|
deltas = []
|
||||||
|
with client.stream("POST", CHAT_STREAM_PATH,
|
||||||
|
json={"message": PROMPT, "session": session_id}) as response:
|
||||||
|
assert response.status_code == 200
|
||||||
|
for line in response.iter_lines():
|
||||||
|
if not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
payload = line[len("data: "):].strip()
|
||||||
|
if payload == "[DONE]":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
event = json.loads(payload)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if "delta" in event:
|
||||||
|
deltas.append(str(event["delta"]))
|
||||||
|
return "".join(deltas)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_blind_comparison_streams_and_reveals(client, stub_endpoint):
|
||||||
|
started = client.post(f"{COMPARE_PATH}/start", data={
|
||||||
|
"prompt": PROMPT,
|
||||||
|
"model_a": MODEL_PRIMARY,
|
||||||
|
"model_b": MODEL_SECONDARY,
|
||||||
|
"endpoint_a_id": stub_endpoint,
|
||||||
|
"endpoint_b_id": stub_endpoint,
|
||||||
|
"is_blind": "true",
|
||||||
|
})
|
||||||
|
assert started.status_code == 200, started.text
|
||||||
|
comparison = started.json()
|
||||||
|
comparison_id = comparison["id"]
|
||||||
|
|
||||||
|
# Blind: the start response must not say which model is on which side.
|
||||||
|
assert not comparison.get("model_left"), comparison
|
||||||
|
assert not comparison.get("model_right"), comparison
|
||||||
|
|
||||||
|
left = _stream_text(client, comparison["session_left"])
|
||||||
|
right = _stream_text(client, comparison["session_right"])
|
||||||
|
assert {left, right} == {reply_for(MODEL_PRIMARY), reply_for(MODEL_SECONDARY)}, (left, right)
|
||||||
|
|
||||||
|
voted = client.post(f"{COMPARE_PATH}/{comparison_id}/vote", data={"winner": "left"})
|
||||||
|
assert voted.status_code == 200, voted.text
|
||||||
|
revealed = voted.json().get("revealed") or {}
|
||||||
|
assert revealed.get("left") in (MODEL_PRIMARY, MODEL_SECONDARY), voted.text
|
||||||
|
assert reply_for(revealed["left"]) == left, (revealed, left)
|
||||||
|
assert reply_for(revealed["right"]) == right, (revealed, right)
|
||||||
|
|
||||||
|
history = client.get(f"{COMPARE_PATH}/history")
|
||||||
|
assert history.status_code == 200, history.text
|
||||||
|
entries = [row for row in history.json() if row.get("id") == comparison_id]
|
||||||
|
assert entries, history.text
|
||||||
|
assert entries[0].get("winner"), entries[0]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Cookbook: hardware is detected and the recommendations are sized against it.
|
||||||
|
|
||||||
|
What the README advertises here is hardware-aware recommendation, and
|
||||||
|
that is exactly the part that runs offline. Downloading and serving a
|
||||||
|
model is left to the gap list: it needs tmux, a GPU runtime and several
|
||||||
|
gigabytes over the network.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
SYSTEM_PATH = "/api/hwfit/system"
|
||||||
|
MODELS_PATH = "/api/hwfit/models"
|
||||||
|
STATE_PATH = "/api/cookbook/state"
|
||||||
|
GPUS_PATH = "/api/cookbook/gpus"
|
||||||
|
|
||||||
|
STATE_MARKER = "odysseusSmokeMarker"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hardware_is_detected(client):
|
||||||
|
response = client.get(SYSTEM_PATH)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
system = response.json()
|
||||||
|
assert (system.get("total_ram_gb") or 0) > 0, system
|
||||||
|
assert (system.get("cpu_cores") or 0) > 0, system
|
||||||
|
assert system.get("cpu_name"), system
|
||||||
|
|
||||||
|
gpus = client.get(GPUS_PATH)
|
||||||
|
assert gpus.status_code == 200, gpus.text
|
||||||
|
assert gpus.json().get("ok") is True, gpus.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_recommendations_fit_the_detected_hardware(client):
|
||||||
|
response = client.get(MODELS_PATH)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
system = body.get("system") or {}
|
||||||
|
assert system.get("cpu_name"), body
|
||||||
|
recommended = body.get("models") or body.get("recommendations") or []
|
||||||
|
assert recommended, f"no model recommendation for this hardware: {list(body)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cookbook_state_persists(client):
|
||||||
|
written = client.post(STATE_PATH, json={STATE_MARKER: "ody-95"})
|
||||||
|
assert written.status_code == 200, written.text
|
||||||
|
assert written.json().get("ok") is True, written.text
|
||||||
|
|
||||||
|
read = client.get(STATE_PATH)
|
||||||
|
assert read.status_code == 200, read.text
|
||||||
|
assert read.json().get(STATE_MARKER) == "ody-95", read.text
|
||||||
|
|
||||||
|
client.post(STATE_PATH, json={})
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Documents (RAG): an uploaded file is chunked, indexed and then listed.
|
||||||
|
|
||||||
|
This is the one area whose dependency is not satisfiable from a clean
|
||||||
|
checkout. `requirements.txt` pins `chromadb-client`, the HTTP client;
|
||||||
|
the ChromaDB *server* is a separate install, and without one reachable
|
||||||
|
the app returns a deliberate 503 from the upload route rather than
|
||||||
|
indexing into nothing. So the scenario skips with that reason printed in
|
||||||
|
the table instead of being quietly dropped - a row saying SKIP and why
|
||||||
|
is the honest report, and it goes green as soon as a vector service is
|
||||||
|
there.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
PERSONAL_PATH = "/api/personal"
|
||||||
|
UPLOAD_PATH = "/api/personal/upload"
|
||||||
|
|
||||||
|
# The route uniquifies the stored name and lists it under the owner's
|
||||||
|
# upload dir, so assertions match on the stem rather than the filename.
|
||||||
|
STEM = "odysseus-smoke-corpus"
|
||||||
|
FILENAME = f"{STEM}.txt"
|
||||||
|
CONTENT = (
|
||||||
|
"The release smoke suite indexed this file. "
|
||||||
|
"It exists so the retrieval path has something deterministic to chunk."
|
||||||
|
)
|
||||||
|
# The route's own 503 text when no vector store answers.
|
||||||
|
UNAVAILABLE_MARKER = "RAG system is not available"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_uploaded_file_is_indexed_and_listed(client):
|
||||||
|
response = client.post(UPLOAD_PATH,
|
||||||
|
files={"files": (FILENAME, CONTENT.encode("utf-8"), "text/plain")})
|
||||||
|
if response.status_code == 503 and UNAVAILABLE_MARKER in response.text:
|
||||||
|
pytest.skip(
|
||||||
|
"no vector service reachable, so indexing is unavailable. "
|
||||||
|
"requirements.txt pins chromadb-client, not the server; install "
|
||||||
|
"chromadb in the venv and re-run to cover this area."
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
try:
|
||||||
|
assert body.get("indexed_count", 0) > 0, f"nothing was indexed: {body}"
|
||||||
|
assert body.get("failed_count", 1) == 0, f"a chunk failed to index: {body}"
|
||||||
|
assert FILENAME in (body.get("uploaded") or []), body
|
||||||
|
|
||||||
|
listed = client.get(PERSONAL_PATH)
|
||||||
|
assert listed.status_code == 200, listed.text
|
||||||
|
names = [str(f.get("name")) for f in listed.json().get("files") or []]
|
||||||
|
assert any(STEM in name for name in names), names
|
||||||
|
finally:
|
||||||
|
listed = client.get(PERSONAL_PATH).json().get("files") or []
|
||||||
|
for entry in listed:
|
||||||
|
if STEM in str(entry.get("name")):
|
||||||
|
client.request("DELETE", "/api/personal/file",
|
||||||
|
params={"filepath": entry.get("path")})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Documents: the editor's create, edit and version history survive a round trip."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
DOCUMENT_PATH = "/api/document"
|
||||||
|
LIBRARY_PATH = "/api/documents/library"
|
||||||
|
|
||||||
|
TITLE = "Odysseus smoke document"
|
||||||
|
FIRST = "First revision, written by the release smoke suite."
|
||||||
|
SECOND = "Second revision, written by the release smoke suite."
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_document_round_trips_with_its_versions(client):
|
||||||
|
created = client.post(DOCUMENT_PATH, json={"title": TITLE, "content": FIRST})
|
||||||
|
assert created.status_code == 200, created.text
|
||||||
|
body = created.json()
|
||||||
|
doc_id = body["id"]
|
||||||
|
try:
|
||||||
|
assert body.get("current_content") == FIRST, body
|
||||||
|
assert body.get("version_count") == 1, body
|
||||||
|
|
||||||
|
library = client.get(LIBRARY_PATH)
|
||||||
|
assert library.status_code == 200, library.text
|
||||||
|
assert doc_id in [d.get("id") for d in library.json().get("documents") or []]
|
||||||
|
|
||||||
|
# `force_version` because a save inside the route's coalesce
|
||||||
|
# window updates the current version in place instead of adding
|
||||||
|
# one - which is right for autosave and would make a smoke check
|
||||||
|
# that edits immediately depend on the clock.
|
||||||
|
edited = client.put(f"{DOCUMENT_PATH}/{doc_id}",
|
||||||
|
json={"content": SECOND, "force_version": True})
|
||||||
|
assert edited.status_code == 200, edited.text
|
||||||
|
assert edited.json().get("current_content") == SECOND, edited.text
|
||||||
|
assert edited.json().get("version_count") == 2, edited.text
|
||||||
|
|
||||||
|
versions = client.get(f"{DOCUMENT_PATH}/{doc_id}/versions")
|
||||||
|
assert versions.status_code == 200, versions.text
|
||||||
|
contents = {v.get("version_number"): v.get("content") for v in versions.json()}
|
||||||
|
assert contents.get(1) == FIRST, contents
|
||||||
|
assert contents.get(2) == SECOND, contents
|
||||||
|
|
||||||
|
restored = client.post(f"{DOCUMENT_PATH}/{doc_id}/restore/1")
|
||||||
|
assert restored.status_code == 200, restored.text
|
||||||
|
assert client.get(f"{DOCUMENT_PATH}/{doc_id}").json()["current_content"] == FIRST
|
||||||
|
finally:
|
||||||
|
removed = client.delete(f"{DOCUMENT_PATH}/{doc_id}")
|
||||||
|
assert removed.status_code == 200, removed.text
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Email: the inbox lists a seeded message, opens it, and marks it read.
|
||||||
|
|
||||||
|
Email is the one area with no way to reach a real account deterministically,
|
||||||
|
and the repo already solved that: `routes/email_routes.py` carries a
|
||||||
|
fixture path gated on `ODYSSEUS_EMAIL_FIXTURE=1` plus a fixture file in
|
||||||
|
the data dir. This uses that mechanism rather than inventing a second
|
||||||
|
one - which means it also only covers what the fixture covers. Real IMAP
|
||||||
|
sync and SMTP send stay out, and say so in the table's gap list.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.constants import DATA_DIR
|
||||||
|
|
||||||
|
LIST_PATH = "/api/email/list"
|
||||||
|
READ_PATH = "/api/email/read"
|
||||||
|
MARK_READ_PATH = "/api/email/mark-read"
|
||||||
|
UNREAD_STATE_PATH = "/api/email/unread-state"
|
||||||
|
|
||||||
|
# The filename the fixture path reads. Same value as
|
||||||
|
# routes/email_routes.py's `_fixture_email_file`.
|
||||||
|
FIXTURE_FILENAME = "fixture_email_messages.json"
|
||||||
|
|
||||||
|
SUBJECT = "Odysseus smoke inbox message"
|
||||||
|
BODY = "Body of the smoke fixture message."
|
||||||
|
SENDER = "Smoke Sender <smoke@example.invalid>"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seeded_inbox(client, account):
|
||||||
|
"""Write the fixture inbox, and put back whatever was there before.
|
||||||
|
|
||||||
|
The flag itself has to be in the app's environment, which is the
|
||||||
|
launcher's job; if it is missing the fixture path stays off and the
|
||||||
|
list route falls through to a real account that does not exist. That
|
||||||
|
reads as a skip, not a failure.
|
||||||
|
"""
|
||||||
|
path = Path(DATA_DIR) / FIXTURE_FILENAME
|
||||||
|
previous = path.read_bytes() if path.exists() else None
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps({"messages": [{
|
||||||
|
"owner": account["username"],
|
||||||
|
"from": SENDER,
|
||||||
|
"subject": SUBJECT,
|
||||||
|
"date": "2026-09-29T12:00:00+00:00",
|
||||||
|
"body": BODY,
|
||||||
|
}]}, indent=2) + "\n", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
yield path
|
||||||
|
finally:
|
||||||
|
if previous is None:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
path.write_bytes(previous)
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture_rows(client):
|
||||||
|
response = client.get(LIST_PATH, params={"folder": "INBOX", "limit": 10})
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
rows = [e for e in body.get("emails") or [] if e.get("subject") == SUBJECT]
|
||||||
|
if not rows:
|
||||||
|
pytest.skip(
|
||||||
|
"the instance is not serving the email fixture, so there is no "
|
||||||
|
"deterministic inbox to read. Boot it with ODYSSEUS_EMAIL_FIXTURE=1 "
|
||||||
|
"(scripts/odysseus-smoke does)."
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_inbox_lists_opens_and_marks_a_message(client, seeded_inbox):
|
||||||
|
row = _fixture_rows(client)[0]
|
||||||
|
uid = row["uid"]
|
||||||
|
assert row.get("from_address") == "smoke@example.invalid", row
|
||||||
|
assert row.get("is_read") is False, row
|
||||||
|
|
||||||
|
read = client.get(f"{READ_PATH}/{uid}", params={"folder": "INBOX"})
|
||||||
|
assert read.status_code == 200, read.text
|
||||||
|
opened = read.json()
|
||||||
|
assert opened.get("subject") == SUBJECT, opened
|
||||||
|
assert BODY in str(opened.get("body") or ""), opened
|
||||||
|
assert BODY in str(opened.get("body_html") or ""), opened
|
||||||
|
|
||||||
|
before = client.get(UNREAD_STATE_PATH, params={"folder": "INBOX"})
|
||||||
|
assert before.status_code == 200, before.text
|
||||||
|
assert before.json().get("unread_count") == 1, before.text
|
||||||
|
|
||||||
|
marked = client.post(f"{MARK_READ_PATH}/{uid}", params={"folder": "INBOX"})
|
||||||
|
assert marked.status_code == 200, marked.text
|
||||||
|
|
||||||
|
after = client.get(UNREAD_STATE_PATH, params={"folder": "INBOX"})
|
||||||
|
assert after.json().get("unread_count") == 0, after.text
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Memory: a stored fact is listed, found by search, and gone after delete.
|
||||||
|
|
||||||
|
Keyword mode is enough here on purpose. The memory store degrades to
|
||||||
|
keyword matching when no vector service answers, and that degraded path
|
||||||
|
is the one a clean checkout actually runs, so it is the one worth
|
||||||
|
smoking.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
MEMORY_PATH = "/api/memory"
|
||||||
|
ADD_PATH = "/api/memory/add"
|
||||||
|
SEARCH_PATH = "/api/memory/search"
|
||||||
|
|
||||||
|
# A token that cannot collide with a real memory on a scratch instance.
|
||||||
|
TOKEN = "odysseus-smoke-marker-quintile"
|
||||||
|
TEXT = f"The release smoke suite stored the token {TOKEN} as a fact."
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_memory_round_trips(client):
|
||||||
|
created = client.post(ADD_PATH, json={"text": TEXT, "category": "fact"})
|
||||||
|
assert created.status_code == 200, created.text
|
||||||
|
assert created.json().get("ok") is True, created.text
|
||||||
|
|
||||||
|
listed = client.get(MEMORY_PATH)
|
||||||
|
assert listed.status_code == 200, listed.text
|
||||||
|
matching = [m for m in listed.json().get("memory") or [] if TOKEN in str(m.get("text"))]
|
||||||
|
assert matching, [m.get("text") for m in listed.json().get("memory") or []]
|
||||||
|
memory_id = matching[0]["id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
found = client.post(SEARCH_PATH, data={"query": TOKEN})
|
||||||
|
assert found.status_code == 200, found.text
|
||||||
|
hits = [m for m in found.json().get("memories") or [] if TOKEN in str(m.get("text"))]
|
||||||
|
assert hits, found.text
|
||||||
|
finally:
|
||||||
|
removed = client.delete(f"{MEMORY_PATH}/{memory_id}")
|
||||||
|
assert removed.status_code == 200, removed.text
|
||||||
|
|
||||||
|
remaining = client.get(MEMORY_PATH).json().get("memory") or []
|
||||||
|
assert memory_id not in [m.get("id") for m in remaining]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Notes: a note created through the API is readable, editable and gone after delete."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
NOTES_PATH = "/api/notes"
|
||||||
|
|
||||||
|
TITLE = "Odysseus smoke note"
|
||||||
|
BODY = "Created by the release smoke suite."
|
||||||
|
EDITED_BODY = "Edited by the release smoke suite."
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_note_round_trips(client):
|
||||||
|
created = client.post(NOTES_PATH, json={"title": TITLE, "content": BODY})
|
||||||
|
assert created.status_code == 200, created.text
|
||||||
|
note_id = created.json()["id"]
|
||||||
|
try:
|
||||||
|
listed = client.get(NOTES_PATH)
|
||||||
|
assert listed.status_code == 200, listed.text
|
||||||
|
titles = [n.get("title") for n in listed.json().get("notes") or []]
|
||||||
|
assert TITLE in titles, titles
|
||||||
|
|
||||||
|
read = client.get(f"{NOTES_PATH}/{note_id}")
|
||||||
|
assert read.status_code == 200, read.text
|
||||||
|
assert read.json().get("content") == BODY, read.text
|
||||||
|
|
||||||
|
edited = client.put(f"{NOTES_PATH}/{note_id}",
|
||||||
|
json={"title": TITLE, "content": EDITED_BODY})
|
||||||
|
assert edited.status_code == 200, edited.text
|
||||||
|
assert client.get(f"{NOTES_PATH}/{note_id}").json()["content"] == EDITED_BODY
|
||||||
|
finally:
|
||||||
|
removed = client.delete(f"{NOTES_PATH}/{note_id}")
|
||||||
|
assert removed.status_code == 200, removed.text
|
||||||
|
assert client.get(f"{NOTES_PATH}/{note_id}").status_code == 404
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Settings: a preference written through the API survives a new login.
|
||||||
|
|
||||||
|
Reading the value back on the same cookie only proves the handler
|
||||||
|
answered. Reading it back after authenticating again is what proves it
|
||||||
|
was persisted rather than held in the session, which is the closest an
|
||||||
|
API-level check gets to the user reloading the page.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
PREFS_PATH = "/api/prefs"
|
||||||
|
|
||||||
|
KEY = "odysseus_smoke_preference"
|
||||||
|
VALUE = "set-by-the-release-smoke-suite"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_preference_survives_a_new_login(client, fresh_client):
|
||||||
|
written = client.put(f"{PREFS_PATH}/{KEY}", json={"value": VALUE})
|
||||||
|
assert written.status_code == 200, written.text
|
||||||
|
assert written.json().get("value") == VALUE, written.text
|
||||||
|
|
||||||
|
read = client.get(f"{PREFS_PATH}/{KEY}")
|
||||||
|
assert read.status_code == 200, read.text
|
||||||
|
assert read.json().get("value") == VALUE, read.text
|
||||||
|
|
||||||
|
reloaded = fresh_client.get(f"{PREFS_PATH}/{KEY}")
|
||||||
|
assert reloaded.status_code == 200, reloaded.text
|
||||||
|
assert reloaded.json().get("value") == VALUE, reloaded.text
|
||||||
|
|
||||||
|
listed = fresh_client.get(PREFS_PATH)
|
||||||
|
assert listed.status_code == 200, listed.text
|
||||||
|
assert listed.json().get(KEY) == VALUE, listed.text
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Tasks: a scheduled task is created with a computed next run and is listed.
|
||||||
|
|
||||||
|
Deliberately not fired. Running a task is model and tool work the
|
||||||
|
checkpoint benchmark covers; what this asserts is that the scheduler
|
||||||
|
still accepts a task and computes when it should run, which is the part
|
||||||
|
a route move can break silently.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
TASKS_PATH = "/api/tasks"
|
||||||
|
|
||||||
|
NAME = "Odysseus smoke task"
|
||||||
|
SCHEDULED_TIME = "03:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_scheduled_task_round_trips(client):
|
||||||
|
created = client.post(TASKS_PATH, json={
|
||||||
|
"name": NAME,
|
||||||
|
"task_type": "llm",
|
||||||
|
"prompt": "Smoke task; never run by this suite.",
|
||||||
|
"trigger_type": "schedule",
|
||||||
|
"schedule": "daily",
|
||||||
|
"scheduled_time": SCHEDULED_TIME,
|
||||||
|
})
|
||||||
|
assert created.status_code == 200, created.text
|
||||||
|
body = created.json()
|
||||||
|
task_id = body["id"]
|
||||||
|
try:
|
||||||
|
assert body.get("next_run"), f"no next run computed for a daily task: {body}"
|
||||||
|
assert body.get("status") == "active", body
|
||||||
|
|
||||||
|
listed = client.get(TASKS_PATH)
|
||||||
|
assert listed.status_code == 200, listed.text
|
||||||
|
assert task_id in [t.get("id") for t in listed.json().get("tasks") or []]
|
||||||
|
|
||||||
|
paused = client.post(f"{TASKS_PATH}/{task_id}/pause")
|
||||||
|
assert paused.status_code == 200, paused.text
|
||||||
|
assert client.get(f"{TASKS_PATH}/{task_id}").json().get("status") == "paused"
|
||||||
|
finally:
|
||||||
|
removed = client.delete(f"{TASKS_PATH}/{task_id}")
|
||||||
|
assert removed.status_code == 200, removed.text
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Uploads: a file uploaded through the chat attachment route reads back byte for byte."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
UPLOAD_PATH = "/api/upload"
|
||||||
|
STATS_PATH = "/api/upload/stats"
|
||||||
|
|
||||||
|
FILENAME = "odysseus-smoke-attachment.txt"
|
||||||
|
CONTENT = b"Uploaded by the release smoke suite."
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_upload_reads_back_unchanged(client):
|
||||||
|
response = client.post(UPLOAD_PATH,
|
||||||
|
files={"files": (FILENAME, CONTENT, "text/plain")})
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
files = response.json().get("files") or []
|
||||||
|
assert len(files) == 1, response.text
|
||||||
|
entry = files[0]
|
||||||
|
assert entry.get("name") == FILENAME, entry
|
||||||
|
assert entry.get("size") == len(CONTENT), entry
|
||||||
|
|
||||||
|
fetched = client.get(f"{UPLOAD_PATH}/{entry['id']}")
|
||||||
|
assert fetched.status_code == 200, fetched.text
|
||||||
|
assert fetched.content == CONTENT, fetched.content
|
||||||
|
|
||||||
|
stats = client.get(STATS_PATH)
|
||||||
|
assert stats.status_code == 200, stats.text
|
||||||
|
assert stats.json().get("total_files", 0) >= 1, stats.text
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""The release smoke suite's report cannot overstate what it checked.
|
||||||
|
|
||||||
|
The suite's value is entirely in whether its table is honest, and the
|
||||||
|
table is built from a registry rather than from what happened to run.
|
||||||
|
These pin the properties that make it honest: every advertised area has
|
||||||
|
a row whether or not its module ran, every module that exists is
|
||||||
|
registered, a failure outranks a pass, and the whole thing stays ASCII.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.smoke import areas
|
||||||
|
|
||||||
|
SMOKE_DIR = Path(__file__).parent / "smoke"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_registered_area_has_its_module_on_disk():
|
||||||
|
missing = [area.module for area in areas.COVERED
|
||||||
|
if not (SMOKE_DIR / area.module).exists()]
|
||||||
|
assert not missing, f"registered areas with no test module: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_smoke_module_is_registered():
|
||||||
|
"""A module nobody registered would run and never appear in the table."""
|
||||||
|
on_disk = {path.name for path in SMOKE_DIR.glob("test_*_smoke.py")}
|
||||||
|
registered = {area.module for area in areas.COVERED}
|
||||||
|
assert on_disk == registered, (
|
||||||
|
f"unregistered modules: {sorted(on_disk - registered)}; "
|
||||||
|
f"registered but absent: {sorted(registered - on_disk)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_area_keys_are_unique():
|
||||||
|
keys = [area.key for area in areas.COVERED]
|
||||||
|
assert len(keys) == len(set(keys)), keys
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_run_that_reported_nothing_shows_every_area_as_not_run():
|
||||||
|
table = areas.render_table({})
|
||||||
|
for area in areas.COVERED:
|
||||||
|
assert area.label in table, area.label
|
||||||
|
assert table.count(areas.NOT_RUN) == len(areas.COVERED)
|
||||||
|
assert areas.PASS not in table
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unreported_area_is_not_dropped_from_the_table():
|
||||||
|
"""The registry decides the rows, so a partial run still lists the rest."""
|
||||||
|
table = areas.render_table({areas.COVERED[0].key: {"result": areas.PASS, "checks": 1}})
|
||||||
|
assert table.count(areas.NOT_RUN) == len(areas.COVERED) - 1
|
||||||
|
assert areas.COVERED[-1].label in table
|
||||||
|
|
||||||
|
|
||||||
|
def test_declared_gaps_are_printed_with_their_reason():
|
||||||
|
assert areas.DECLARED_GAPS, "a suite with no declared gaps is claiming total coverage"
|
||||||
|
table = areas.render_table({})
|
||||||
|
for gap in areas.DECLARED_GAPS:
|
||||||
|
assert gap.label in table, gap.label
|
||||||
|
# The reason is wrapped across lines, so match its first words.
|
||||||
|
assert " ".join(gap.reason.split()[:3]) in " ".join(table.split()), gap.reason
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("outcomes,expected", [
|
||||||
|
([], areas.NOT_RUN),
|
||||||
|
([areas.PASS], areas.PASS),
|
||||||
|
([areas.PASS, areas.SKIP], areas.SKIP),
|
||||||
|
([areas.PASS, areas.SKIP, areas.FAIL], areas.FAIL),
|
||||||
|
([areas.PASS, areas.FAIL], areas.FAIL),
|
||||||
|
])
|
||||||
|
def test_the_worst_outcome_decides_the_row(outcomes, expected):
|
||||||
|
assert areas.resolve(outcomes) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_table_is_ascii_only():
|
||||||
|
"""No emoji or status glyphs: the repo bans them in UI and in code."""
|
||||||
|
table = areas.render_table({area.key: {"result": areas.PASS, "checks": 1}
|
||||||
|
for area in areas.COVERED})
|
||||||
|
assert table.isascii(), [ch for ch in table if not ch.isascii()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_names_map_back_to_their_area():
|
||||||
|
for area in areas.COVERED:
|
||||||
|
assert areas.area_for_module(area.module) == area.key
|
||||||
|
assert areas.area_for_module("test_not_a_smoke_module.py") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_summary_line_counts_every_row():
|
||||||
|
results = {area.key: {"result": areas.PASS, "checks": 1} for area in areas.COVERED}
|
||||||
|
results[areas.COVERED[0].key] = {"result": areas.FAIL, "checks": 0}
|
||||||
|
results[areas.COVERED[1].key] = {"result": areas.SKIP, "checks": 0}
|
||||||
|
summary = areas.render_table(results).splitlines()[-1]
|
||||||
|
assert f"{len(areas.COVERED) - 2} pass" in summary, summary
|
||||||
|
assert "1 fail" in summary, summary
|
||||||
|
assert "1 skip" in summary, summary
|
||||||
|
assert "0 not run" in summary, summary
|
||||||
|
assert f"{len(areas.DECLARED_GAPS)} declared gaps" in summary, summary
|
||||||
Reference in New Issue
Block a user