369857d3f1
Heid panel review (Gróa + Hulda, thread 01KSP5P6CSJH) on v0.15.0/
v0.15.1 surfaced one load-bearing bug + several precision items. This
pass closes them.
Load-bearing fix — cancel paths targeted the wrong turn_id:
- `_TURN_COUNTER` allocates browser-local ids (1, 2, 3…); the real
upstream Worldtree turn_id (e.g. 799) only arrives in the first SSE
event. The v0.15.x cancel/disconnect/shutdown paths posted to
/sessions/{sid}/turns/{LOCAL_ID}/cancel — wrong URL upstream.
- TurnHandle.upstream_response (dead field) → upstream_turn_id: int|None.
Captured from the first event's sse_id.turn_id in the stream
generator. All cancel paths now target it. Cancel before the upstream
stream starts (upstream_turn_id None) is a no-op
({"cancelled": false, "reason": "not_started"}).
- The old cancel tests mocked the local-id URL, so they encoded the bug;
rewritten to assert the UPSTREAM id is targeted.
Behavior change (minor-bump driver) — server-side end_user_id:
- create_app gains end_user_id kwarg; entrypoint reads
RATATOSKR_END_USER_ID and threads it in. POST /api/sessions uses
app.state.end_user_id, IGNORING any browser-supplied value (a client
can't impersonate an arbitrary end-user partition). JS no longer
sends end_user_id.
Precision fixes:
- Entrypoint missing-extras ImportError catch scoped to starlette/
uvicorn ONLY; baseline-dep / first-party import failures now
propagate as real tracebacks instead of masking as exit-12.
- Lifespan shutdown logs per-pending session_id + upstream_turn_id
(was a single aggregate count).
Tests (+18; 376 total):
- disconnect_triggers_upstream_cancel (INV-005 load-bearing — drives
the stream generator directly + cancels the consuming task; would
have caught the turn_id bug)
- cancel_targets_upstream_turn_id, cancel_before_started_is_noop,
cancel_failed_500
- server-side end_user_id: uses / ignores-body / omits-when-unset
- create_app: routes_registered / state_attached / factory_stored
- entrypoint: default_host / port_zero / happy_argv / open / no-open
- real_import_bug_propagates (precision guard)
- full_event_vocab at the stream-endpoint layer
Contract #16 amended: v0.16.0 amendment banner + INV-005/006 reworded
for upstream_turn_id + FN sketches corrected (server-side end_user_id,
upstream_response→upstream_turn_id, manual client lifecycle vs the
non-executable async-with sketch, not-started cancel branch).
155 lines
5.9 KiB
Python
155 lines
5.9 KiB
Python
"""Packaging + lazy-import discipline tests for ratatoskr.web per issue #16.
|
|
|
|
- `index.html` resolvable via importlib.resources (ships in wheel)
|
|
- `ratatoskr.web.entrypoint` importable without starlette+uvicorn,
|
|
prints install-hint and exits non-zero in that mode
|
|
"""
|
|
|
|
from importlib.resources import files
|
|
|
|
|
|
def test_index_html_in_package() -> None:
|
|
"""static/index.html is locatable via importlib.resources.
|
|
|
|
INV-009 packaging discipline. The asset must be part of the
|
|
installed package — `_static_dir()` in the server uses this exact
|
|
resolution path at startup.
|
|
"""
|
|
path = files("ratatoskr.web") / "static" / "index.html"
|
|
assert path.is_file(), f"index.html missing at {path}"
|
|
content = path.read_text()
|
|
assert "<html" in content
|
|
assert "ratatoskr-web" in content
|
|
|
|
|
|
def test_entrypoint_no_top_level_starlette_import() -> None:
|
|
"""INV-001: importing `ratatoskr.web.entrypoint` MUST NOT import
|
|
starlette or uvicorn at the module level. Verified by AST inspection
|
|
of the source — checks no top-level `import starlette` /
|
|
`from starlette` / `import uvicorn` / `from uvicorn` statements.
|
|
"""
|
|
import ast
|
|
from importlib.resources import files
|
|
|
|
src = (files("ratatoskr.web") / "entrypoint.py").read_text()
|
|
tree = ast.parse(src)
|
|
banned = {"starlette", "uvicorn"}
|
|
for node in tree.body:
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
top = alias.name.split(".")[0]
|
|
assert top not in banned, (
|
|
f"top-level `import {alias.name}` violates INV-001 "
|
|
"lazy-import discipline"
|
|
)
|
|
elif isinstance(node, ast.ImportFrom):
|
|
mod = (node.module or "").split(".")[0]
|
|
assert mod not in banned, (
|
|
f"top-level `from {node.module} import ...` violates "
|
|
"INV-001 lazy-import discipline"
|
|
)
|
|
|
|
|
|
def test_entrypoint_missing_api_key_returns_11(monkeypatch) -> None:
|
|
"""missing_api_key [error]: WORLDTREE_API_KEY unset → exit 11."""
|
|
monkeypatch.delenv("WORLDTREE_API_KEY", raising=False)
|
|
from ratatoskr.web.entrypoint import main
|
|
|
|
rc = main(["--port", "0"])
|
|
assert rc == 11
|
|
|
|
|
|
def test_entrypoint_missing_extras_returns_12(monkeypatch) -> None:
|
|
"""missing_extras [error]: starlette unimportable → exit 12.
|
|
|
|
v0.16.0: the missing-extras probe is scoped to the OPTIONAL extras
|
|
(starlette / uvicorn) only. Simulated by shadowing `starlette` to
|
|
None in sys.modules so its import raises ImportError inside the
|
|
narrow try-block.
|
|
"""
|
|
import sys
|
|
|
|
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
|
monkeypatch.setenv("WORLDTREE_API_URL", "https://example.com")
|
|
monkeypatch.setitem(sys.modules, "starlette", None)
|
|
|
|
from ratatoskr.web.entrypoint import main
|
|
|
|
rc = main(["--port", "0"])
|
|
assert rc == 12
|
|
|
|
|
|
def test_entrypoint_real_import_bug_propagates(monkeypatch) -> None:
|
|
"""v0.16.0: an ImportError from a BASELINE module (not an extra) MUST
|
|
propagate as a real traceback, not be masked as exit-12 missing-extras.
|
|
Shadowing ratatoskr.web.server (a first-party module, present with or
|
|
without the [web] extras) should raise, not return 12.
|
|
"""
|
|
import sys
|
|
|
|
import pytest as _pytest
|
|
|
|
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
|
monkeypatch.setenv("WORLDTREE_API_URL", "https://example.com")
|
|
monkeypatch.setitem(sys.modules, "ratatoskr.web.server", None)
|
|
|
|
from ratatoskr.web.entrypoint import main
|
|
|
|
with _pytest.raises(ImportError):
|
|
main(["--port", "0"])
|
|
|
|
|
|
class TestEntrypointArgs:
|
|
"""FN main argparse + serve-loop traces (contract TESTS)."""
|
|
|
|
def test_default_host_is_zero(self) -> None:
|
|
"""default_host_is_zero [trace]: argv=[] → host == '0.0.0.0'."""
|
|
from ratatoskr.web.entrypoint import _build_arg_parser
|
|
args = _build_arg_parser().parse_args([])
|
|
assert args.host == "0.0.0.0"
|
|
assert args.port == 8765
|
|
assert args.open is False
|
|
|
|
def test_port_zero_supported(self) -> None:
|
|
"""port_zero_supported [trace]: argv=['--port','0'] → port == 0."""
|
|
from ratatoskr.web.entrypoint import _build_arg_parser
|
|
args = _build_arg_parser().parse_args(["--port", "0"])
|
|
assert args.port == 0
|
|
|
|
def test_happy_argv_serves_and_returns_zero(self, monkeypatch) -> None:
|
|
"""happy_argv [tracer]: env set + uvicorn.run mocked → main returns 0."""
|
|
import uvicorn
|
|
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
|
monkeypatch.setenv("WORLDTREE_API_URL", "https://example.com")
|
|
calls = {}
|
|
monkeypatch.setattr(uvicorn, "run", lambda app, **kw: calls.update(kw))
|
|
from ratatoskr.web.entrypoint import main
|
|
rc = main(["--port", "0"])
|
|
assert rc == 0
|
|
assert calls["host"] == "0.0.0.0"
|
|
assert calls["port"] == 0
|
|
|
|
def test_open_flag_calls_webbrowser(self, monkeypatch) -> None:
|
|
"""open_flag_calls_webbrowser [trace]: --open → webbrowser.open called."""
|
|
import uvicorn
|
|
import webbrowser
|
|
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
|
monkeypatch.setattr(uvicorn, "run", lambda app, **kw: None)
|
|
opened = []
|
|
monkeypatch.setattr(webbrowser, "open", lambda url: opened.append(url))
|
|
from ratatoskr.web.entrypoint import main
|
|
main(["--port", "0", "--open"])
|
|
assert len(opened) == 1
|
|
|
|
def test_no_open_default(self, monkeypatch) -> None:
|
|
"""no_open_default [trace]: argv without --open → webbrowser.open NOT called."""
|
|
import uvicorn
|
|
import webbrowser
|
|
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
|
monkeypatch.setattr(uvicorn, "run", lambda app, **kw: None)
|
|
opened = []
|
|
monkeypatch.setattr(webbrowser, "open", lambda url: opened.append(url))
|
|
from ratatoskr.web.entrypoint import main
|
|
main(["--port", "0"])
|
|
assert opened == []
|