diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd736ed..2e8520f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,19 @@ jobs: steps: - uses: actions/checkout@v4 + # scripts/install_service.sh installs both of these for the host and + # this file never did, so ./scripts/e2e.sh has failed on every commit + # for as long as it has existed: tool/e2e_fixtures.py builds its JPEG + # and MP4 fixtures with ffmpeg, and signal_bot/media_strip.py strips + # metadata with exiftool. Verified rather than trusted, because an + # apt exit code is not the same claim as "the binary resolves". + - name: Install ffmpeg and exiftool + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg libimage-exiftool-perl + ffmpeg -version | head -1 + exiftool -ver + - name: Install uv uses: astral-sh/setup-uv@v5 diff --git a/fake_signal/cli.py b/fake_signal/cli.py index 2709308..b9c36da 100644 --- a/fake_signal/cli.py +++ b/fake_signal/cli.py @@ -10,13 +10,26 @@ from __future__ import annotations import argparse import asyncio import sys +import time from pathlib import Path +from typing import Any import httpx from fake_signal.scenario import Scenario, check, load_scenario SETTLE_SECONDS = 0.35 +# How long a scenario may wait for the sends it expects before giving up. +# +# A single fixed settle cannot cover both a reply that is a dictionary lookup +# and one that fetches an image and shells out to exiftool, and the failure +# that produced was not "slow" but "wrong": the late send landed after the +# NEXT scenario's reset and was counted against that one instead. So two +# different scenarios failed per run, never the same two twice -- one with +# "captured 0" and one with "captured 2". Waiting for the count is a +# guarantee; a bigger number is a guess that fails again on a slower machine. +PATIENCE_SECONDS = 15.0 +POLL_SECONDS = 0.05 def _emit(line: str) -> None: @@ -51,8 +64,51 @@ def resolve_groups(args: argparse.Namespace) -> dict[str, str]: return groups +async def captured(client: httpx.AsyncClient) -> list[dict[str, Any]]: + """Every send the fake server has recorded since the last reset.""" + response = await client.get("/_control/sent") + response.raise_for_status() + sends: list[dict[str, Any]] = response.json() + return sends + + +async def collect( + client: httpx.AsyncClient, want: int, *, settle: float, patience: float +) -> list[dict[str, Any]]: + """Wait for the sends a scenario expects, then for a beat of quiet. + + The quiet beat is not politeness: returning the moment the expected count + arrives would make "expected 1, captured 2" unobservable, because the + surplus send is exactly the one that has not landed yet. A scenario that + expects nothing skips the poll entirely and waits out the settle, which + is the only way to assert that nothing happens. + + Args: + client: A client pointed at the fake server's control plane. + want: How many sends the scenario expects. + settle: How long "quiet" is. + patience: How long to wait for ``want`` sends before giving up. + + Returns: + Every send recorded, which may be more or fewer than expected -- the + comparison is somebody else's job. + """ + deadline = time.monotonic() + patience + sends = await captured(client) + while len(sends) < want and time.monotonic() < deadline: + await asyncio.sleep(POLL_SECONDS) + sends = await captured(client) + await asyncio.sleep(settle) + return await captured(client) + + async def run_scenario( - base_url: str, scenario: Scenario, groups: dict[str, str], *, settle: float + base_url: str, + scenario: Scenario, + groups: dict[str, str], + *, + settle: float, + patience: float = PATIENCE_SECONDS, ) -> 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: @@ -67,9 +123,8 @@ async def run_scenario( 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) + sent = await collect(client, len(scenario.expect), settle=settle, patience=patience) + return check(scenario, sent, groups) def parse_args(argv: list[str] | None = None) -> argparse.Namespace: @@ -79,6 +134,9 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: 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) + # Overridable so the tests can drive the give-up path without waiting it + # out, and so a slow machine can be given longer without editing source. + parser.add_argument("--patience", type=float, default=PATIENCE_SECONDS) return parser.parse_args(argv) @@ -88,7 +146,9 @@ async def _main_async(args: argparse.Namespace) -> int: failed = 0 for path in args.scenarios: scenario = load_scenario(path) - failures = await run_scenario(args.base_url, scenario, groups, settle=args.settle) + failures = await run_scenario( + args.base_url, scenario, groups, settle=args.settle, patience=args.patience + ) if failures: failed += 1 _emit(f"FAIL {scenario.name}") diff --git a/tests/test_cli.py b/tests/test_cli.py index 098fba3..e45e031 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -87,7 +87,7 @@ async def test_run_scenario_announces_arriving_groups(monkeypatch: pytest.Monkey async def test_run_scenario_reports_a_mismatch(monkeypatch: pytest.MonkeyPatch) -> None: _patch_client(monkeypatch, Recorder([])) scenario = Scenario("s", expect=[Expectation(kind="image")]) - assert await cli.run_scenario("http://x", scenario, GROUPS, settle=0) + assert await cli.run_scenario("http://x", scenario, GROUPS, settle=0, patience=0.05) def test_parse_args_collects_named_groups() -> None: @@ -115,7 +115,19 @@ def test_main_exits_zero_when_every_scenario_passes( ) -> None: _patch_client(monkeypatch, Recorder([{"base64_attachments": ["A"], "recipients": ["R=="]}])) path = _scenario_file(tmp_path, "expect:\n - {kind: image}\n") - code = cli.main([str(path), "--group", "watched=W==", "--group", "reply=R==", "--settle", "0"]) + code = cli.main( + [ + str(path), + "--group", + "watched=W==", + "--group", + "reply=R==", + "--settle", + "0", + "--patience", + "0.05", + ] + ) assert code == 0 assert "ok demo" in capsys.readouterr().out @@ -126,7 +138,19 @@ def test_main_exits_non_zero_on_a_mismatch( """The e2e script is a gate only because this returns 1.""" _patch_client(monkeypatch, Recorder([])) path = _scenario_file(tmp_path, "expect:\n - {kind: image}\n") - code = cli.main([str(path), "--group", "watched=W==", "--group", "reply=R==", "--settle", "0"]) + code = cli.main( + [ + str(path), + "--group", + "watched=W==", + "--group", + "reply=R==", + "--settle", + "0", + "--patience", + "0.05", + ] + ) assert code == 1 out = capsys.readouterr().out assert "FAIL demo" in out @@ -143,8 +167,70 @@ def test_module_runs_as_a_script(tmp_path: Path, monkeypatch: pytest.MonkeyPatch monkeypatch.setattr( sys, "argv", - ["cli", str(path), "--group", "watched=W==", "--group", "reply=R==", "--settle", "0"], + [ + "cli", + str(path), + "--group", + "watched=W==", + "--group", + "reply=R==", + "--settle", + "0", + "--patience", + "0.05", + ], ) with pytest.raises(SystemExit) as exit_info: runpy.run_module("fake_signal.cli", run_name="__main__", alter_sys=True) assert exit_info.value.code == 1 + + +class Slow(Recorder): + """A server whose send only shows up after a few polls.""" + + def __init__(self, sent: list[dict], *, after: int) -> None: + super().__init__(sent) + self.after = after + self.reads = 0 + + def __call__(self, request: httpx.Request) -> httpx.Response: + if request.url.path == "/_control/sent": + self.reads += 1 + return httpx.Response(200, json=self.sent if self.reads > self.after else []) + return super().__call__(request) + + +async def test_a_reply_that_is_slow_is_waited_for_rather_than_missed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The bug this replaces: a fixed settle expired before a reply that had + # to fetch an image and shell out to exiftool, so the send landed after + # the NEXT scenario's reset and was counted against that one instead. + recorder = Slow([{"base64_attachments": ["A"], "recipients": ["R=="]}], after=3) + _patch_client(monkeypatch, recorder) + scenario = Scenario("s", inject=[Injection(text="!cat")], expect=[Expectation(kind="image")]) + assert await cli.run_scenario("http://x", scenario, GROUPS, settle=0) == [] + assert recorder.reads > 3 + + +async def test_waiting_gives_up_and_reports_what_did_arrive( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Patience is a deadline, not a promise: a reply that never comes has to + # fail the scenario rather than hang the whole run. + recorder = Recorder([]) + _patch_client(monkeypatch, recorder) + scenario = Scenario("s", inject=[Injection(text="!cat")], expect=[Expectation(kind="image")]) + failures = await cli.run_scenario("http://x", scenario, GROUPS, settle=0, patience=0.05) + assert "captured 0" in failures[0] + + +async def test_a_scenario_expecting_nothing_does_not_poll_at_all( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # "nothing must happen" cannot be waited for -- there is no count to + # reach -- so it waits out the settle and reads once. + recorder = Recorder([]) + _patch_client(monkeypatch, recorder) + scenario = Scenario("s", inject=[Injection(text="chatter")]) + assert await cli.run_scenario("http://x", scenario, GROUPS, settle=0) == [] diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..e0b7e70 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,207 @@ +/* + * Layout only. Every colour, space, radius and size below is a token from + * @kuhyx/web-ui — the same frozen palette the rest of kuhy's apps use, and + * the charcoal-and-accent family tool/tagger.html carried by hand. Nothing + * here invents a value; if one is missing it belongs in the token package. + */ + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + font-size: var(--text-body); +} + +main { + max-width: 60rem; + margin: 0 auto; + padding: var(--space-4) var(--space-3) var(--space-6); +} + +h1 { + margin: 0 0 var(--space-3); + font-size: var(--text-display); + letter-spacing: var(--tracking-display); +} + +h2 { + margin: 0 0 var(--space-3); + font-size: var(--text-title); + letter-spacing: var(--tracking-title); +} + +p { + max-width: var(--measure); + margin: 0 0 var(--space-3); + color: var(--text-muted); + line-height: 1.5; +} + +/* Navigation ------------------------------------------------------------- */ + +nav { + display: flex; + gap: var(--space-2); + margin-bottom: var(--space-4); + border-bottom: 1px solid var(--border); + padding-bottom: var(--space-3); +} + +/* The hover rule below is more specific than a bare attribute selector, so + * the active tab has to claim :hover too. Without it the pointer -- which is + * still sitting on the tab you just clicked -- repaints it as an ordinary + * surface, and --on-fill text on --surface-2 is dark on dark. */ +nav button[aria-current="true"], +nav button[aria-current="true"]:hover:not(:disabled) { + background: var(--accent); + border-color: var(--accent); + color: var(--on-fill); +} + +/* Controls --------------------------------------------------------------- */ + +button { + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-1); + color: var(--text); + font: inherit; + font-size: var(--text-label); + cursor: pointer; +} + +button:hover:not(:disabled) { + background: var(--surface-2); +} + +button:disabled { + color: var(--text-muted); + cursor: not-allowed; + opacity: 0.6; +} + +input, +select, +textarea { + padding: var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-1); + color: var(--text); + font: inherit; + font-size: var(--text-body); +} + +input:disabled, +select:disabled { + color: var(--text-muted); + opacity: 0.6; +} + +:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +/* Settings: one row per key, so the names line up and the values do too. */ + +.setting { + display: grid; + grid-template-columns: 16rem 1fr; + gap: var(--space-3); + align-items: center; + margin-bottom: var(--space-2); + font-family: var(--font-mono); + font-size: var(--text-label); + color: var(--text-muted); +} + +section > button { + margin-top: var(--space-3); +} + +pre { + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-1); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: var(--text-label); + overflow-x: auto; +} + +textarea { + width: 100%; + font-family: var(--font-mono); +} + +/* Photos ----------------------------------------------------------------- */ + +img { + display: block; + max-width: 100%; + max-height: 26rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-1); +} + +ul { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin: var(--space-3) 0; + padding: 0; + list-style: none; +} + +li label { + display: flex; + gap: var(--space-2); + align-items: center; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-1); + font-size: var(--text-label); + cursor: pointer; +} + +li label:has(input:checked) { + border-color: var(--accent); + color: var(--accent); +} + +input[type="checkbox"] { + width: var(--space-3); + height: var(--space-3); + accent-color: var(--accent); +} + +/* Adding somebody is one short field, not a settings row. */ + +.adder { + display: flex; + gap: var(--space-2); + align-items: center; + font-size: var(--text-label); + color: var(--text-muted); +} + +/* Anything the page has to admit went wrong. */ + +[data-testid$="error"], +[data-testid="groups-locked"] { + color: var(--danger); +} + +[data-testid="note"] { + color: var(--success); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index b0aa1cd..5caf898 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,3 +1,7 @@ +import "@kuhyx/web-ui/tokens.css"; + +import "./index.css"; + import { createRoot } from "react-dom/client"; import { App } from "./app.tsx"; diff --git a/web/src/views/settings.tsx b/web/src/views/settings.tsx index 928aa70..8b8eb5e 100644 --- a/web/src/views/settings.tsx +++ b/web/src/views/settings.tsx @@ -66,7 +66,7 @@ export function Settings(): React.JSX.Element { setEdits((current) => ({ ...current, [entry.key]: value })); }; return ( -