Skip to content

Commit 8dd5fe5

Browse files
committed
Add retention, trend reporting, and a browsable demo site
A second, mostly additive pass on top of the enterprise hardening: the operability features that make formwatch sustainable over time, and a bundled playground so people can see it work. History retention. `formwatch prune (--keep-last N | --keep-days D) [--dry-run]`, plus `keep_last`/`keep_days` config and FORMWATCH_KEEP_LAST/FORMWATCH_KEEP_DAYS, trims append-only history per form. Backed by history::prune (Retain::Last/Retain::Days), which only ever touches *.json run files and uses a total filename tiebreak so same-second runs stay ordered. Reporting. history::streaks computes each check's unbroken run of its current status; the plain-text report adds a "↳ FAIL for the last N run(s), since <date>" line for chronic non-Pass checks, and the HTML report adds a streak badge plus a compact trend strip. The HTML report also gained a client-side toolbar: search by name/URL, filter by status (failing / warnings / flaky / passing), sort by recency / severity / name, and a live shown/total count — all self-contained, no external assets. Demo site and benchmarks. `formwatch demo` serves, from the binary, 35 bundled forms (demo-sites/) covering every built-in check and known edge case: a clean baseline, missing labels / low contrast / missing alt / no landmarks, tiny targets and horizontal overflow, validation variants, the document-upload variants, autofill, lost input vs. explained session loss, multi-step wizards including slow/keyup-gated/icon-only-next, open and nested and closed shadow roots, multiple forms on a page, RTL Arabic, reCAPTCHA/hCaptcha/Turnstile/full-page Cloudflare challenge, and the LLM wording demos. scripts/benchmark.sh runs monitor over the whole set and reports timings; BENCHMARKS.md carries the methodology and current numbers (35 forms / 280 checks, median 12.96s at --wait 1). Fixes found by building the demo site: - Mobile usability never reported horizontal overflow: it compared documentElement.scrollWidth to window.innerWidth, which under mobile emulation already reflects the overflowed width. Now compares against the layout viewport (documentElement.clientWidth). New fixture + test. - Validation counted the <form> element itself as an unlabeled invalid field, since form:invalid matches whenever any control inside is invalid. Now queries input/select/textarea:invalid plus [aria-invalid=true]. New fixture + test. Also corrects two demo pages that silently did nothing: a JS `var name` collision with window.name, and a text glyph where an icon-only button needs an SVG. fmt, clippy -D warnings, and the full test suite (138 tests) pass.
1 parent ee425b4 commit 8dd5fe5

56 files changed

Lines changed: 2415 additions & 11 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.dockerignore‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@ community-forms/
77
docs/
88
fixtures/
99
tests/
10-
index.html
10+
/index.html
1111
*.md
1212
!README.md

