signal-bot/fake_signal/cli.py
2026-08-29 15:30:08 +02:00

109 lines
3.6 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Drive a running fake server through a scenario, and adjudicate the result.
Exits non-zero on any mismatch, so scripts/e2e.sh is a real gate rather than
a log to read.
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
import httpx
from fake_signal.scenario import Scenario, check, load_scenario
SETTLE_SECONDS = 0.35
def _emit(line: str) -> None:
"""Write one report line to stdout."""
sys.stdout.write(f"{line}\n")
def resolve_groups(args: argparse.Namespace) -> dict[str, str]:
"""Map the scenario's symbolic group names onto real ids.
Names are supplied as repeated ``--group NAME=ID`` pairs rather than one
flag per group, because the bot now watches an arbitrary number of them
and a scenario has to be able to say "this reply belongs in the *other*
watched group".
Args:
args: Parsed command line.
Returns:
Scenario alias to real group id.
Raises:
SystemExit: A --group argument is not NAME=ID.
"""
groups: dict[str, str] = {}
for pair in args.group:
name, separator, value = pair.partition("=")
if not separator or not name or not value:
msg = f"--group expects NAME=ID, got {pair!r}"
raise SystemExit(msg)
groups[name] = value
return groups
async def run_scenario(
base_url: str, scenario: Scenario, groups: dict[str, str], *, settle: float
) -> list[str]:
"""Inject every frame, wait for the bot to react, then compare."""
async with httpx.AsyncClient(base_url=base_url, timeout=10.0) as client:
await client.post("/_control/reset")
if scenario.arrive:
# Additive: the groups already on the account stay, exactly as
# they would when somebody adds the bot to one more.
await client.post(
"/_control/groups/add",
json=[arrival.to_entry(groups) for arrival in scenario.arrive],
)
for injection in scenario.inject:
await client.post("/_control/inject", json={"raw": injection.to_frame(groups)})
await asyncio.sleep(settle)
response = await client.get("/_control/sent")
response.raise_for_status()
return check(scenario, response.json(), groups)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Parse the injector's command line."""
parser = argparse.ArgumentParser(description="Replay a scenario against the bot.")
parser.add_argument("scenarios", nargs="+", type=Path)
parser.add_argument("--base-url", default="http://127.0.0.1:8099")
parser.add_argument("--group", action="append", default=[], metavar="NAME=ID")
parser.add_argument("--settle", type=float, default=SETTLE_SECONDS)
return parser.parse_args(argv)
async def _main_async(args: argparse.Namespace) -> int:
"""Run every scenario, reporting failures as they happen."""
groups = resolve_groups(args)
failed = 0
for path in args.scenarios:
scenario = load_scenario(path)
failures = await run_scenario(args.base_url, scenario, groups, settle=args.settle)
if failures:
failed += 1
_emit(f"FAIL {scenario.name}")
for line in failures:
_emit(f" {line}")
else:
_emit(f"ok {scenario.name}")
return 1 if failed else 0
def main(argv: list[str] | None = None) -> int:
"""Entry point. Returns a process exit code."""
return asyncio.run(_main_async(parse_args(argv)))
if __name__ == "__main__":
sys.exit(main())