mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
109 lines
3.6 KiB
Python
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())
|