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)
Changes
@@@ -95,7 +95,7 @@ ovhost validate <service> [--manifest <file>] env NAMES, unit files, port, vhosovhost env-names <service> names declared in the checkout's .env.exampleovhost 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 sequence1. 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).nnew file mode 100644@@@ -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>nnew file mode 100644@@@ -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 };@@@ -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 logcerts [--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) => procconst 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': {nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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 }); }++ }),++]);nnew file mode 100644@@@ -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 };++ },++};
@@ -95,7 +95,7 @@ ovhost validate <service> [--manifest <file>] env NAMES, unit files, port, vhos
@@ -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 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 show [<service>...] the inventory as ovhost reads it (repo, owner, units, socket, workers, probes)
ovhost plan <service> [--to <sha>] [--no-fetch]
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 rollback <service> [--to <sha>] [--wait-idle] [--force]
ovhost releases <service> [--limit <n>]
ovhost releases <service> [--limit <n>]
ovhost certs [--warn-days <n>]
ovhost certs [--warn-days <n>]
@@ -111,6 +111,12 @@ ovhost drill <service> [--backup <dir>] [--keep] restore drill (root only)
@@ -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).
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
### 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).
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).
new file mode 100644
new file mode 100644
@@ -0,0 +1,409 @@
@@ -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>
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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 };
@@ -7,6 +7,7 @@ const lib = require('./index');
@@ -7,6 +7,7 @@ const lib = require('./index');
const { InventoryError } = require('./inventory');
const { InventoryError } = require('./inventory');
const { LockedError } = require('./lock');
const { LockedError } = require('./lock');
const { SocketGuardError } = require('./systemd');
const { SocketGuardError } = require('./systemd');
+const { runBrowserCheck } = require('./browser-check-hook');
const USAGE = `ovhost — OpenVibe.Host operator plane (Stage A)
const USAGE = `ovhost — OpenVibe.Host operator plane (Stage A)
@@ -23,6 +24,8 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
@@ -23,6 +24,8 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
plan <service> [--to <sha>] [--no-fetch]
plan <service> [--to <sha>] [--no-fetch]
what a deploy would do (fetches the remote; changes nothing else)
what a deploy would do (fetches the remote; changes nothing else)
deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>]
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]
rollback <service> [--to <sha>] [--wait-idle] [--force]
releases <service> [--limit <n>] the release log
releases <service> [--limit <n>] the release log
certs [--warn-days <n>] certificates referenced by enabled nginx vhosts, with expiry
certs [--warn-days <n>] certificates referenced by enabled nginx vhosts, with expiry
@@ -106,7 +109,7 @@ function bytes(n) {
@@ -106,7 +109,7 @@ function bytes(n) {
return `${i ? v.toFixed(1) : v} ${u[i]}`;
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;
let parsed;
try { parsed = parseArgs(argv); } catch (err) { out(err.message); out(USAGE); return 1; }
try { parsed = parseArgs(argv); } catch (err) { out(err.message); out(USAGE); return 1; }
const { pos, opts } = parsed;
const { pos, opts } = parsed;
@@ -176,6 +179,13 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -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);
const r = cmd === 'deploy' ? await lib.deploy(ctx, id, o) : await lib.rollback(ctx, id, o);
if (json) emit(r.record);
if (json) emit(r.record);
else if (r.record.result !== 'unchanged') out(`[ovhost] result: ${r.record.result} (release ${r.record.id})`);
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;
return r.exitCode;
}
}
case 'status': {
case 'status': {
new file mode 100644
new file mode 100644
@@ -0,0 +1,272 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,185 @@
@@ -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 }); }
+ }),
+]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -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 };
+ },
+};