‎BENCHMARKS.md‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Benchmarks
2+
3+
formwatch drives a real headless Chrome against every form, so its wall-clock
4+
time is dominated by browser work (page load, axe-core, DOM evaluation), not
5+
by formwatch's own bookkeeping. To make that measurable and reproducible
6+
without touching anyone's website, the benchmarks run against the **bundled
7+
demo site**: [`formwatch demo`](demo-sites/README.md) serves 35 local forms —
8+
a clean baseline plus a page for every check and known edge case — and
9+
`monitor demo-sites/forms.yml` checks all of them.
10+
11+
Because everything is served from `127.0.0.1`, there's no network latency in
12+
these numbers — what's left is browser launch, the eight built-in checks per
13+
form, screenshot capture, and report writing.
14+
15+
## Latest results
16+
17+
Measured with `RUNS=5 WAIT=1 scripts/benchmark.sh` (median of 5 runs, after
18+
one warm-up run):
19+
20+
| Forms | Checks/run | Runs | `--wait` | min (s) | median (s) | max (s) | forms/min | checks/s |
21+
|------:|-----------:|-----:|---------:|--------:|-----------:|--------:|----------:|---------:|
22+
| 35 | 280 | 5 | 1s | 12.93 | 12.96 | 13.19 | 162.0 | 21.6 |
23+
24+
The 35 demo forms cover a clean baseline, a failing page for most checks, a
25+
three-step wizard and its slow/keyup/icon-only variants, shadow-DOM and
26+
nested-shadow forms, a closed shadow root, RTL, lost input vs. explained
27+
session loss, three bot-protection widgets, and the LLM-wording demos — a
28+
realistic mix, not 35 trivial pages.
29+
30+
### Environment
31+
32+
- CPU: 13th Gen Intel Core i7-13620H (16 threads)
33+
- RAM: 46.9 GiB
34+
- OS: Ubuntu on WSL2
35+
- Chromium (`~/.cache/formwatch/chrome`): 107.0.5296.0
36+
- Rust: 1.98.1
37+
- `--max-concurrent 4` (the default)
38+
39+
## Reproduce
40+
41+
```
42+
scripts/benchmark.sh # 5 runs, --wait 1
43+
RUNS=10 WAIT=0 scripts/benchmark.sh # more runs, no idle wait
44+
PORT=9001 WAIT=3 scripts/benchmark.sh
45+
```
46+
47+
The script builds the release binary, starts `formwatch demo`, runs
48+
`monitor` over `demo-sites/forms.yml` repeatedly, and prints the same table.
49+
Everything is local; it never contacts a third-party site.
50+
51+
## Reading the numbers
52+
53+
- **`--wait` dominates.** Each form's Input-persistence check idles for
54+
`--wait` seconds (1s here; the default is 5s). That is deliberate idle
55+
time, not overhead — set `WAIT=0` to see the floor. Real `monitor` runs
56+
against remote forms add page-load time on top.
57+
- **Concurrency matters more than raw speed.** `--max-concurrent` (default
58+
4) multiplies throughput on multi-core machines at the cost of running
59+
more browsers at once; the per-host pacer (`--per-host-delay-ms`) is the
60+
polite counterweight.
61+
- **Screenshots cost something.** Every non-Pass check captures a full-page
62+
PNG; `--no-screenshots` trades evidence for speed.
63+
- **A failing page is slower than a passing one** (screenshots plus the
64+
extra validation work), which is why the demo mix matters.
65+
66+
## A note on comparing machines
67+
68+
Absolute seconds vary with CPU, disk, and the Chromium revision, so treat
69+
the table as an order-of-magnitude reference and re-run the script on your
70+
own hardware rather than quoting these numbers as a spec.

‎CHANGELOG.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,34 @@ project doesn't have a release yet, so everything below is grouped under
7575
Ctrl-C. Deliberately read-only — no endpoint accepts a URL, triggers a
7676
run, or submits anything, so exposing it can't make formwatch touch a
7777
third-party site.
78+
- History **retention**: `formwatch prune (--keep-last N | --keep-days D)
79+
[--dry-run]` (defaults via `keep_last`/`keep_days` config or
80+
`FORMWATCH_KEEP_LAST`/`FORMWATCH_KEEP_DAYS`) trims append-only history
81+
per form, touching only `*.json` run files.
82+
- **Streak/trend reporting**: the report now computes each check's
83+
unbroken run of its current status. The plain-text report adds a
84+
`↳ FAIL for the last N run(s), since <date>` line for chronic
85+
non-Pass checks, and the HTML report adds a streak badge plus a compact
86+
coloured trend strip of recent runs.
87+
- The HTML report gained a client-side toolbar: search by name/URL, filter
88+
by status (failing / warnings / flaky / passing), sort by recency,
89+
severity, or name, and a live "shown/total" count — all self-contained,
90+
no external assets.
91+
- A bundled **demo site** (`formwatch demo`, pages under `demo-sites/`)
92+
with 35 deliberately varied forms covering **every** built-in check and
93+
known edge case: a clean baseline; missing labels / low contrast /
94+
missing alt / no landmarks; small tap targets and horizontal overflow;
95+
validation variants (vague errors, aria-required-only, no required
96+
fields, a required checkbox); document-upload variants; autofill;
97+
lost input vs. explained session loss; multi-step wizards including
98+
slow, keyup-gated, and icon-only-next variants; open, nested, and closed
99+
shadow roots; multiple forms on a page; RTL Arabic; reCAPTCHA, hCaptcha,
100+
Turnstile, and a full-page Cloudflare challenge; and the LLM-wording
101+
demos. Pages are compiled into the binary, so `cargo install` gets them;
102+
nothing third-party is contacted.
103+
- `scripts/benchmark.sh` + [BENCHMARKS.md](BENCHMARKS.md): a reproducible
104+
benchmark over the demo site (9 forms / 72 checks, median 4.4s at
105+
`--wait 1`), with methodology and caveats.
78106
- Structured diagnostics via `tracing`, written to **stderr** so
79107
machine-readable stdout is never corrupted: `-v`/`-vv`/`--quiet` and
80108
`--log-format json`.
@@ -160,6 +188,20 @@ project doesn't have a release yet, so everything below is grouped under
160188

