openvibers/openvibe.host Commits

Browser check (WS-Q task 3; WS-T tasks 3-4): scripts/browser-check.js runs openvibe-shared/browser-harness against every public product (the registry's public origins whose home answers HTML; known routes + one sitemap URL per section, detail pages first, up to --max-routes 7; /__ovcheck-404 must answer 404; Billing /policy only): per route x 390/768/1280 status, console errors, overflow, duplicate scripts; no-JS text, canonical and JSON-LD against visible text; axe-core serious/critical; per site repeated-navigation growth and idle work. Known noise (Cloudflare's injected beacon blocked by CSP, signed-out 401 session probes) is counted per label, --strict-errors fails it. --json, --out (appends a dated table + details), --remote <ssh-host> (script + harness streamed over ssh into a temp dir, run with --json there, removed; needs Node 22 + Chrome; OVHOST_SSH), --routes-file. ovhost deploy --browser-check: after a deploy that went through, that site is checked and reported; never changes the exit code or the record (lib/browser-check-hook.js). docs/browser-check.md: what it checks, the remote runner, the 2026-09-26 findings with the fix commits per repo, what is left, and the first recorded production run (11/23 sites pass before the fixes deploy). test/browser-check.test.js (stub harness, local registry, fake ssh, the deploy hook)

OP OpenVibers committed 26 Sept 2026 · 228653124f · 7 files, +944 −2


Changes

