fix: green CI, and a page that has actually been looked at

Two things the last commits could not have caught.

CI was red on every commit for as long as scripts/e2e.sh has existed, and
for two reasons, not one. ci.yml never installed ffmpeg or exiftool while
install_service.sh installs both for the host, so tool/e2e_fixtures.py could
not build its JPEG and MP4 fixtures. That step is added and verified by
running the binaries rather than by trusting apt's exit code.

Underneath it was a flake that ffmpeg alone would not have fixed. With the
tools present, e2e still failed two scenarios per run -- never the same two
twice, always one reporting "captured 0" and another "captured 2". The cause
is fake_signal/cli.py sleeping a fixed settle after each injection and then
reading the sends: a reply that fetches an image and shells out to exiftool
lands after that expires, so it arrives after the NEXT scenario's reset and
is counted against that one instead. It now waits for the count it expects,
with a deadline, and then for a beat of quiet -- returning the moment the
count arrives would make "expected 1, captured 2" unobservable, because the
surplus send is exactly the one that has not landed yet. A scenario expecting
nothing skips the poll and waits out the settle, which is the only way to
assert that nothing happens. --patience is exposed so the tests can drive the
give-up path without waiting it out; test_cli went from 45s to 0.4s. Two
consecutive full runs: 28 ok, 0 FAIL, exit 0.

And the page had no styling at all. Times New Roman on white, default
buttons, and the tagger's charcoal theme lost in the absorption -- the jsdom
suite asserts behaviour and cannot see any of that. @kuhyx/web-ui's tokens.css
is now imported and src/index.css lays the page out with those tokens only,
inventing no values.

The visual pass also found a real defect the tests could not: the active tab
was unreadable after being clicked. `button:hover` is more specific than a
bare attribute selector, so with the pointer still resting on the tab you
just clicked, the active rule lost and --on-fill dark text repainted onto
--surface-2 dark. Confirmed by reading the computed style, not by eye:
bg was rgb(56,49,46) where it should have been the accent. The active rule
now claims :hover too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
This commit is contained in:
Krzysztof Rudnicki 2026-08-31 15:38:24 +02:00
parent 8500a1add1
commit 34b9900a90
8 changed files with 383 additions and 12 deletions

View File

@ -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

View File

@ -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}")

View File

@ -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) == []

207
web/src/index.css Normal file
View File

@ -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);
}

View File

@ -1,3 +1,7 @@
import "@kuhyx/web-ui/tokens.css";
import "./index.css";
import { createRoot } from "react-dom/client";
import { App } from "./app.tsx";

View File

@ -66,7 +66,7 @@ export function Settings(): React.JSX.Element {
setEdits((current) => ({ ...current, [entry.key]: value }));
};
return (
<label key={entry.key}>
<label className="setting" key={entry.key}>
{entry.key}
{view.needs_groups.includes(entry.key) ? (
<select

View File

@ -112,7 +112,7 @@ export function Tagger(props: Readonly<{
>
Next
</button>
<label>
<label className="adder">
Add somebody
<input
onChange={(event) => {

View File

@ -11,7 +11,8 @@
"jsx": "react-jsx",
"types": [
"vitest/globals",
"node"
"node",
"vite/client"
],
"strict": true,
"noUncheckedIndexedAccess": true,