161189
### Fixed
162190

191+
- Mobile usability never reported horizontal overflow: the check compared
192+
`document.documentElement.scrollWidth` against `window.innerWidth`, but
193+
under mobile emulation `innerWidth` already reflects the overflowed
194+
width, so the comparison was never true even on a page far wider than a
195+
phone. It now compares against the layout viewport
196+
(`documentElement.clientWidth`). Found while building the demo site, and
197+
now covered by a fixture + regression test.
198+
- The validation check counted the `<form>` element itself as an unlabeled
199+
invalid field: the invalid-field query used a bare `:invalid` scoped at
200+
the form, and `form:invalid` matches whenever any control inside is
201+
invalid. A form whose *actual* invalid field had a proper
202+
`aria-describedby` was therefore failed. Found by building the demo
203+
site; fixed to query `input:invalid, select:invalid, textarea:invalid,
204+
[aria-invalid=true]`, with a regression fixture and test.
163205
- A truly **cold** Chrome cache failed to populate: the concurrent-safe
164206
download created the cache's parent directory but never the private
165207
temp directory the fetcher writes its archive into, so the very first

‎README.md‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,12 @@ your own use.
126126
a textfile-collector path, or serve it). With `--baseline`, accepted
127127
findings render as `<skipped>` (JUnit) or `baselineState: "unchanged"`
128128
with a `suppressions` entry (SARIF), so an allow-listed finding doesn't
129-
redden a pipeline.
129+
redden a pipeline. The plain-text report also shows, for any check that
130+
has held a non-Pass status for two or more consecutive runs, a `↳ FAIL
131+
for the last N run(s), since <date>` line; the HTML report adds a streak
132+
badge, a compact coloured trend strip of recent runs, and a client-side
133+
toolbar to search by name/URL, filter by status (failing / warnings /
134+
flaky / passing), and sort by recency, severity, or name.
130135
- `formwatch legal` — print the full authorized-use notice and exit.
131136
- `formwatch doctor [--json] [--probe-llm]` — check the local environment
132137
(Chrome availability, writable history/audit directories, config)
@@ -143,6 +148,13 @@ your own use.
143148
- `formwatch serve [--addr HOST:PORT]` — serve read-only HTTP endpoints
144149
(`/healthz`, `/readyz`, `/metrics`, `/api/forms`) over the accumulated
145150
history. See [Service mode](#service-mode).
151+
- `formwatch prune (--keep-last N | --keep-days D) [--dry-run]` — delete
152+
old history runs so a long-lived deployment doesn't grow forever. See
153+
[Retention](#retention).
154+
- `formwatch demo [--addr HOST:PORT]` — serve a bundled demo site of
155+
deliberately varied forms (one clean, the rest each tripping a check) so
156+
you can see what formwatch catches. See
157+
[Demo site & benchmarks](#demo-site--benchmarks).
146158
- `formwatch completions <shell>` — emit a completion script for bash,
147159
zsh, fish, PowerShell, or elvish.
148160

@@ -233,6 +245,7 @@ fail_on: fail # or: warn — also fail CI on warnings
233245
audit_log: var/audit.jsonl
234246
baseline: .formwatch/baseline.json
235247
# serve_addr: 127.0.0.1:8080 # formwatch serve bind address
248+
# keep_last: 50 # formwatch prune: runs to keep per form
236249
accept_terms: true # record acknowledgement of the legal notice
237250
notify:
238251
webhook_url: https://hooks.slack.com/services/...
@@ -257,6 +270,7 @@ in CI and containers: `FORMWATCH_HISTORY_DIR`, `FORMWATCH_CHECKS_DIR`,
257270
`FORMWATCH_INSECURE`,
258271
`FORMWATCH_NO_SANDBOX`, `FORMWATCH_AUDIT_LOG`, `FORMWATCH_BASELINE`,
259272
`FORMWATCH_SERVE_ADDR`, `FORMWATCH_ACCEPT_TERMS`,
273+
`FORMWATCH_KEEP_LAST`, `FORMWATCH_KEEP_DAYS`,
260274
`FORMWATCH_FAIL_ON`, `FORMWATCH_WEBHOOK_URL`, `FORMWATCH_WEBHOOK_ON`,
261275
`FORMWATCH_LLM`, `FORMWATCH_LLM_PROVIDER`, `FORMWATCH_LLM_MODEL`,
262276
`FORMWATCH_LLM_API_KEY`, `FORMWATCH_LLM_BASE_URL`,
@@ -397,6 +411,23 @@ It binds to `127.0.0.1` by default; set `--addr`, `serve_addr:`, or
397411
`/readyz`, or a status page at `/api/forms`. It runs until interrupted
398412
(Ctrl-C).
399413
414+
## Retention
415+
416+
History is append-only, so a deployment that runs nightly accumulates runs
417+
forever. `formwatch prune` trims it, per form:
418+
419+
```
420+
formwatch prune --keep-last 50 # keep the 50 most recent runs of each form
421+
formwatch prune --keep-days 90 # keep only runs from the last 90 days
422+
formwatch prune --keep-last 50 --dry-run # report what would go, delete nothing
423+
```
424+
425+
`--keep-last` and `--keep-days` are mutually exclusive; both keep the
426+
newest runs. Defaults can live in config (`keep_last:` / `keep_days:`) or
427+
`FORMWATCH_KEEP_LAST` / `FORMWATCH_KEEP_DAYS`. `prune` only ever touches
428+
`*.json` run files and leaves anything else in the history directory
429+
alone. Run it from the same cron/Action that runs `monitor`.
430+
400431
## Container
401432
402433
A multi-stage `Dockerfile` builds a small Debian image with Chromium and
@@ -409,6 +440,29 @@ docker build -t formwatch .
409440
docker run --rm -v "$PWD:/work" -w /work formwatch monitor forms.yml
410441
```
411442
443+
## Demo site & benchmarks
444+
445+
Want to see it work before pointing it at anything real? `formwatch demo`
446+
serves 35 bundled forms — a clean baseline, plus a page for **every** check
447+
and known edge case (missing labels, low contrast, lost input vs. explained
448+
session loss, wizards and their slow/keyup/icon-only variants, shadow DOM,
449+
a closed shadow root, RTL, three bot-protection widgets, vague wording, …):
450+
451+
```
452+
formwatch demo # serves http://127.0.0.1:8099
453+
formwatch monitor demo-sites/forms.yml
454+
formwatch report --html --out report.html
455+
```
456+
457+
The pages are compiled into the binary (`demo-sites/`), so `cargo install`
458+
gets them too — nothing to copy, and nothing third-party is contacted. See
459+
[demo-sites/README.md](demo-sites/README.md) for the page-by-page list.
460+
461+
For performance, `scripts/benchmark.sh` runs `monitor` over the demo site
462+
repeatedly and reports timings; the current reference numbers and how to
463+
reproduce them are in [BENCHMARKS.md](BENCHMARKS.md) (35 forms / 280 checks
464+
per run, **median 13.0s** with `--wait 1` and the default concurrency).
465+
412466
## Requirements
413467
414468
Just a Chrome or Chromium install. If none is found on `PATH`, formwatch

‎demo-sites/README.md‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# formwatch demo site
2+
3+
A deliberately varied set of forms for seeing what formwatch does — one
4+
clean baseline, plus a page for **every** built-in check and the known edge
5+
cases (shadow DOM, wizards, CAPTCHAs, RTL, lost input, …). It is served by
6+
`formwatch demo`, which embeds these pages in the binary, so they work after
7+
a plain `cargo install` with nothing to copy.
8+
9+
## Running it
10+
11+
```
12+
formwatch demo # serves http://127.0.0.1:8099
13+
formwatch demo --addr 127.0.0.1:9000
14+
```
15+
16+
Then browse the pages, or point formwatch at them:
17+
18+
```
19+
formwatch test http://127.0.0.1:8099/good-form.html
20+
formwatch test http://127.0.0.1:8099/lost-input.html --wait 3
21+
formwatch monitor demo-sites/forms.yml # every demo form at once
22+
formwatch report --html --out report.html # browse the results
23+
```
24+
25+
For the optional LLM wording checks:
26+
27+
```
28+
OPENAI_API_KEY=... formwatch --llm test http://127.0.0.1:8099/vague-errors.html
29+
```
30+
31+
## Baseline
32+
33+
| Page | Expected headline |
34+
|---|---|
35+
| `good-form.html` | everything Passes |
36+
37+
## Submission flow
38+
39+
| Page | Expected headline |
40+
|---|---|
41+
| `multi-step.html` | Passes after a 3-step wizard |
42+
| `slow-step.html` | Passes despite a 1.2s transition |
43+
| `keyup-next.html` | Passes (Next enabled on `keyup`) |
44+
| `icon-only-next.html` | Passes via `aria-label` |
45+
| `no-submit-control.html` | **Fail** — no Next/Continue/Submit |
46+
| `shadow-form.html` | Passes — form inside an open shadow root |
47+
| `nested-shadow-form.html` | Validation reaches a doubly-nested shadow field |
48+
| `closed-shadow.html` | **Fail** "No `<form>` found" — a real platform limit |
49+
| `multi-form.html` | Targets the bigger form, not the header search |
50+
| `rtl-arabic.html` | Passes — Arabic Next/Submit wording |
51+
52+
## Accessibility
53+
54+
| Page | Expected headline |
55+
|---|---|
56+
| `missing-labels.html` | **Fail** — unlabeled fields |
57+
| `low-contrast.html` | **Fail** — axe color-contrast |
58+
| `missing-alt.html` | **Fail** — axe image-alt |
59+
| `no-landmarks.html` | **Warn** — moderate axe findings |
60+
61+
## Validation errors
62+
63+
| Page | Expected headline |
64+
|---|---|
65+
| `vague-errors.html` | described but unhelpful wording (LLM demo) |
66+
| `aria-required-only.html` | **Warn** — native validation can't exercise it |
67+
| `no-required-fields.html` | **Warn** — nothing to test |
68+
| `checkbox-required-first.html` | **Fail** — invalid with no accessible message |
69+
70+
## Required documents
71+
72+
| Page | Expected headline |
73+
|---|---|
74+
| `document-upload.html` | **Fail** — no label, no format guidance |
75+
| `no-format-guidance.html` | **Warn** — labeled but no accept/format text |
76+
| `documents-prose-no-upload.html` | **Warn** — documents mentioned, no file input |
77+
| `good-upload.html` | Passes — labeled with `accept` + guidance |
78+
79+
## Autofill hints
80+
81+
| Page | Expected headline |
82+
|---|---|
83+
| `no-autocomplete.html` | **Warn** — name/email/phone/address with no hints |
84+
85+
## Input persistence
86+
87+
| Page | Expected headline |
88+
|---|---|
89+
| `lost-input.html` | **Fail** — the field silently clears |
90+
| `session-timeout.html` | **Warn** — cleared, but explained |
91+
| `no-text-field.html` | **Warn** — nothing to test |
92+
93+
## Mobile usability
94+
95+
| Page | Expected headline |
96+
|---|---|
97+
| `tiny-targets.html` | **Warn** — sub-44px controls |
98+
| `horizontal-overflow.html` | **Warn** — content wider than a phone |
99+
100+
## Bot protection
101+
102+
| Page | Expected headline |
103+
|---|---|
104+
| `captcha.html` | **Warn** — reCAPTCHA widget on a testable form |
105+
| `hcaptcha.html` | **Warn** — hCaptcha widget |
106+
| `turnstile.html` | **Warn** — Cloudflare Turnstile widget |
107+
| `cloudflare-challenge.html` | **Warn** — full-page block, no form reached |
108+
| `generic-botcheck.html` | **Warn** — "prove you're not a robot" wording |
109+
110+
## LLM wording (optional)
111+
112+
| Page | Expected headline |
113+
|---|---|
114+
| `vague-errors.html` | Error wording — "Invalid input." |
115+
| `instructions-unclear.html` | Instructions — vague upload guidance |
116+
117+
Expected results are indicative; real pages vary. The point is to have
118+
something to click around and compare against.
119+
120+
## Benchmarks
121+
122+
`scripts/benchmark.sh` starts this site and times repeated `monitor` runs
123+
against it; see [BENCHMARKS.md](../BENCHMARKS.md).

‎demo-sites/aria-required-only.html‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>ARIA-required only</title>
7+
<link rel="stylesheet" href="/demo.css">
8+
</head>
9+
<body>
10+
<main>
11+
<h1>ARIA-required only</h1>
12+
<p class="lede">The field is required via <code>aria-required</code> but
13+
not the native <code>required</code> attribute, so the browser's own
14+
validation never exercises it — Validation errors should Warn (not a
15+
false Pass).</p>
16+
17+
<form id="f">
18+
<label for="name">Full name</label>
19+
<input id="name" name="name" type="text" autocomplete="name" aria-required="true" aria-describedby="name-help">
20+
<small class="hint" id="name-help">As it appears on your ID.</small>
21+
<p><button type="submit">Submit</button></p>
22+
</form>
23+
</main>
24+
</body>
25+
</html>

0 commit comments

Comments
 (0)