ddiff --git a/README.md b/README.md
iindex 153e660..f5fc551 100644
---- a/README.md
++++ b/README.md
@@@ -95,7 +95,7 @@ ovhost validate <service> [--manifest <file>] env NAMES, unit files, port, vhos
ovhost env-names <service> names declared in the checkout's .env.example
ovhost show [<service>...] the inventory as ovhost reads it (repo, owner, units, socket, workers, probes)
ovhost plan <service> [--to <sha>] [--no-fetch]
--ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>]
++ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>] [--browser-check]
ovhost rollback <service> [--to <sha>] [--wait-idle] [--force]
ovhost releases <service> [--limit <n>]
ovhost certs [--warn-days <n>]
@@@ -111,6 +111,12 @@ ovhost drill <service> [--backup <dir>] [--keep] restore drill (root only)
Every command accepts `--json` and `--inventory <file>`. `drill` exits `0` passed, `1` refused (not root, port in use, no backup, unsupported), `2` failed. Other exit codes: `0` ok · `1` usage/precondition (including a held lock) · `2` validation failed, nothing restarted · `3` not ready, rolled back and serving · `4` rollback failed, **manual intervention** · `5` protected sessions active (refused, or `--wait-idle` gave up).
++`deploy --browser-check` checks the service's public site in headless Chrome after a deploy that went through. It runs [`scripts/browser-check.js`](docs/browser-check.md) for that one site. The check is report only: it never changes the exit code or the release record. It needs Chrome where ovhost runs.
++
++### Browser check (all public sites)
++
++`node scripts/browser-check.js [--sites a,b] [--out docs/browser-check.md] [--remote <ssh-host>]` loads every public product in headless Chrome with `openvibe-shared/browser-harness`. Per route it checks status, console errors, overflow at 390/768/1280 px, duplicate scripts, no-JS text, canonical, JSON-LD against the visible text and axe-core. Per site it checks repeated-navigation growth and idle work. See [docs/browser-check.md](docs/browser-check.md) for what it checks, the remote runner and the recorded runs.
++
### Deploy sequence
1. Take the per-service lock. Refuse if the checkout uses Live's `releases/current` layout (Stage A only drives in-place checkouts, which is what every service runs today).
ddiff --git a/docs/browser-check.md b/docs/browser-check.md
nnew file mode 100644
iindex 0000000..fc61b2e
---- /dev/null
++++ b/docs/browser-check.md
@@@ -0,0 +1,409 @@
++# Browser check (roadmap WS-Q task 3; WS-T tasks 3 and 4)
++
++`scripts/browser-check.js` loads every public OpenVibe product in headless Chrome and checks what a visitor,
++a crawler and a screen reader get. It uses `openvibe-shared/browser-harness` (Shared 1.16.0), which grew out of
++Live's `test/browser/smoke.js`. It needs Node 22 or later and Chrome, and has no npm dependency: it drives Chrome
++over the DevTools protocol with Node's own WebSocket.
++
++```sh
++node scripts/browser-check.js # every public product, summary per site
++node scripts/browser-check.js --sites live,wiki --json # some sites, the full reports as JSON
++node scripts/browser-check.js --out docs/browser-check.md # append a dated run (table + details) here
++node scripts/browser-check.js --remote runner@other-host # run it on another machine over ssh
++ovhost deploy wiki --browser-check # after a deploy: that site only, report only
++```
++
++Other options: `--routes-file <json>`, `--max-routes <n>` (default 7), `--widths 390,768,1280`, `--no-axe`,
++`--no-nav`, `--laps <n>` (default 5), `--strict-errors` and `--harness <path>`. The exit code is 0 when every
++site passes, 1 when one fails and 2 when the run itself broke.
++
++## What it loads
++
++- **Sites.** Every service in OpenVibe.Network's registry (`/api/v1/registry/services`) with a public origin
++ whose home page answers HTML. API roots that answer `text/plain` (Events) are skipped.
++- **Routes per site.** The home page and a few known routes (`KNOWN_ROUTES` in the script). Then one URL per
++ section of the site's `sitemap.xml`, detail pages first, up to `--max-routes`; a sitemap index is followed
++ one level. Last comes `/__ovcheck-404`, which must answer 404. Billing is checked on `/policy` only.
++- **Load.** Only GETs, one page at a time, one Chrome for the whole run. Each route gets a fresh browser
++ context with its cache in memory. The user agent ends in `OpenVibe-BrowserCheck/1`. A full run of 23 sites
++ takes about 20 minutes. Chrome's profile goes to `$TMPDIR`: point it at a roomy disk.
++
++## What it checks
++
++Per route, at 390, 768 and 1280 px:
++
++| Check | Fails when |
++|---|---|
++| status | the document's HTTP status is not the expected one (200, or the route's `status`) |
++| errors | an uncaught exception, `console.error`, or a browser error entry (a failed load, a CSP block) |
++| overflow | the page can be scrolled sideways; the report names the widest offending elements |
++| scripts | a script URL (origin + path) is requested twice |
++
++Once per route, with JavaScript off (the initial HTML, what a crawler reads):
++
++| Check | Fails when |
++|---|---|
++| nojs | fewer than 200 characters of visible text |
++| canonical | there is not exactly one absolute `<link rel=canonical>`. A noindex page may have none. Another origin, or a different URL once JavaScript runs, is a warning. |
++| jsonld | a JSON-LD block does not parse, or an entity's headline or name is in no visible text. An ItemList needs 80% of its item names visible. Visible only with JavaScript is a warning. |
++
++At the widest width, **axe-core 4.13.0** runs the WCAG 2.0/2.1 A and AA rules. Serious and critical violations
++fail; moderate and minor are reported. axe-core is not vendored. An installed `axe-core` is used when there is
++one; otherwise the pinned file comes from jsDelivr, is checked against its sha384 (the same bytes as the npm
++tarball) and is cached in `~/.cache/openvibe-shared`.
++
++A document that is not HTML, such as an API's JSON 404, is judged on its status only.
++
++Once per site, **navigation growth** (a D46 scenario) runs home ↔ the second route five times. It clicks a link
++when there is one, so a single-page app navigates in place; the back/forward cache is off. After a forced GC at
++the end of each lap it samples the JS heap, DOM nodes, event listeners and documents (`Performance.getMetrics`).
++It also samples live intervals, pending timeouts and open WebSockets, counted by a probe installed before any
++page script. A measure fails when it grows from lap 2 to the last lap by more than its budget **and** is still
++growing over the second half:
++
++| heap | nodes | listeners | documents | intervals | timeouts | sockets |
++|---|---|---|---|---|---|---|
++| 3 MB | 300 | 30 | 1 | 1 | 10 | 1 |
++
++Then **idle** work: for 5 seconds after the page settles, the report gives CPU busy time (script, style,
++layout), requests (with their URLs) and running animations. Idle work is reported, never failed.
++
++**Known noise** is counted per label in the report, not failed; `--strict-errors` fails it too. There are two
++kinds:
++- Cloudflare's Web Analytics beacon, which Cloudflare injects at the edge and the sites' CSP blocks.
++- A signed-out visitor's session probe answering 401 (`/auth/me`, `/api/auth/refresh`).
++
++## Remote runner
++
++`--remote <ssh-host>` exists because the workstation's disk is nearly full (hazard H7). It streams the script and
++the harness file over one ssh connection into `mktemp -d` on the other machine and runs them there with `--json`.
++It then removes the directory and writes the report here. The remote needs **Node 22+ and Chrome**
++(`CHROME_BIN` or `/usr/bin/google-chrome`) and must reach the sites itself. `--routes-file` travels inline.
++`OVHOST_SSH` replaces the ssh command, for example `ssh -p 2222 -i key`. The default is `ssh -o BatchMode=yes`,
++which never prompts.
++
++## After a deploy
++
++`ovhost deploy <service> --browser-check` runs the check for that one site after a deploy that went through,
++and prints the result. It never changes the exit code or the release record, and it says so when Chrome is
++missing on the host. Until Host's `openvibe-shared` pin reaches 1.16.0, the script finds the harness with
++`--harness` or in a sibling `OpenVibe.Shared` checkout.
++
++## Findings, 2026-09-26
++
++The production run recorded below covered 23 sites and 75 routes (two calibration runs before it found the same product issues). Everything with a small, clear fix is fixed and
++committed in its repository. **Nothing here is deployed** except Network's two commits, which went out with an
++unrelated push. The run table shows production as it was.
++
++| Site | Found | Fix (commit) |
++|---|---|---|
++| every site's `/updates` | the pressed filter chip of the shared update log was white on `--accent`, 3.67:1 (axe serious) | Shared `f4877e0` (1.16.0). Each site needs a pin bump after the tag. |
++| openvibe.network | `/login`: unnamed password toggles (axe critical); the tabs and muted text below AA; no canonical; 117 characters without JS. `/updates`: current chip 3.67:1. Home: JSON-LD list named by no visible text | Network `6665245`, `dae6ab2` (deployed) |
++| openvibe.live | `/chat` and `/search` had no canonical (the `/search` meta existed but was never routed). The home JSON-LD list name was not visible. VOD/clip/channel volume sliders were unnamed (axe critical). Guests requested `/api/coins/channel-balance` and got 401 on every channel/VOD/clip. Docs: `/assets/favicon.ico` 404, no canonical, scrolling code not reachable by keyboard | Live `934156b`, `fcfc0d5` (branch `seadragon`) |
++| openvibe.tools | `/developers` 858 px wide at 768 px; the family pages' `.cta` at 3.67:1; the tool pages' "Primary" tag at 3.16:1 | Tools `2140ef5`, `f0307c0`, `858845c` |
++| openvibe.community | breadcrumb links told apart by colour alone (axe serious) | Community `4072708` |
++| openvibe.blog | buttons and `.button` links at 3.67:1 (the 404 page) | Blog `b5b49d3` |
++| openvibe.games | no icon link, so `/favicon.ico` 404 on every visit | Games `0f29769` |
++| billing.openvibe.network | `/policy`: no canonical, `/favicon.ico` 404. nginx sent `X-Robots-Tag: noindex` on the policy that robots.txt and the app call indexable | Billing `f7ff3cd`, `5fa5546` |
++| search.openvibe.network | indexable front page without a canonical | Search `99f1e76` |
++| codes, community, wiki, blog, chat | `/auth/me` answered **503** after about ten quick page loads. The navbar's session probe shared the 10-per-minute sign-in rate limit, and nginx answers 503 by default. A signed-in visitor reading quickly looks signed out. | Codes `f1ddee9`, Community `eb68e9e`, Wiki `a8362b9`, Blog `6555217`, Chat `d461fd4` (vhosts; `nginx -t` not run here) |
++
++Repeated navigation: nothing grows on any site. Early calibration runs showed +0.9 MB and +1,300 nodes per three
++laps on multi-page sites. That was Chrome's back/forward cache holding the previous pages (3 → 5 → 7 documents,
++then flat), not a leak; the harness now turns that cache off.
++
++### Left to do
++
++- **Releases.** Tag Shared 1.16.0, then bump the `openvibe-shared` pin on every site so the update-log chip fix
++ reaches them, and on Host so `scripts/browser-check.js` and `deploy --browser-check` find the harness without
++ `--harness`. Deploy the commits above; the vhost changes need `nginx -t` and a reload.
++- **Cloudflare Web Analytics beacon** (161 blocked loads on Blog, Codes, Community, Media, Network, Search,
++ Sources and Wiki). Cloudflare injects it at the edge and each site's CSP blocks it. Either turn off Web
++ Analytics' automatic setup for those zones (the network has its own analytics), or allow
++ `static.cloudflareinsights.com` and `cloudflareinsights.com` in each CSP. That is a decision, so it is
++ counted as known here, not fixed.
++- **401 from signed-out session probes** (`/auth/me`, Live's `/api/auth/refresh`) on 7 sites. Every guest
++ page view logs one red console line. Answering 200 `{ user: null }`, or skipping the probe when there is no
++ session cookie, would remove it; this changes each site's auth contract, so it is counted as known.
++- **Idle work.** Every page with the shared navbar keeps the main thread 12–29% busy while nothing happens.
++ The OV mark's infinite SVG animations (`ovmFloat`, `ovmGlow`, `ovmComet`, `ovmDot`) restyle and re-lay-out
++ every frame. Placeholder pages add `drift` (19 infinite animations), and Live's home runs 41. Stopping the
++ mark after its intro, animating only a composited HTML wrapper, or pausing when out of view would bring idle
++ CPU near 0. That is Shared `ov-mark.js` and a design call. Live's home also makes 15 requests (159 KB) in
++ 5 idle seconds.
++- **Live `/clip/369`.** It asks `/api/vods/5541/context`, which answers 404 because the clip's source VOD
++ has no context, and logs a console error. The client could skip the call when the VOD is gone.
++- **openvibe.chat** answers unknown paths with JSON, not an HTML 404 page. The harness judges such an answer
++ on its status only.
++- **Coverage.** Coupons, News, Tips and VIP have the same `/auth/` rate limit in their repositories' vhosts
++ (their domains still serve the Sites placeholder). Live's own `test/browser/smoke.js` keeps its Live-only
++ checks (SPA routes, chat pinning, the broadcast gate); its generic checks could move to the harness. Signed-in
++ pages are not checked. Neither are the other tool subdomains (`*.openvibe.tools`) or the placeholder sites'
++ other routes.
++
++## Runs
++
++The first two runs used earlier harness builds, with the back/forward cache on and ItemLists judged by name.
++They are not recorded here. The run below is the first with the harness as committed.
++
++### Run 2026-09-26T04:11:56.295Z (local; Chrome/150.0.7843.0)
++
++11/23 sites pass. Widths 390, 768, 1280; axe axe-core 4.13.0, WCAG 2.1 A/AA; navigation 5 laps. Cells: ✓ all pass, **n✗** routes failing, n! warnings, - not run.
++
++| Site | Routes | Status | Errors | Overflow | Scripts | No-JS | Canonical | JSON-LD | axe | Nav | axe serious/critical (moderate) | Growth lap 2→last | Idle 5 s: CPU · requests · infinite animations |
++|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
++| ai.openvibe.network | 2 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 0/0 (0) | -1 KB · 0 nodes · 0 lst | 28.2% · 2 req · 19 anim |
++| billing.openvibe.network | 1 | ✓ | **1✗** | ✓ | ✓ | ✓ | - | - | ✓ | ✓ | 0/0 (0) | -1 KB · 0 nodes · 0 lst | 0% · 0 req |
++| openvibe.blog | 4 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | **2✗** | ✓ | 2/0 (0) | 0 KB · 0 nodes · 0 lst | 13.9% · 2 req · 8 anim |
++| openvibe.chat | 4 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | - | **1✗** | ✓ | 1/0 (0) | 0 KB · 0 nodes · 0 lst | 14.1% · 2 req · 8 anim |
++| openvibe.codes | 7 | ✓ | **1✗** | ✓ | ✓ | ✓ | ✓ | - | ✓ | ✓ | 0/0 (0) | 0 KB · 0 nodes · 0 lst | 12.3% · 2 req · 9 anim |
++| openvibe.community | 6 | ✓ | **2✗** | ✓ | ✓ | ✓ | ✓ | ✓ | **3✗** | ✓ | 3/0 (0) | 0 KB · 0 nodes · 0 lst | 24.9% · 2 req · 8 anim |
++| openvibe.coupons | 2 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 0/0 (0) | +2 KB · 0 nodes · +2 lst | 23% · 2 req · 19 anim |
++| openvibe.deals | 2 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 0/0 (0) | 0 KB · 0 nodes · 0 lst | 22.5% · 2 req · 19 anim |
++| openvibe.games | 2 | ✓ | **1✗** | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 0/0 (0) | +1 KB · 0 nodes · 0 lst | 20.8% · 2 req · 9 anim |
++| openvibe.host | 2 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 0/0 (0) | -1 KB · 0 nodes · 0 lst | 21.1% · 2 req · 16 anim |
++| openvibe.live | 8 | ✓ | **2✗** | ✓ | ✓ | ✓ | **2✗** | ✓ | **2✗** | ✓ | 0/2 (0) | +111 KB · 0 nodes · +3 lst | 23.7% · 15 req · 41 anim |
++| openvibe.media | 3 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | - | **1✗** | ✓ | 1/0 (0) | +1 KB · 0 nodes · 0 lst | 21.8% · 2 req · 8 anim |
++| openvibe.network | 4 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | **1✗** | ✓ | 1/0 (0) | 0 KB · 0 nodes · 0 lst | 25.7% · 2 req · 19 anim |
++| openvibe.news | 2 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 0/0 (0) | 0 KB · 0 nodes · 0 lst | 19.5% · 2 req · 19 anim |
++| openre.stream | 2 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 0/0 (0) | 0 KB · 0 nodes · 0 lst | 23.3% · 2 req · 19 anim |
++| openvibe.reviews | 2 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 0/0 (0) | -1 KB · 0 nodes · -2 lst | 22.4% · 2 req · 19 anim |
++| search.openvibe.network | 2 | ✓ | ✓ | ✓ | ✓ | ✓ | **1✗** | - | ✓ | ✓ | 0/0 (0) | 0 KB · 0 nodes · 0 lst | 12.2% · 2 req · 8 anim |
++| sources.openvibe.network | 2 | ✓ | ✓ | ✓ | ✓ | ✓ | - | - | ✓ | ✓ | 0/0 (0) | +2 KB · 0 nodes · 0 lst | 0% · 0 req |
++| openvibe.tips | 2 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 0/0 (0) | 0 KB · 0 nodes · 0 lst | 22.5% · 2 req · 19 anim |
++| openvibe.tools | 8 | ✓ | ✓ | **1✗** | ✓ | ✓ | ✓ | ✓ | **4✗** | ✓ | 4/0 (0) | 0 KB · 0 nodes · 0 lst | 28.7% · 2 req · 8 anim |
++| openvibe.trade | 2 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 0/0 (0) | +1 KB · 0 nodes · 0 lst | 21.2% · 2 req · 19 anim |
++| openvibe.vip | 2 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 0/0 (0) | +2 KB · 0 nodes · +2 lst | 21.4% · 2 req · 19 anim |
++| openvibe.wiki | 4 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | **1✗** | ✓ | 1/0 (0) | 0 KB · 0 nodes · 0 lst | 13% · 2 req · 8 anim |
++
++Known errors, counted and not failed (`--strict-errors` fails them):
++- Cloudflare Web Analytics beacon, injected at the edge, blocked by the site CSP: 161× on openvibe.blog, openvibe.codes, openvibe.community, openvibe.media, openvibe.network, search.openvibe.network, sources.openvibe.network, openvibe.wiki
++- signed-out session probe answered 401: 142× on openvibe.blog, openvibe.chat, openvibe.codes, openvibe.community, openvibe.live, openvibe.media, openvibe.wiki
++
++<details><summary>Details: routes and findings per site</summary>
++
++**https://ai.openvibe.network**: pass (2 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/__ovcheck-404`: pass
++- navigation `/` ↔ `/` ×5 (link): pass; growth lap 2→5: heap -1 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 28.2% (script 2 ms, style 172 ms, layout 204 ms), 2 request(s), 0.2 KB (`https://openvibe.network/shared/release-watch.js`, `https://ai.openvibe.network/release.json`); 19 running animation(s), 19 infinite: `drift on i`, `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`
++
++**https://billing.openvibe.network**: FAIL (1 route(s), Chrome/150.0.7843.0)
++- `/policy`: errors fail
++ - error: network: Failed to load resource: the server responded with a status of 404 () (https://billing.openvibe.network/favicon.ico)
++ - canonical: noindex page: no canonical needed
++- navigation `/policy` ↔ `/policy` ×5 (load): pass; growth lap 2→5: heap -1 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 0% (script 0 ms, style 0 ms, layout 0 ms), 0 request(s), 0 KB
++
++**https://openvibe.blog**: FAIL (4 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/updates`: axe fail
++ - ignored ×3: Cloudflare Web Analytics beacon, injected at the edge, blocked by the site CSP
++ - ignored ×3: signed-out session probe answered 401
++ - axe serious: color-contrast (1 node) Elements must meet minimum color contrast ratio thresholds: `.ov-shipped-chip:nth-child(1)`
++- `/@openvibe/patch-notes-object-first-writes`: pass
++- `/__ovcheck-404`: axe fail
++ - ignored ×3: Cloudflare Web Analytics beacon, injected at the edge, blocked by the site CSP
++ - ignored ×3: signed-out session probe answered 401
++ - axe serious: color-contrast (1 node) Elements must meet minimum color contrast ratio thresholds: `.button`
++- navigation `/` ↔ `/updates` ×5 (link): pass; growth lap 2→5: heap 0 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 13.9% (script 2 ms, style 74 ms, layout 92 ms), 2 request(s), 4 KB (`https://openvibe.blog/shared/release-watch.js`, `https://openvibe.blog/release.json`); 8 running animation(s), 8 infinite: `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`, `ovmDot on circle`
++ - error during navigation: network: Failed to load resource: the server responded with a status of 503 ()
++
++**https://openvibe.chat**: FAIL (4 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/rooms`: pass
++- `/updates`: axe fail
++ - ignored ×3: signed-out session probe answered 401
++ - axe serious: color-contrast (1 node) Elements must meet minimum color contrast ratio thresholds: `.ov-shipped-chip:nth-child(1)`
++- `/__ovcheck-404`: pass (application/json: only the status is checked)
++- navigation `/` ↔ `/rooms` ×5 (link): pass; growth lap 2→5: heap 0 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 14.1% (script 2 ms, style 72 ms, layout 97 ms), 2 request(s), 4 KB (`https://openvibe.chat/shared/release-watch.js`, `https://openvibe.chat/release.json`); 8 running animation(s), 8 infinite: `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`, `ovmDot on circle`
++ - error during navigation: network: Failed to load resource: the server responded with a status of 503 ()
++
++**https://openvibe.codes**: FAIL (7 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/tools/webhooks`: pass
++- `/manifests/validate`: pass
++- `/docs`: pass
++- `/oauth`: pass
++- `/policy`: pass
++- `/__ovcheck-404`: errors fail
++ - error: network: Failed to load resource: the server responded with a status of 503 () (https://openvibe.codes/auth/me)
++ - ignored ×3: Cloudflare Web Analytics beacon, injected at the edge, blocked by the site CSP
++ - ignored ×1: signed-out session probe answered 401
++- navigation `/` ↔ `/tools/webhooks` ×5 (link): pass; growth lap 2→5: heap 0 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 12.3% (script 2 ms, style 62 ms, layout 79 ms), 2 request(s), 4 KB (`https://openvibe.codes/shared/release-watch.js`, `https://openvibe.codes/release.json`); 9 running animation(s), 9 infinite: `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`, `ovmDraw on path`
++ - error during navigation: network: Failed to load resource: the server responded with a status of 503 ()
++
++**https://openvibe.community**: FAIL (6 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/pastes`: pass
++- `/pulse`: axe fail
++ - ignored ×3: Cloudflare Web Analytics beacon, injected at the edge, blocked by the site CSP
++ - ignored ×3: signed-out session probe answered 401
++ - axe serious: link-in-text-block (1 node) Links must be distinguishable without relying on color: `.crumbs > a[href="/"]`
++- `/p/rogue-frame-5292`: axe fail
++ - ignored ×3: Cloudflare Web Analytics beacon, injected at the edge, blocked by the site CSP
++ - ignored ×3: signed-out session probe answered 401
++ - axe serious: link-in-text-block (2 nodes) Links must be distinguishable without relying on color: `.crumbs > a[href="/"]`, `.crumbs > a[href$="pastes"]`
++- `/s`: errors fail, axe fail
++ - error: network: Failed to load resource: the server responded with a status of 503 () (https://openvibe.community/auth/me)
++ - ignored ×3: Cloudflare Web Analytics beacon, injected at the edge, blocked by the site CSP
++ - ignored ×2: signed-out session probe answered 401
++ - axe serious: link-in-text-block (1 node) Links must be distinguishable without relying on color: `.crumbs > a[href="/"]`
++- `/__ovcheck-404`: errors fail
++ - error: network: Failed to load resource: the server responded with a status of 503 () (https://openvibe.community/auth/me)
++ - ignored ×3: Cloudflare Web Analytics beacon, injected at the edge, blocked by the site CSP
++ - ignored ×1: signed-out session probe answered 401
++- navigation `/` ↔ `/pastes` ×5 (link): pass; growth lap 2→5: heap 0 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 24.9% (script 2 ms, style 79 ms, layout 90 ms), 2 request(s), 4 KB (`https://openvibe.community/shared/release-watch.js`, `https://openvibe.community/release.json`); 8 running animation(s), 8 infinite: `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`, `ovmDot on circle`
++ - error during navigation: network: Failed to load resource: the server responded with a status of 503 ()
++
++**https://openvibe.coupons**: pass (2 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/__ovcheck-404`: pass
++- navigation `/` ↔ `/` ×5 (link): pass; growth lap 2→5: heap +2 KB, nodes 0, listeners +2, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 23% (script 2 ms, style 138 ms, layout 155 ms), 2 request(s), 0.2 KB (`https://openvibe.network/shared/release-watch.js`, `https://openvibe.coupons/release.json`); 19 running animation(s), 19 infinite: `drift on i`, `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`
++
++**https://openvibe.deals**: pass (2 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/__ovcheck-404`: pass
++- navigation `/` ↔ `/` ×5 (link): pass; growth lap 2→5: heap 0 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 22.5% (script 2 ms, style 126 ms, layout 157 ms), 2 request(s), 4.1 KB (`https://openvibe.network/shared/release-watch.js`, `https://openvibe.deals/release.json`); 19 running animation(s), 19 infinite: `drift on i`, `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`
++
++**https://openvibe.games**: FAIL (2 route(s), Chrome/150.0.7843.0)
++- `/`: errors fail
++ - error: network: Failed to load resource: the server responded with a status of 404 () (https://openvibe.games/favicon.ico)
++- `/__ovcheck-404`: pass
++- navigation `/` ↔ `/` ×5 (link): pass; growth lap 2→5: heap +1 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 20.8% (script 2 ms, style 89 ms, layout 91 ms), 2 request(s), 0.3 KB (`https://openvibe.network/shared/release-watch.js`, `https://openvibe.games/release.json`); 9 running animation(s), 9 infinite: `ovmBounce on svg`, `ovmGlow on circle`, `ovmComet on circle`, `ovmDot on circle`
++
++**https://openvibe.host**: pass (2 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/__ovcheck-404`: pass
++- navigation `/` ↔ `/` ×5 (link): pass; growth lap 2→5: heap -1 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 21.1% (script 2 ms, style 135 ms, layout 146 ms), 2 request(s), 0.2 KB (`https://openvibe.network/shared/release-watch.js`, `https://openvibe.host/release.json`); 16 running animation(s), 16 infinite: `drift on i`, `ovmGlow on circle`, `ovmComet on circle`, `ovmDot on circle`
++
++**https://openvibe.live**: FAIL (8 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/content`: pass
++- `/chat`: canonical fail
++ - ignored ×3: signed-out session probe answered 401
++ - canonical: no canonical link
++- `/search?q=minecraft`: canonical fail
++ - ignored ×3: signed-out session probe answered 401
++ - canonical: no canonical link
++- `/vod/5627`: errors fail, axe fail
++ - error: network: Failed to load resource: the server responded with a status of 401 () (https://openvibe.live/api/coins/channel-balance?streamerId=327)
++ - ignored ×6: signed-out session probe answered 401
++ - axe critical: label (1 node) Form elements must have labels: `#vp-vol-slider`
++- `/clip/369`: errors fail, axe fail
++ - error: network: Failed to load resource: the server responded with a status of 404 () (https://openvibe.live/api/vods/5541/context)
++ - error: network: Failed to load resource: the server responded with a status of 401 () (https://openvibe.live/api/coins/channel-balance?streamerId=80)
++ - ignored ×6: signed-out session probe answered 401
++ - axe critical: label (1 node) Form elements must have labels: `#clp-vol-slider`
++- `/@JapaneseOldGuy`: pass
++- `/__ovcheck-404`: pass
++- navigation `/` ↔ `/content` ×5 (in-page+link): pass; growth lap 2→5: heap +111 KB, nodes 0, listeners +3, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 23.7% (script 44 ms, style 197 ms, layout 55 ms), 15 request(s), 158.8 KB; 44 running animation(s), 41 infinite: `opacity on div.hero-float-inner`, `transform on div.hero-float-inner`, `ovmFloat on svg`, `ovmGlow on circle`
++
++**https://openvibe.media**: FAIL (3 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/updates`: axe fail
++ - ignored ×3: signed-out session probe answered 401
++ - axe serious: color-contrast (1 node) Elements must meet minimum color contrast ratio thresholds: `.ov-shipped-chip:nth-child(1)`
++- `/__ovcheck-404`: pass
++- navigation `/` ↔ `/updates` ×5 (link): pass; growth lap 2→5: heap +1 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 21.8% (script 2 ms, style 57 ms, layout 77 ms), 2 request(s), 4.2 KB (`https://openvibe.media/shared/release-watch.js`, `https://openvibe.media/release.json`); 8 running animation(s), 8 infinite: `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`, `ovmDot on circle`
++
++**https://openvibe.network**: FAIL (4 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/updates`: axe fail
++ - ignored ×3: Cloudflare Web Analytics beacon, injected at the edge, blocked by the site CSP
++ - axe serious: color-contrast (1 node) Elements must meet minimum color contrast ratio thresholds: `.ov-shipped-chip:nth-child(1)`
++- `/login`: pass
++- `/__ovcheck-404`: pass
++- navigation `/` ↔ `/updates` ×5 (link): pass; growth lap 2→5: heap 0 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 25.7% (script 2 ms, style 105 ms, layout 138 ms), 2 request(s), 4.2 KB (`https://openvibe.network/shared/release-watch.js`, `https://openvibe.network/release.json`); 19 running animation(s), 19 infinite: `orb-drift on div.orb`, `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`
++
++**https://openvibe.news**: pass (2 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/__ovcheck-404`: pass
++- navigation `/` ↔ `/` ×5 (link): pass; growth lap 2→5: heap 0 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 19.5% (script 2 ms, style 104 ms, layout 139 ms), 2 request(s), 0.2 KB (`https://openvibe.network/shared/release-watch.js`, `https://openvibe.news/release.json`); 19 running animation(s), 19 infinite: `drift on i`, `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`
++
++**https://openre.stream**: pass (2 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/__ovcheck-404`: pass
++- navigation `/` ↔ `/` ×5 (link): pass; growth lap 2→5: heap 0 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 23.3% (script 2 ms, style 132 ms, layout 172 ms), 2 request(s), 0.2 KB (`https://openvibe.network/shared/release-watch.js`, `https://openre.stream/release.json`); 19 running animation(s), 19 infinite: `drift on i`, `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`
++
++**https://openvibe.reviews**: pass (2 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/__ovcheck-404`: pass
++- navigation `/` ↔ `/` ×5 (link): pass; growth lap 2→5: heap -1 KB, nodes 0, listeners -2, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 22.4% (script 3 ms, style 133 ms, layout 149 ms), 2 request(s), 0.2 KB (`https://openvibe.network/shared/release-watch.js`, `https://openvibe.reviews/release.json`); 19 running animation(s), 19 infinite: `drift on i`, `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`
++
++**https://search.openvibe.network**: FAIL (2 route(s), Chrome/150.0.7843.0)
++- `/`: canonical fail
++ - ignored ×3: Cloudflare Web Analytics beacon, injected at the edge, blocked by the site CSP
++ - canonical: no canonical link
++- `/__ovcheck-404`: pass (application/problem+json: only the status is checked)
++- navigation `/` ↔ `/` ×5 (link): pass; growth lap 2→5: heap 0 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 12.2% (script 2 ms, style 89 ms, layout 126 ms), 2 request(s), 4.2 KB (`https://search.openvibe.network/shared/release-watch.js`, `https://search.openvibe.network/release.json`); 8 running animation(s), 8 infinite: `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`, `ovmDot on circle`
++
++**https://sources.openvibe.network**: pass (2 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/__ovcheck-404`: pass (application/problem+json: only the status is checked)
++- navigation `/` ↔ `/` ×5 (load): pass; growth lap 2→5: heap +2 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 0% (script 0 ms, style 0 ms, layout 0 ms), 0 request(s), 0 KB
++
++**https://openvibe.tips**: pass (2 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/__ovcheck-404`: pass
++- navigation `/` ↔ `/` ×5 (link): pass; growth lap 2→5: heap 0 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 22.5% (script 2 ms, style 130 ms, layout 145 ms), 2 request(s), 0.2 KB (`https://openvibe.network/shared/release-watch.js`, `https://openvibe.tips/release.json`); 19 running animation(s), 19 infinite: `drift on i`, `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`
++
++**https://openvibe.tools**: FAIL (8 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/developers`: overflow fail
++ - overflow at 768px: page 858px wide; code (right 858px), code (right 762px)
++- `/updates`: axe fail
++ - axe serious: color-contrast (1 node) Elements must meet minimum color contrast ratio thresholds: `.ov-shipped-chip:nth-child(1)`
++- `/tool/yaml`: axe fail
++ - axe serious: color-contrast (2 nodes) Elements must meet minimum color contrast ratio thresholds: `.cta`, `.tag`
++- `/all-tools`: pass
++- `/network-tools`: axe fail
++ - axe serious: color-contrast (1 node) Elements must meet minimum color contrast ratio thresholds: `.cta`
++- `/developer-tools`: axe fail
++ - axe serious: color-contrast (1 node) Elements must meet minimum color contrast ratio thresholds: `.cta`
++- `/__ovcheck-404`: pass
++- navigation `/` ↔ `/developers` ×5 (link): pass; growth lap 2→5: heap 0 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 28.7% (script 2 ms, style 64 ms, layout 88 ms), 2 request(s), 4.2 KB (`https://openvibe.tools/shared/release-watch.js`, `https://openvibe.tools/release.json`); 8 running animation(s), 8 infinite: `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`, `ovmDot on circle`
++
++**https://openvibe.trade**: pass (2 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/__ovcheck-404`: pass
++- navigation `/` ↔ `/` ×5 (link): pass; growth lap 2→5: heap +1 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 21.2% (script 2 ms, style 121 ms, layout 142 ms), 2 request(s), 0.2 KB (`https://openvibe.network/shared/release-watch.js`, `https://openvibe.trade/release.json`); 19 running animation(s), 19 infinite: `drift on i`, `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`
++
++**https://openvibe.vip**: pass (2 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/__ovcheck-404`: pass
++- navigation `/` ↔ `/` ×5 (link): pass; growth lap 2→5: heap +2 KB, nodes 0, listeners +2, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 21.4% (script 2 ms, style 121 ms, layout 171 ms), 2 request(s), 0.2 KB (`https://openvibe.network/shared/release-watch.js`, `https://openvibe.vip/release.json`); 19 running animation(s), 19 infinite: `drift on i`, `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`
++
++**https://openvibe.wiki**: FAIL (4 route(s), Chrome/150.0.7843.0)
++- `/`: pass
++- `/updates`: axe fail
++ - ignored ×3: Cloudflare Web Analytics beacon, injected at the edge, blocked by the site CSP
++ - ignored ×3: signed-out session probe answered 401
++ - axe serious: color-contrast (1 node) Elements must meet minimum color contrast ratio thresholds: `.ov-shipped-chip[type="button"]:nth-child(1)`
++- `/s/openvibe`: pass
++- `/__ovcheck-404`: pass
++- navigation `/` ↔ `/updates` ×5 (link): pass; growth lap 2→5: heap 0 KB, nodes 0, listeners 0, documents 0, intervals 0, timeouts 0, sockets 0
++ - idle 5s after settle: CPU 13% (script 1 ms, style 70 ms, layout 87 ms), 2 request(s), 4 KB (`https://openvibe.wiki/shared/release-watch.js`, `https://openvibe.wiki/release.json`); 8 running animation(s), 8 infinite: `ovmFloat on svg`, `ovmGlow on circle`, `ovmComet on circle`, `ovmDot on circle`
++ - error during navigation: network: Failed to load resource: the server responded with a status of 503 ()
++
++</details>
ddiff --git a/lib/browser-check-hook.js b/lib/browser-check-hook.js
nnew file mode 100644
iindex 0000000..7d2a068
---- /dev/null
++++ b/lib/browser-check-hook.js
@@@ -0,0 +1,32 @@
++'use strict';
++/**
++ * `ovhost deploy <service> --browser-check`: after a deploy that went through, the service's public site is
++ * checked in a real browser by scripts/browser-check.js (roadmap WS-Q task 3). Report only: the result is
++ * printed and never changes the deploy's exit code or its release record. It needs Chrome on the machine that
++ * runs ovhost; without it the check says it did not run.
++ */
++const { spawn } = require('child_process');
++const path = require('path');
++
++const SCRIPT = path.join(__dirname, '..', 'scripts', 'browser-check.js');
++
++/** Answers { code, lines, error }: code 0 pass, 1 problems found, 2 (or a signal) the check did not run. */
++function runBrowserCheck(service, { timeoutMs = 15 * 60 * 1000, script = SCRIPT, args = [] } = {}) {
++ return new Promise((resolve) => {
++ const child = spawn(process.execPath, [script, '--sites', service, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
++ let out = '', err = '';
++ child.stdout.on('data', (c) => { out += c; });
++ child.stderr.on('data', (c) => { err += c; });
++ const timer = setTimeout(() => { err += `\ntimed out after ${Math.round(timeoutMs / 1000)}s`; child.kill('SIGKILL'); }, timeoutMs);
++ child.on('error', (e) => { clearTimeout(timer); resolve({ code: 2, lines: [], error: e.message }); });
++ child.on('close', (code, signal) => {
++ clearTimeout(timer);
++ const lines = out.split('\n').map((l) => l.trim()).filter(Boolean);
++ const errLines = err.split('\n').map((l) => l.trim()).filter(Boolean);
++ const c = code == null ? 2 : code;
++ resolve({ code: c, lines, error: c >= 2 ? (errLines.find((l) => /Error|not found|timed out/.test(l)) || errLines[errLines.length - 1] || signal || `exit ${c}`) : null });
++ });
++ });
++}
++
++module.exports = { runBrowserCheck, SCRIPT };
ddiff --git a/lib/cli.js b/lib/cli.js
iindex 5953fad..ed002d4 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -7,6 +7,7 @@ const lib = require('./index');
const { InventoryError } = require('./inventory');
const { LockedError } = require('./lock');
const { SocketGuardError } = require('./systemd');
++const { runBrowserCheck } = require('./browser-check-hook');
const USAGE = `ovhost — OpenVibe.Host operator plane (Stage A)
@@@ -23,6 +24,8 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
plan <service> [--to <sha>] [--no-fetch]
what a deploy would do (fetches the remote; changes nothing else)
deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>]
++ [--browser-check] then check the service's public site in headless Chrome
++ (scripts/browser-check.js; report only, never changes the exit code)
rollback <service> [--to <sha>] [--wait-idle] [--force]
releases <service> [--limit <n>] the release log
certs [--warn-days <n>] certificates referenced by enabled nginx vhosts, with expiry
@@@ -106,7 +109,7 @@ function bytes(n) {
return `${i ? v.toFixed(1) : v} ${u[i]}`;
}
--async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => process.stdout.write(`${s}\n`), env = process.env, s3 = lib.offsite.createClient } = {}) {
++async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => process.stdout.write(`${s}\n`), env = process.env, s3 = lib.offsite.createClient, browserCheck = runBrowserCheck } = {}) {
let parsed;
try { parsed = parseArgs(argv); } catch (err) { out(err.message); out(USAGE); return 1; }
const { pos, opts } = parsed;
@@@ -176,6 +179,13 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
const r = cmd === 'deploy' ? await lib.deploy(ctx, id, o) : await lib.rollback(ctx, id, o);
if (json) emit(r.record);
else if (r.record.result !== 'unchanged') out(`[ovhost] result: ${r.record.result} (release ${r.record.id})`);
++ if (cmd === 'deploy' && opts['browser-check'] && !json && r.exitCode === 0 && r.record.result === 'deployed') {
++ log(`browser check of ${id}'s public site (report only)…`);
++ let b;
++ try { b = await browserCheck(id); } catch (e) { b = { code: 2, lines: [], error: e.message }; }
++ for (const line of b.lines) log(` ${line}`);
++ log(`browser check ${b.code === 0 ? 'passed' : b.code === 1 ? 'found problems (see above)' : `did not run: ${b.error}`}; the deploy stands`);
++ }
return r.exitCode;
}
case 'status': {
ddiff --git a/scripts/browser-check.js b/scripts/browser-check.js
nnew file mode 100644
iindex 0000000..f994f6e
---- /dev/null
++++ b/scripts/browser-check.js
@@@ -0,0 +1,272 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Browser and no-JS check of every public OpenVibe product (roadmap WS-Q task 3; WS-T tasks 3 and 4), with
++ * openvibe-shared/browser-harness: per route × width (390, 768, 1280) status, console errors, horizontal
++ * overflow and duplicate scripts; per route without JavaScript visible text, canonical and JSON-LD against the
++ * visible text; axe-core (serious/critical fail); per site a repeated-navigation growth check and idle activity.
++ *
++ * node scripts/browser-check.js [--sites live,https://openvibe.wiki] [--routes-file routes.json] [--max-routes 7]
++ * [--widths 390,768,1280] [--no-axe] [--no-nav] [--laps 5] [--strict-errors]
++ * [--json] [--out docs/browser-check.md] [--remote <ssh-host>] [--harness <browser-harness.js>]
++ *
++ * Sites: --sites takes registry ids or origins; without it, every service in OpenVibe.Network's registry with a
++ * public origin whose home page answers HTML (API roots that answer text/plain are left out). Routes per site:
++ * the known routes below, then one same-origin URL per section of the site's sitemap.xml (a sitemap index is
++ * followed one level), up to --max-routes, then /__ovcheck-404, which must answer 404. --routes-file is JSON,
++ * { "<id or origin>": ["/", "/p/x", { "path": "/gone", "status": 410 }] }, and replaces the derived list.
++ *
++ * Only GETs, one page at a time, one Chrome for the run (a fresh browser context per route, cache in memory).
++ * Known noise (KNOWN_ERRORS) is counted per label, not failed; --strict-errors fails it too.
++ * Output: a summary per site (stderr: progress); --json prints the reports; --out appends a dated run (a table
++ * and the details) to a Markdown file. Exit 0 when every site passes, 1 when one fails, 2 when the run broke.
++ *
++ * --remote <ssh-host> runs the same script on another machine (roadmap hazard H7: workstation disk). The
++ * script and the harness are streamed over ssh into a temporary directory there, run with --json, and removed;
++ * the report is written here. The remote needs Node 22+ and Chrome (CHROME_BIN or /usr/bin/google-chrome),
++ * and reaches the sites itself. OVHOST_SSH overrides the ssh command (e.g. "ssh -p 2222").
++ *
++ * The harness comes from --harness, else openvibe-shared/browser-harness (Shared 1.16.0 and later), else a
++ * sibling OpenVibe.Shared checkout. Needs Chrome and Node 22. (--routes-json '<json>' is --routes-file inline;
++ * --remote uses it to carry the routes file.)
++ */
++const { spawn } = require('child_process');
++const fs = require('fs');
++const path = require('path');
++
++const NETWORK = process.env.OV_NETWORK_ORIGIN || 'https://openvibe.network';
++const NOT_FOUND_PROBE = '/__ovcheck-404';
++// Routes worth checking on every run, beyond what the sitemap samples (paths that answered 200 on 2026-09-25).
++const KNOWN_ROUTES = {
++ live: ['/', '/content', '/chat', '/search?q=minecraft'],
++ network: ['/', '/updates', '/login'],
++ tools: ['/', '/developers', '/updates'],
++ community: ['/', '/pastes', '/pulse'],
++ media: ['/', '/updates'],
++ chat: ['/', '/rooms', '/updates'],
++ wiki: ['/', '/updates'],
++ blog: ['/', '/updates'],
++ // Billing's only public page (its root is the API).
++ billing: { only: ['/policy'] },
++};
++const KNOWN_ERRORS = [
++ // Cloudflare's Web Analytics automatic setup injects its beacon into HTML at the edge; the sites' CSP blocks it.
++ { label: 'Cloudflare Web Analytics beacon, injected at the edge, blocked by the site CSP', text: /static\.cloudflareinsights\.com/ },
++ { label: 'Cloudflare Web Analytics beacon, injected at the edge, blocked by the site CSP', url: /static\.cloudflareinsights\.com/ },
++ // A signed-out visitor's page asks who is signed in and gets 401.
++ { label: 'signed-out session probe answered 401', text: /status of 401\b/, url: /\/(api\/)?(auth\/(me|refresh|session|status|whoami)|me|session)(\?|$)/ },
++];
++
++const argv = process.argv.slice(2);
++const has = (n) => argv.includes(`--${n}`);
++const arg = (n) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : null; };
++const errLog = (s) => process.stderr.write(`${s}\n`);
++
++/** The harness module and its file (the file is what --remote ships). */
++function loadHarness() {
++ const tries = arg('harness') ? [path.resolve(arg('harness'))] : ['openvibe-shared/browser-harness', path.join(__dirname, '..', '..', 'OpenVibe.Shared', 'browser-harness.js')];
++ for (const t of tries) {
++ let file;
++ try { file = require.resolve(t); } catch { continue; }
++ return { h: require(file), file };
++ }
++ throw new Error(`browser harness not found (tried ${tries.join(', ')}); pass --harness <path to openvibe-shared/browser-harness.js>`);
++}
++
++async function get(url, accept = '*/*') {
++ const r = await fetch(url, { headers: { accept, 'user-agent': 'OpenVibe-BrowserCheck/1 (+https://openvibe.host)' }, redirect: 'follow', signal: AbortSignal.timeout(15000) });
++ return { status: r.status, type: r.headers.get('content-type') || '', body: await r.text() };
++}
++
++async function registry() {
++ const r = await get(`${NETWORK}/api/v1/registry/services`, 'application/json');
++ const body = JSON.parse(r.body);
++ const out = [];
++ for (const s of Array.isArray(body) ? body : body.services || []) {
++ const o = s.publicOrigin || s.public_origin;
++ if (o && /^https?:\/\//.test(o)) out.push({ id: s.id || s.name, origin: new URL(o).origin });
++ }
++ return out;
++}
++
++async function resolveSites(reg) {
++ const list = arg('sites') ? arg('sites').split(',').map((x) => x.trim()).filter(Boolean) : null;
++ if (list) {
++ return list.map((x) => {
++ if (/^https?:\/\//.test(x)) { const origin = new URL(x).origin; return { id: (reg.find((s) => s.origin === origin) || {}).id || new URL(x).hostname, origin }; }
++ const s = reg.find((r) => r.id === x);
++ if (!s) throw new Error(`--sites: ${x} is not a registry id with a public origin (${reg.map((r) => r.id).join(', ')})`);
++ return s;
++ });
++ }
++ const out = [];
++ for (const s of reg) {
++ if (KNOWN_ROUTES[s.id] && KNOWN_ROUTES[s.id].only) { out.push(s); continue; }
++ try {
++ const r = await get(`${s.origin}/`, 'text/html');
++ if (/text\/html/i.test(r.type)) out.push(s); else errLog(`skip ${s.origin}: its root answers ${r.type || r.status}, not HTML`);
++ } catch (e) { out.push(s); errLog(`${s.origin}: home did not answer (${e.message}); checked anyway`); }
++ }
++ return out;
++}
++
++async function sitemapSample(h, origin, cap) {
++ if (cap <= 0) return [];
++ let xml;
++ try { const r = await get(`${origin}/sitemap.xml`, 'application/xml'); if (r.status !== 200) return []; xml = r.body; } catch { return []; }
++ if (/<sitemapindex/i.test(xml)) {
++ const children = [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]).filter((u) => { try { return new URL(u).origin === origin; } catch { return false; } }).slice(0, 3);
++ const parts = [];
++ for (const u of children) { try { const r = await get(u, 'application/xml'); if (r.status === 200) parts.push(r.body); } catch { /* skip */ } }
++ xml = parts.join('\n');
++ }
++ return h.sitemapPaths(xml, origin, cap);
++}
++
++async function routesFor(h, site, routesFile, max) {
++ if (routesFile) {
++ const own = routesFile[site.id] || routesFile[site.origin];
++ if (own) return own;
++ }
++ const known = KNOWN_ROUTES[site.id];
++ if (known && known.only) return known.only;
++ const routes = [...(known || ['/'])];
++ // One sitemap URL per section the known routes do not already cover (/@… channels count as one section).
++ const section = (p) => { const s = String(p).split(/[/?#]/)[1] || ''; return s.startsWith('@') ? '@' : s; };
++ const covered = new Set(routes.map(section));
++ // Detail pages first (/vod/1, /@name, /p/x): list pages are usually among the known routes already.
++ const detail = (p) => (section(p) === '@' || String(p).split('?')[0].split('/').filter(Boolean).length > 1 ? 0 : 1);
++ const sampled = (await sitemapSample(h, site.origin, 50)).map((p, i) => [p, i]).sort((a, b) => detail(a[0]) - detail(b[0]) || a[1] - b[1]).map(([p]) => p);
++ for (const p of sampled) {
++ if (routes.length >= max) break;
++ if (covered.has(section(p))) continue;
++ covered.add(section(p));
++ routes.push(p);
++ }
++ routes.push({ path: NOT_FOUND_PROBE, status: 404 });
++ return routes;
++}
++
++// ─── Report ───────────────────────────────────────────────────────────────────────────────────────
++
++const COLUMNS = ['status', 'errors', 'overflow', 'scripts', 'nojs', 'canonical', 'jsonld', 'axe', 'navigation'];
++function cell(c) {
++ if (!c) return '-';
++ const used = c.pass + c.warn + c.fail;
++ if (!used) return '-';
++ if (c.fail) return `**${c.fail}✗**${c.warn ? ` ${c.warn}!` : ''}`;
++ return c.warn ? `${c.warn}!` : '✓';
++}
++function growthCell(n) {
++ if (!n || n.skipped) return '-';
++ if (n.error) return 'error';
++ const d = n.growth.deltas;
++ const f = (v) => (v > 0 ? `+${v}` : String(v));
++ return `${f(d.heapKB)} KB · ${f(d.nodes)} nodes · ${f(d.listeners)} lst${n.growth.over.length ? ` **(${n.growth.over.map((o) => o.name).join(', ')})**` : ''}`;
++}
++function idleCell(n) {
++ if (!n || !n.idle) return '-';
++ const a = n.idle.animations;
++ return `${n.idle.cpuPct}% · ${n.idle.requests} req${a && a.infinite ? ` · ${a.infinite} anim` : ''}`;
++}
++function ignoredOf(rep) {
++ const out = {};
++ for (const src of [...rep.routes, rep.navigation || {}]) for (const [k, v] of Object.entries(src.ignored || {})) out[k] = (out[k] || 0) + v;
++ return out;
++}
++
++function markdown(h, reports, meta) {
++ const L = [];
++ L.push(`### Run ${meta.at} (${meta.runner}; ${meta.chrome || 'Chrome'})`, '');
++ L.push(`${reports.filter((r) => r.ok).length}/${reports.length} sites pass. Widths ${meta.widths.join(', ')}; axe ${meta.axe}; navigation ${meta.nav}. Cells: ✓ all pass, **n✗** routes failing, n! warnings, - not run.`, '');
++ L.push('| Site | Routes | Status | Errors | Overflow | Scripts | No-JS | Canonical | JSON-LD | axe | Nav | axe serious/critical (moderate) | Growth lap 2→last | Idle 5 s: CPU · requests · infinite animations |');
++ L.push('|---|---|---|---|---|---|---|---|---|---|---|---|---|---|');
++ for (const r of reports) {
++ const s = r.summary || { checks: {}, axe: {} };
++ const host = r.base.replace(/^https?:\/\//, '');
++ L.push(`| ${host} | ${r.routes.length} | ${COLUMNS.map((c) => cell(s.checks[c])).join(' | ')} | ${s.axe ? `${s.axe.serious}/${s.axe.critical} (${s.axe.moderate})` : '-'} | ${growthCell(r.navigation)} | ${idleCell(r.navigation)} |`);
++ }
++ const ignored = {};
++ for (const r of reports) for (const [k, v] of Object.entries(ignoredOf(r))) { ignored[k] = ignored[k] || { n: 0, sites: new Set() }; ignored[k].n += v; ignored[k].sites.add(r.base.replace(/^https?:\/\//, '')); }
++ if (Object.keys(ignored).length) {
++ L.push('', 'Known errors, counted and not failed (`--strict-errors` fails them):');
++ for (const [k, v] of Object.entries(ignored)) L.push(`- ${k}: ${v.n}× on ${[...v.sites].join(', ')}`);
++ }
++ L.push('', '<details><summary>Details: routes and findings per site</summary>', '');
++ for (const r of reports) L.push(h.format(r, { markdown: true }), '');
++ L.push('</details>', '');
++ return L.join('\n');
++}
++
++// ─── Remote ───────────────────────────────────────────────────────────────────────────────────────
++
++function runRemote(host, harnessFile) {
++ return new Promise((resolve, reject) => {
++ const passArgs = [];
++ for (let i = 0; i < argv.length; i++) {
++ const a = argv[i];
++ if (['--remote', '--out', '--harness'].includes(a)) { i++; continue; }
++ if (a === '--json') continue;
++ if (a === '--routes-file') { passArgs.push('--routes-json', JSON.stringify(JSON.parse(fs.readFileSync(argv[++i], 'utf8')))); continue; }
++ passArgs.push(a);
++ }
++ const q = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
++ const remoteCmd = `d=$(mktemp -d) && cd "$d" && tar xzf - && node browser-check.js --json --harness ./${q(path.basename(harnessFile))} ${passArgs.map(q).join(' ')}; rc=$?; cd / && rm -rf "$d"; exit $rc`;
++ const ssh = (process.env.OVHOST_SSH || 'ssh -o BatchMode=yes').split(/\s+/).filter(Boolean);
++ const child = spawn(ssh[0], [...ssh.slice(1), host, remoteCmd], { stdio: ['pipe', 'pipe', 'inherit'] });
++ const tar = spawn('tar', ['czf', '-', '-C', path.dirname(harnessFile), path.basename(harnessFile), '-C', __dirname, path.basename(__filename)], { stdio: ['ignore', 'pipe', 'inherit'] });
++ tar.stdout.pipe(child.stdin);
++ let out = '';
++ child.stdout.on('data', (c) => { out += c; });
++ child.on('error', reject);
++ child.on('close', (code) => {
++ try { resolve({ code, reports: JSON.parse(out) }); } catch { reject(new Error(`remote run on ${host} exited ${code} without a JSON report${out ? `: ${out.slice(0, 300)}` : ''}`)); }
++ });
++ });
++}
++
++// ─── Main ─────────────────────────────────────────────────────────────────────────────────────────
++
++(async () => {
++ const { h, file: harnessFile } = loadHarness();
++ const widths = arg('widths') ? arg('widths').split(',').map(Number) : h.WIDTHS;
++ const meta = { at: new Date().toISOString(), runner: 'local', widths, axe: has('no-axe') ? 'off' : `axe-core ${h.AXE.version}, WCAG 2.1 A/AA`, nav: has('no-nav') ? 'off' : `${Number(arg('laps') || 5)} laps` };
++ let reports, code;
++ if (arg('remote')) {
++ meta.runner = `remote ${arg('remote')}`;
++ ({ reports, code } = await runRemote(arg('remote'), harnessFile));
++ } else {
++ const routesFile = arg('routes-file') ? JSON.parse(fs.readFileSync(arg('routes-file'), 'utf8')) : arg('routes-json') ? JSON.parse(arg('routes-json')) : null;
++ const max = Number(arg('max-routes') || 7);
++ const reg = await registry();
++ const sites = await resolveSites(reg);
++ reports = [];
++ let browser = null;
++ try {
++ for (const site of sites) {
++ if (!browser || browser.closed) browser = await h.launch({});
++ const routes = await routesFor(h, site, routesFile, max);
++ errLog(`${site.origin}: ${routes.map((r) => (typeof r === 'string' ? r : r.path)).join(' ')}`);
++ const rep = await h.run({
++ base: site.origin, routes, widths, browser, log: (s) => errLog(` ${s}`),
++ checks: { axe: !has('no-axe'), navigation: !has('no-nav') }, navigation: { laps: Number(arg('laps') || 5) },
++ ignoreErrors: has('strict-errors') ? [] : KNOWN_ERRORS,
++ });
++ rep.id = site.id;
++ reports.push(rep);
++ }
++ } finally { if (browser) browser.close(); }
++ code = reports.every((r) => r.ok) ? 0 : 1;
++ }
++ meta.chrome = (reports.find((r) => r.chrome) || {}).chrome;
++ if (has('json')) process.stdout.write(`${JSON.stringify(reports, null, 2)}\n`);
++ else {
++ for (const r of reports) {
++ const s = r.summary;
++ const bad = Object.entries(s.checks).filter(([, c]) => c.fail).map(([k, c]) => `${k} ${c.fail}`);
++ console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.base} ${r.routes.length} route(s)${bad.length ? `; failing: ${bad.join(', ')}` : ''}; axe serious ${s.axe.serious}, critical ${s.axe.critical}, moderate ${s.axe.moderate}; nav ${growthCell(r.navigation).replace(/\*\*/g, '')}; idle ${idleCell(r.navigation)}`);
++ }
++ }
++ if (arg('out')) fs.appendFileSync(arg('out'), `\n${markdown(h, reports, meta)}`);
++ process.exit(code === 0 && reports.every((r) => r.ok) ? 0 : 1);
++})().catch((err) => { console.error(err.stack || err.message); process.exit(2); });
ddiff --git a/test/browser-check.test.js b/test/browser-check.test.js
nnew file mode 100644
iindex 0000000..b084781
---- /dev/null
++++ b/test/browser-check.test.js
@@@ -0,0 +1,185 @@
++'use strict';
++// scripts/browser-check.js (WS-Q task 3) with a stub harness (no Chrome) against a local registry and local
++// "sites": which sites and routes it picks, the JSON and Markdown reports, exit codes, --remote through a
++// stand-in ssh, and `ovhost deploy --browser-check`, which reports and never changes the deploy's result.
++const assert = require('assert');
++const { spawn } = require('child_process');
++const fs = require('fs');
++const http = require('http');
++const os = require('os');
++const path = require('path');
++const { scenario, push, test, runTests } = require('./helpers');
++const { main } = require('../lib/cli');
++const { runBrowserCheck } = require('../lib/browser-check-hook');
++
++const ROOT = path.join(__dirname, '..');
++const SCRIPT = path.join(ROOT, 'scripts', 'browser-check.js');
++const STUB = path.join(__dirname, 'fixtures', 'browser-harness-stub.js');
++
++// One local server answers for every "site" (<name>.localhost, which resolves to loopback) and the registry.
++function serve() {
++ const server = http.createServer((req, res) => {
++ const port = server.address().port;
++ const o = `http://127.0.0.1:${port}`;
++ const send = (type, body, status = 200) => { res.writeHead(status, { 'content-type': type }); res.end(body); };
++ if (req.url === '/api/v1/registry/services') {
++ return send('application/json', JSON.stringify({ services: [
++ { id: 'good', publicOrigin: `http://good.localhost:${port}` },
++ { id: 'bad', publicOrigin: `http://bad.localhost:${port}` },
++ { id: 'api', publicOrigin: `http://api.localhost:${port}` },
++ { id: 'billing', publicOrigin: `http://billing.localhost:${port}` },
++ { id: 'contracts' },
++ ] }));
++ }
++ const host = String(req.headers.host).split('.')[0];
++ if (host === 'api') return send('text/plain', 'ok');
++ if (req.url === '/sitemap.xml' && host === 'good') {
++ return send('application/xml', `<sitemapindex><sitemap><loc>http://good.localhost:${port}/sm-1.xml</loc></sitemap></sitemapindex>`);
++ }
++ if (req.url === '/sm-1.xml') {
++ return send('application/xml', `<urlset><url><loc>http://good.localhost:${port}/</loc></url><url><loc>http://good.localhost:${port}/p/one</loc></url>
++ <url><loc>http://good.localhost:${port}/p/three</loc></url><url><loc>http://good.localhost:${port}/list</loc></url><url><loc>http://good.localhost:${port}/s/two</loc></url><url><loc>${o}/elsewhere</loc></url></urlset>`);
++ }
++ if (req.url === '/sitemap.xml') return send('text/html', 'no', 404);
++ return send('text/html', '<!doctype html><title>x</title>');
++ });
++ return new Promise((ok) => server.listen(0, '127.0.0.1', () => ok(server)));
++}
++
++function runScript(args, env = {}) {
++ return new Promise((resolve) => {
++ const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: ROOT, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
++ let out = '', err = '';
++ child.stdout.on('data', (c) => { out += c; });
++ child.stderr.on('data', (c) => { err += c; });
++ child.on('close', (code) => resolve({ code, out, err }));
++ });
++}
++
++const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ovhost-bc-'));
++const calls = (file) => fs.readFileSync(file, 'utf8').trim().split('\n').map((l) => JSON.parse(l));
++
++runTests([
++ test('every public HTML site from the registry, known + sitemap routes + a 404 probe; JSON, Markdown and exit 1 on a failing site', async () => {
++ const server = await serve();
++ const dir = tmp();
++ try {
++ const env = { OV_NETWORK_ORIGIN: `http://127.0.0.1:${server.address().port}`, STUB_CALLS: path.join(dir, 'calls.jsonl') };
++ const out = path.join(dir, 'report.md');
++ fs.writeFileSync(out, '# Browser check\n');
++ const r = await runScript(['--harness', STUB, '--json', '--out', out, '--max-routes', '3'], env);
++ assert.strictEqual(r.code, 1, r.err);
++ const reports = JSON.parse(r.out);
++ const port = server.address().port;
++ assert.deepStrictEqual(reports.map((x) => x.id), ['good', 'bad', 'billing'], 'the text/plain API root and the service without an origin are left out');
++ assert.match(r.err, /skip http:\/\/api\.localhost:\d+: its root answers text\/plain/);
++ const c = calls(env.STUB_CALLS);
++ assert.deepStrictEqual(c[0].routes, ['/', '/p/one', '/s/two', { path: '/__ovcheck-404', status: 404 }], 'sitemap index followed, detail pages first, one per section, capped, other origins dropped');
++ assert.deepStrictEqual(c[1].routes, ['/', { path: '/__ovcheck-404', status: 404 }], 'no sitemap: home and the probe');
++ assert.deepStrictEqual(c[2].routes, ['/policy'], 'billing: its policy page only');
++ assert.strictEqual(c[0].base, `http://good.localhost:${port}`);
++ assert.deepStrictEqual(c[0].widths, [390, 768, 1280]);
++ assert.deepStrictEqual(c[0].checks, { axe: true, navigation: true });
++ assert.strictEqual(c[0].laps, 5);
++ assert.ok(c[0].ignore.includes('signed-out session probe answered 401'));
++ const md = fs.readFileSync(out, 'utf8');
++ assert.ok(md.startsWith('# Browser check\n\n### Run '), 'appended under the existing heading');
++ assert.match(md, /2\/3 sites pass/);
++ assert.match(md, /\| bad\.localhost:\d+ \| 2 \| ✓ \| ✓ \| \*\*1✗\*\* \| ✓ \| ✓ \| ✓ \| ✓ \| ✓ \| ✓ \| 2\/0 \(1\) \| \+12 KB · 0 nodes · \+1 lst \| 0\.5% · 1 req \|/);
++ assert.match(md, /signed-out session probe answered 401: 10× on good\.localhost:\d+, billing\.localhost:\d+/);
++ } finally { server.close(); fs.rmSync(dir, { recursive: true, force: true }); }
++ }),
++ test('--sites (ids and origins), --routes-file, --widths, --no-axe, --no-nav, --laps, --strict-errors; exit 0 when all pass', async () => {
++ const server = await serve();
++ const dir = tmp();
++ try {
++ const port = server.address().port;
++ const env = { OV_NETWORK_ORIGIN: `http://127.0.0.1:${port}`, STUB_CALLS: path.join(dir, 'calls.jsonl') };
++ const routesFile = path.join(dir, 'routes.json');
++ fs.writeFileSync(routesFile, JSON.stringify({ good: ['/a', { path: '/gone', status: 410 }] }));
++ const r = await runScript(['--harness', STUB, '--sites', `good,http://billing.localhost:${port}/x`, '--routes-file', routesFile, '--widths', '320,1440', '--no-axe', '--no-nav', '--laps', '3', '--strict-errors'], env);
++ assert.strictEqual(r.code, 0, r.err + r.out);
++ assert.match(r.out, /^ok {3}http:\/\/good\.localhost:\d+ {2}2 route\(s\); axe serious 0, critical 0, moderate 1/m);
++ const c = calls(env.STUB_CALLS);
++ assert.deepStrictEqual(c.map((x) => x.routes), [['/a', { path: '/gone', status: 410 }], ['/policy']]);
++ assert.deepStrictEqual(c[0].widths, [320, 1440]);
++ assert.deepStrictEqual(c[0].checks, { axe: false, navigation: false });
++ assert.strictEqual(c[0].laps, 3);
++ assert.deepStrictEqual(c[0].ignore, []);
++ const bad = await runScript(['--harness', STUB, '--sites', 'nope'], env);
++ assert.strictEqual(bad.code, 2);
++ assert.match(bad.err, /--sites: nope is not a registry id with a public origin/);
++ const missing = await runScript(['--harness', path.join(dir, 'none.js'), '--sites', 'good'], env);
++ assert.strictEqual(missing.code, 2, 'an explicit --harness that is missing is an error, not a fallback');
++ assert.match(missing.err, /browser harness not found \(tried .*none\.js\)/);
++ } finally { server.close(); fs.rmSync(dir, { recursive: true, force: true }); }
++ }),
++ test('--remote: the script and harness go over ssh into a temp dir, run with --json there, the report is written here', async () => {
++ const server = await serve();
++ const dir = tmp();
++ try {
++ const port = server.address().port;
++ // A stand-in for ssh: `fake-ssh <host> <command>` runs the command here in a shell, with stdin.
++ const fakeSsh = path.join(dir, 'fake-ssh');
++ fs.writeFileSync(fakeSsh, `#!/bin/sh\necho "$1" > ${JSON.stringify(path.join(dir, 'host'))}\nexec sh -c "$2"\n`, { mode: 0o755 });
++ const routesFile = path.join(dir, 'routes.json');
++ fs.writeFileSync(routesFile, JSON.stringify({ good: ['/r'] }));
++ const out = path.join(dir, 'report.md');
++ const env = { OV_NETWORK_ORIGIN: `http://127.0.0.1:${port}`, STUB_CALLS: path.join(dir, 'calls.jsonl'), OVHOST_SSH: fakeSsh, TMPDIR: dir };
++ const before = fs.readdirSync(dir).length;
++ const r = await runScript(['--harness', STUB, '--remote', 'runner@far', '--sites', 'good', '--routes-file', routesFile, '--out', out], env);
++ assert.strictEqual(r.code, 0, r.err + r.out);
++ assert.strictEqual(fs.readFileSync(path.join(dir, 'host'), 'utf8').trim(), 'runner@far');
++ assert.deepStrictEqual(calls(env.STUB_CALLS).map((x) => x.routes), [['/r']], 'the routes file travelled inline');
++ assert.match(r.out, /^ok {3}http:\/\/good\.localhost/m);
++ assert.match(fs.readFileSync(out, 'utf8'), /### Run .* \(remote runner@far; Chrome\/0-stub\)/);
++ // The remote temp dir is gone: only what this test made is left (calls, host, fake-ssh, routes, report).
++ assert.strictEqual(fs.readdirSync(dir).filter((f) => f.startsWith('tmp.')).length, 0);
++ assert.ok(fs.readdirSync(dir).length <= before + 3);
++ } finally { server.close(); fs.rmSync(dir, { recursive: true, force: true }); }
++ }),
++ test('ovhost deploy --browser-check: after a deploy, the site is checked and reported; the exit code and record stand', async () => {
++ const host = scenario();
++ push(host, 'live', { 'server/index.js': 'console.log(3);' }, 'server change');
++ const asked = [];
++ const lines = [];
++ const code = await main(['deploy', 'live', '--browser-check'], { exec: host.exec, out: (s) => lines.push(s), env: {},
++ browserCheck: async (id) => { asked.push(id); return { code: 1, lines: ['FAIL https://openvibe.live 5 route(s); failing: overflow 1'], error: null }; } });
++ assert.strictEqual(code, 0, lines.join('\n'));
++ assert.deepStrictEqual(asked, ['live']);
++ const text = lines.join('\n');
++ assert.match(text, /result: deployed/);
++ assert.match(text, /\[ovhost\] {3}FAIL https:\/\/openvibe\.live {2}5 route\(s\); failing: overflow 1/);
++ assert.match(text, /browser check found problems \(see above\); the deploy stands/);
++ // Not asked for, not deployed (unchanged), or --json: no check.
++ for (const argv of [['deploy', 'live'], ['deploy', 'live', '--browser-check'], ['deploy', 'live', '--browser-check', '--json']]) {
++ if (argv.length === 3 && !argv.includes('--json')) push(host, 'live', { 'server/index.js': `console.log(${Math.random()});` });
++ asked.length = 0;
++ await main(argv, { exec: host.exec, out: () => {}, env: {}, browserCheck: async (id) => { asked.push(id); return { code: 0, lines: [] }; } });
++ assert.deepStrictEqual(asked, argv.length === 3 && !argv.includes('--json') ? ['live'] : [], argv.join(' '));
++ }
++ const unchanged = [];
++ await main(['deploy', 'live', '--browser-check'], { exec: host.exec, out: (s) => unchanged.push(s), env: {}, browserCheck: async () => { throw new Error('must not run'); } });
++ assert.ok(!unchanged.join('\n').includes('browser check'), 'an unchanged deploy is not checked');
++ // A check that cannot run says so.
++ push(host, 'live', { 'server/index.js': 'console.log(4);' });
++ const broken = [];
++ const c2 = await main(['deploy', 'live', '--browser-check'], { exec: host.exec, out: (s) => broken.push(s), env: {}, browserCheck: async () => ({ code: 2, lines: [], error: 'Error: Chrome not found (set CHROME_BIN)' }) });
++ assert.strictEqual(c2, 0);
++ assert.match(broken.join('\n'), /browser check did not run: Error: Chrome not found \(set CHROME_BIN\); the deploy stands/);
++ }),
++ test('runBrowserCheck spawns the script for the service and reads its outcome', async () => {
++ const dir = tmp();
++ try {
++ const script = path.join(dir, 'check.js');
++ fs.writeFileSync(script, "const a = process.argv.slice(2); if (a[1] === 'down') { console.error('Error: Chrome not found (set CHROME_BIN)'); process.exit(2); } console.log(`ok site ${a.join(' ')}`); process.exit(a[1] === 'bad' ? 1 : 0);");
++ assert.deepStrictEqual(await runBrowserCheck('wiki', { script, args: ['--no-nav'] }), { code: 0, lines: ['ok site --sites wiki --no-nav'], error: null });
++ assert.strictEqual((await runBrowserCheck('bad', { script })).code, 1);
++ assert.deepStrictEqual(await runBrowserCheck('down', { script }), { code: 2, lines: [], error: 'Error: Chrome not found (set CHROME_BIN)' });
++ fs.writeFileSync(script, 'setTimeout(() => {}, 60000);');
++ const slow = await runBrowserCheck('x', { script, timeoutMs: 300 });
++ assert.strictEqual(slow.code, 2);
++ assert.match(slow.error, /timed out after 0s/);
++ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
++ }),
++]);
ddiff --git a/test/fixtures/browser-harness-stub.js b/test/fixtures/browser-harness-stub.js
nnew file mode 100644
iindex 0000000..eaa26ca
---- /dev/null
++++ b/test/fixtures/browser-harness-stub.js
@@@ -0,0 +1,28 @@
++'use strict';
++// Stand-in for openvibe-shared/browser-harness in test/browser-check.test.js: no Chrome. run() records what it
++// was asked (to $STUB_CALLS, one JSON line per call) and answers a canned report; a base whose host starts with
++// "bad" fails its overflow check.
++const fs = require('fs');
++
++const CHECKS = ['status', 'errors', 'overflow', 'scripts', 'nojs', 'canonical', 'jsonld', 'axe'];
++const tally = (v) => ({ pass: v === 'pass' ? 1 : 0, warn: v === 'warn' ? 1 : 0, fail: v === 'fail' ? 1 : 0, skip: v === 'skip' ? 1 : 0 });
++
++module.exports = {
++ WIDTHS: [390, 768, 1280],
++ AXE: { version: '0.0.0-stub' },
++ sitemapPaths: (xml, base, cap) => [...String(xml).matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => new URL(m[1]))
++ .filter((u) => u.origin === new URL(base).origin && u.pathname !== '/').map((u) => u.pathname).slice(0, cap),
++ launch: async () => ({ closed: false, close() { this.closed = true; } }),
++ format: (r) => `${r.base}: ${r.ok ? 'pass' : 'FAIL'}`,
++ run: async (o) => {
++ if (process.env.STUB_CALLS) {
++ fs.appendFileSync(process.env.STUB_CALLS, `${JSON.stringify({ base: o.base, routes: o.routes, widths: o.widths, checks: o.checks, laps: o.navigation.laps, ignore: (o.ignoreErrors || []).map((x) => x.label) })}\n`);
++ }
++ const bad = new URL(o.base).hostname.startsWith('bad');
++ const checks = Object.fromEntries(CHECKS.map((c) => [c, c === 'overflow' && bad ? 'fail' : 'pass']));
++ const routes = o.routes.map((r) => ({ path: typeof r === 'string' ? r : r.path, checks, ok: !bad, ...(bad ? {} : { ignored: { 'signed-out session probe answered 401': 2 } }) }));
++ const summary = { routes: routes.length, checks: Object.fromEntries(CHECKS.map((c) => [c, tally(checks[c])])), axe: { critical: 0, serious: bad ? 2 : 0, moderate: 1, minor: 0 }, ok: !bad };
++ summary.checks.navigation = tally('pass');
++ return { base: o.base, chrome: 'Chrome/0-stub', routes, navigation: { ok: true, growth: { deltas: { heapKB: 12, nodes: 0, listeners: 1 }, over: [] }, idle: { cpuPct: 0.5, requests: 1 } }, summary, ok: !bad };
++ },
++};