openvibers/openvibe.host Commits

Alerts: a browser-check alert carries what failed, the full release and its links (#365)

OP OpenVibers committed 3 days ago · 17b2dc6000 · 11 files, +297 −20

* Alerts: a browser-check alert carries what failed, the full release and its links

* Axe help links: use the helpUrl axe itself gives (with application=axeAPI)

* Pin openvibe-contracts v0.399.0 (was v0.384.0)

Operator alert details shipped in openvibe-contracts v0.399.0; v0.393.0 was never tagged. STATUS.json and README name the new pin; lockfile regenerated with npm --package-lock-only. ov test test/alerts.test.js test/browsercheck-unit.test.js passes.

* README versions: openvibe-contracts v0.399.0

Changes

ddiff --git a/README.md b/README.md
iindex d2e2b50..0118d65 100644
---- a/README.md
++++ b/README.md
@@@ -392,7 +392,7 @@ the owner's gates (go-ahead, a DMCA agent, a Public Suffix List entry for openvi
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.384.0
++- openvibe-contracts: v0.399.0
- openvibe-sdk: v0.125.0
- openvibe-shared: v4.199.0
- openvibe-publishing: v1.7.0
ddiff --git a/STATUS.json b/STATUS.json
iindex 9c08e43..7dfc64f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -13,11 +13,11 @@
"runtime": "node 22; Express 4 + PostgreSQL 18 (openvibe-sdk/db, async) + optional Valkey",
"port": 4910,
"domain": "openvibe.host",
-- "contracts": "openvibe-contracts v0.384.0",
++ "contracts": "openvibe-contracts v0.399.0",
"sdk": "openvibe-sdk v0.125.0",
"shared": "openvibe-shared v4.199.0",
"packages": [
-- "openvibe-contracts v0.384.0",
++ "openvibe-contracts v0.399.0",
"openvibe-sdk v0.125.0",
"openvibe-shared v4.199.0"
],
ddiff --git a/docs/operator-alerts.md b/docs/operator-alerts.md
iindex cfe28db..5e0bc21 100644
---- a/docs/operator-alerts.md
++++ b/docs/operator-alerts.md
@@@ -26,8 +26,19 @@ Prometheus (127.0.0.1:9090) ──GET /api/v1/alerts──▶ ovhost alerts re
- **Reminded**: the alert pages again once a day while it stays open.
- **Resolved**: a notice goes out when the alert drops out of the firing set.
- **Recipients**: `OWNER_USERNAME` in Network's environment, plus the comma-separated `OPERATOR_ALERT_USERNAMES`.
--- **What leaves the host**: only the alert name, severity, summary, description, `service` label and start time.
++- **What leaves the host**: the alert name, severity, summary, description, `service` label and start time.
Instance and job labels stay on the host; the fingerprint is a hash of the labels.
++- **A browser-check alert says what failed**: `OpenVibeBrowserCheckFailed` also carries up to five `details`
++ from `/var/lib/openvibe-host/browser-watch/<site>.json` (the route, the check, the axe rule and impact, up to
++ five target selectors, the rule's help text and Deque University page, the failing page's own URL, a console
++ error, a viewport width, a status, overflow offenders), the release sha resolved to the full commit, the
++ commit's page (`git.openvibe.codes` when the repository is mirrored there, else `github.com`), when the check
++ ran and since when the alert has been failing. Network turns them into the notification's sentence, a facts
++ table, action links and a next step.
++- **Links are built on the host and allowlisted**: https only, and the host must be one of the network's own
++ domains (from the contracts service manifests), `git.openvibe.codes`, `github.com` or
++ `dequeuniversity.com`; anything else is dropped. Every string is text and is escaped where a notification
++ shows it.
- **Pending alerts** (inside their `for:` window) are not sent.
- **Prometheus unreachable**: the relay sends nothing, because an empty set would resolve every open alert.
- **Relay failures**: `openvibe_alert_relay_last_run_ok` and `openvibe_alert_relay_last_success_timestamp_seconds`
@@@ -63,7 +74,9 @@ The release is its own series (`openvibe_browser_check_release_info`), so a fail
- **A settle window:** a release is judged once its process has run 3 minutes (`/release.json` `booted_at`). This stops a restart's reconnecting sockets from paging.
- **Confirmation:** the confirming second run of a failure waits a minute.
--- **What failed:** the axe rules and targets, console errors, overflow and statuses are in `/var/lib/openvibe-host/browser-watch/<site>.json` under `details`.
++- **What failed:** the axe rules (with their help text and Deque help page), targets, console errors, overflow
++ offenders, duplicate scripts, JSON-LD the page does not show and statuses are in
++ `/var/lib/openvibe-host/browser-watch/<site>.json` under `details`; the relay sends the first five to Network.
## Commands
ddiff --git a/lib/alerts.js b/lib/alerts.js
iindex a15717b..8969524 100644
---- a/lib/alerts.js
++++ b/lib/alerts.js
@@@ -14,6 +14,14 @@
* = the rule's annotations, service = the service label when it is a service id, started_at = activeAt.
* Free-form labels never leave the host.
*
++ * A browser-check alert (`OpenVibeBrowserCheckFailed`) carries what its result file knows, so the notification can
++ * explain it in words instead of repeating the rule: up to five `details` (route, check, axe rule and impact, at
++ * most five target selectors, the rule's help text and Deque help page, the failing page's own URL, a console
++ * error, a viewport width, a status, overflow offenders), the release sha (resolved to the full commit when the
++ * service's checkout has it) and `release_url`, `checked_at` and `failing_since`. Links are built on the host from
++ * facts it has and are accepted only when https on one of the network's own domains, git.openvibe.codes, github.com
++ * or dequeuniversity.com. A plain Prometheus alert carries only its annotations, as before.
++ *
* A Prometheus that does not answer sends nothing: an empty set would resolve every open alert. Each run
* writes openvibe_alert_relay.prom to the textfile collector (last run, last success, alerts firing).
* Credentials: Host's principal from /etc/openvibe/host.env (announce.js loadCredentials; the secret and the
@@@ -22,6 +30,7 @@
const crypto = require('crypto');
const path = require('path');
const { loadCredentials } = require('./announce');
++const { axeHelpUrl } = require('./links');
const DEFAULT_PROMETHEUS = 'http://127.0.0.1:9090';
// The rules' severity label: page (and critical) pages now; ticket (and warning) pages at high; info is quiet.
@@@ -30,6 +39,12 @@ const NAME_RE = /^[A-Za-z][A-Za-z0-9_:]{0,99}$/;
const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
const TEXTFILE_DIR = '/var/lib/prometheus/node-exporter';
const MAX = 200;
++const MAX_DETAILS = 5;
++const MAX_TARGETS = 5;
++const COMMIT_SHA_RE = /^[0-9a-f]{7,40}$/i;
++const REPO_RE = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
++// The only hosts a link may point at, besides the network's own public domains (contracts' service manifests).
++const LINK_HOSTS = new Set(['git.openvibe.codes', 'github.com', 'dequeuniversity.com']);
class AlertsError extends Error {
constructor(message, exitCode = 2) { super(message); this.name = 'AlertsError'; this.exitCode = exitCode; }
@@@ -67,6 +82,139 @@ function toReport(promAlerts) {
return [...out.values()].slice(0, MAX);
}
++// ── What a browser-check alert carries (the result file's facts, in text and https links) ──────────
++
++let domainsCache = null;
++
++/** The network's own public domains, from the service manifests; the only site hosts a link may use. */
++function networkDomains(lib = null) {
++ if (domainsCache) return domainsCache;
++ let c = lib;
++ if (!c) { try { c = require('openvibe-contracts'); } catch { c = null; } }
++ const out = new Set();
++ for (const m of (c && c.services && c.services.manifests) || []) {
++ for (const d of m.domains || []) if (d) out.add(String(d).toLowerCase());
++ try { const h = new URL(m.publicOrigin).hostname.toLowerCase(); if (h) out.add(h); } catch { /* no origin */ }
++ }
++ domainsCache = out;
++ return out;
++}
++
++/** An https link on an allowed host, else null. Every URL a notification shows passes through here. */
++function safeLink(value, domains) {
++ try {
++ const u = new URL(String(value));
++ if (u.protocol !== 'https:') return null;
++ const host = u.hostname.toLowerCase();
++ if (!LINK_HOSTS.has(host) && !domains.has(host)) return null;
++ return u.href;
++ } catch { return null; }
++}
++
++/** The failing page on the site's own origin: https, a network domain, and never off that origin. */
++function pageLink(origin, route, domains) {
++ try {
++ const base = new URL(String(origin));
++ if (base.protocol !== 'https:' || !domains.has(base.hostname.toLowerCase())) return null;
++ const u = new URL(String(route || '/'), base);
++ return u.origin === base.origin ? u.href : null;
++ } catch { return null; }
++}
++
++/** The repository slug (OpenVibers/OpenVibe.Domains) from the service manifest, when it declares one. */
++function serviceRepo(service, lib) {
++ try {
++ const m = lib && lib.services && typeof lib.services.get === 'function' ? lib.services.get(service) : null;
++ return m && REPO_RE.test(String(m.repository || '')) ? String(m.repository) : null;
++ } catch { return null; }
++}
++
++/** The deployed commit's full sha: git in the service's checkout when it knows the short one from /release.json. */
++async function fullCommit(exec, inv, service, short) {
++ const s = String(short || '').toLowerCase();
++ if (!COMMIT_SHA_RE.test(s)) return null;
++ if (s.length === 40) return s;
++ const svc = inv.services && inv.services[service];
++ const dir = svc && svc.repo;
++ if (!dir) return s;
++ try {
++ const r = await exec.run('git', ['-C', dir, '-c', `safe.directory=${dir}`, 'rev-parse', '--verify', `${s}^{commit}`], { as: svc.owner || 'root' });
++ const out = String(r.stdout || '').trim().toLowerCase();
++ return r.code === 0 && /^[0-9a-f]{40}$/.test(out) ? out : s;
++ } catch { return s; }
++}
++
++/** The commit page: git.openvibe.codes when the repository is mirrored there, else github.com. */
++async function releaseLink(exec, repo, sha, cache = new Map()) {
++ if (!repo || !COMMIT_SHA_RE.test(String(sha || ''))) return null;
++ let forge = cache.get(repo);
++ if (forge === undefined) {
++ forge = false;
++ try { forge = (await exec.request(`https://git.openvibe.codes/${repo}`, { timeoutMs: 3000 })).status === 200; } catch { forge = false; }
++ cache.set(repo, forge);
++ }
++ return forge ? `https://git.openvibe.codes/${repo}/commit/${sha}` : `https://github.com/${repo}/commit/${sha}`;
++}
++
++/** One result-file detail → the contract's optional fields, with only allowed https links. */
++function browserDetail(d, origin, domains, axeVersion = null) {
++ if (!d || typeof d !== 'object') return null;
++ const check = clip(d.check, 40);
++ if (!check) return null;
++ const out = { check };
++ const route = typeof d.route === 'string' ? clip(d.route, 200) : '';
++ if (route) out.route = route;
++ if (d.rule) out.rule = clip(d.rule, 100);
++ if (['minor', 'moderate', 'serious', 'critical'].includes(d.impact)) out.impact = d.impact;
++ if (Array.isArray(d.targets)) { const t = d.targets.slice(0, MAX_TARGETS).map((x) => clip(x, 160)).filter(Boolean); if (t.length) out.targets = t; }
++ if (d.help) out.help = clip(d.help, 300);
++ if (check === 'axe') {
++ const help = safeLink(d.help_url, domains) || axeHelpUrl(d.rule, axeVersion);
++ if (help) out.help_url = help;
++ }
++ const page = pageLink(origin, route, domains);
++ if (page) out.page_url = page;
++ if (d.message) out.message = clip(d.message, 300);
++ if (Number.isInteger(d.width) && d.width >= 0 && d.width <= 10000) out.width = d.width;
++ if (Number.isInteger(d.status) && d.status >= 100 && d.status <= 599) out.status = d.status;
++ if (Array.isArray(d.offenders)) { const o = d.offenders.slice(0, MAX_TARGETS).map((x) => clip(x, 160)).filter(Boolean); if (o.length) out.offenders = o; }
++ return out;
++}
++
++/**
++ * Add the browser-check result file's facts to the alerts that have one. A missing or stale file, an unreachable
++ * Forgejo or a checkout without the commit never fails the relay: the alert stays what Prometheus said.
++ */
++async function enrichBrowser(ctx, alerts, { contracts: lib = null, domains = null, forgejo = new Map() } = {}) {
++ const { exec, inv } = ctx;
++ const libc = lib || (() => { try { return require('openvibe-contracts'); } catch { return null; } })();
++ const allowed = domains || networkDomains(libc);
++ const repos = new Map();
++ for (const a of alerts) {
++ if (a.name !== 'OpenVibeBrowserCheckFailed' || !a.service) continue;
++ a.failing_since = a.started_at;
++ try {
++ let state = null;
++ try { state = JSON.parse((await exec.readFile(path.join(inv.stateDir, 'browser-watch', `${a.service}.json`))) || 'null'); } catch { state = null; }
++ if (!state || state.ok !== false) continue;
++ const details = [];
++ for (const d of (state.details || []).slice(0, MAX_DETAILS)) { const x = browserDetail(d, state.origin, allowed, state.axeVersion); if (x) details.push(x); }
++ const sha = await fullCommit(exec, inv, a.service, state.release);
++ if (sha) {
++ a.release = sha;
++ let repo = repos.get(a.service);
++ if (repo === undefined) { repo = serviceRepo(a.service, libc); repos.set(a.service, repo); }
++ const url = await releaseLink(exec, repo, sha, forgejo);
++ if (url) a.release_url = url;
++ }
++ if (details.length) a.details = details;
++ const checked = Date.parse(state.checkedAt);
++ if (Number.isFinite(checked)) a.checked_at = new Date(checked).toISOString();
++ } catch { /* the alert keeps what Prometheus said */ }
++ }
++ return alerts;
++}
++
async function writeMetrics(exec, dir, r) {
if (!(await exec.readdir(dir))) return false;
const now = Math.floor(Date.now() / 1000);
@@@ -109,7 +257,7 @@ async function relay(ctx, { prometheus = null, envFile = null, env = {}, dryRun
if (!data || data.status !== 'success' || !data.data || !Array.isArray(data.data.alerts)) {
return done({ ok: false, stage: 'prometheus', firing: null, error: p.status ? `Prometheus answered ${p.status}` : `Prometheus unreachable (${p.error || 'no answer'})` });
}
-- const alerts = toReport(data.data.alerts);
++ const alerts = await enrichBrowser(ctx, toReport(data.data.alerts));
const body = { source: 'prometheus', sent_at: new Date().toISOString(), alerts };
if (dryRun) return { ok: true, dryRun: true, firing: alerts.length, alerts };
@@@ -131,4 +279,4 @@ async function relay(ctx, { prometheus = null, envFile = null, env = {}, dryRun
return done({ ok: true, firing: alerts.length, alerts, result });
}
--module.exports = { relay, toReport, fingerprint, writeMetrics, AlertsError, DEFAULT_PROMETHEUS };
++module.exports = { relay, toReport, enrichBrowser, browserDetail, networkDomains, safeLink, fullCommit, releaseLink, fingerprint, writeMetrics, AlertsError, DEFAULT_PROMETHEUS };
ddiff --git a/lib/browser-watch.js b/lib/browser-watch.js
iindex 257aea9..d445ee9 100644
---- a/lib/browser-watch.js
++++ b/lib/browser-watch.js
@@@ -24,6 +24,7 @@
*/
const path = require('path');
const { runBrowserCheck } = require('./browser-check-hook');
++const { axeHelpUrl } = require('./links');
const RECHECK_FAILED_MS = 30 * 60 * 1000;
const DAY_MS = 24 * 3600 * 1000;
@@@ -56,21 +57,26 @@ async function currentRelease(exec, origin, { withAge = false } = {}) {
/**
* What failed, for the operator (kept in the state file, never in metrics): per route, the axe rule ids
-- * with up to 3 targets, the first console errors, overflow and status. At most 20 entries, text clipped.
++ * with up to 5 targets and the rule's help text and help page, the first console errors, overflow, duplicate
++ * scripts, JSON-LD the page does not show and statuses. At most 20 entries, text clipped; the relay sends 5.
*/
function failureDetails(reports) {
const out = [];
const clip = (v, n = 200) => String(v == null ? '' : v).slice(0, n);
++ const axeVersion = ((reports || []).find((r) => r && r.axe_version) || {}).axe_version || null;
for (const r of reports || []) {
for (const route of r.routes || []) {
if (route.ok !== false) continue;
-- for (const v of (route.axe && route.axe.violations) || []) out.push({ route: route.path, check: 'axe', rule: v.id, impact: v.impact || null, targets: (v.targets || []).slice(0, 3).map((t) => clip(t, 160)) });
++ for (const v of (route.axe && route.axe.violations) || []) out.push({ route: route.path, check: 'axe', rule: v.id, impact: v.impact || null, targets: (v.targets || []).slice(0, 5).map((t) => clip(t, 160)), help: clip(v.help, 300), help_url: axeHelpUrl(v.id, axeVersion) });
for (const e of route.errors || []) out.push({ route: route.path, check: 'errors', message: clip(e && (e.text || e.message) ? (e.text || e.message) : e) });
for (const w of route.widths || []) {
for (const e of (w.errors || []).slice(0, 3)) out.push({ route: route.path, check: 'errors', width: w.width, message: clip(e && (e.text || e.message) ? (e.text || e.message) : e) });
-- if (w.overflow) out.push({ route: route.path, check: 'overflow', width: w.width, offenders: (w.offenders || []).slice(0, 3).map((t) => clip(typeof t === 'string' ? t : JSON.stringify(t), 160)) });
++ if (w.overflow) out.push({ route: route.path, check: 'overflow', width: w.width, offenders: (w.offenders || []).slice(0, 5).map((t) => clip(typeof t === 'string' ? t : JSON.stringify(t), 160)) });
++ for (const d of (w.duplicateScripts || []).slice(0, 3)) out.push({ route: route.path, check: 'scripts', width: w.width, message: clip(typeof d === 'string' ? d : d && d.url ? d.url : JSON.stringify(d), 300) });
if (w.status && w.status >= 400 && !route.expectStatus) out.push({ route: route.path, check: 'status', width: w.width, status: w.status });
}
++ for (const e of (route.jsonld && route.jsonld.parseErrors) || []) out.push({ route: route.path, check: 'jsonld', message: clip(`does not parse: ${e}`, 300) });
++ for (const e of (route.jsonld && route.jsonld.entities) || []) if (!e.found) out.push({ route: route.path, check: 'jsonld', message: clip(`${e.type || '?'}: ${e.value || e.field || ''}`, 300) });
}
}
return out.slice(0, 20);
@@@ -151,7 +157,8 @@ async function watch(ctx, { sites = ['live'], force = false, asUser = 'ovcheck',
}
if (r.code >= 2 || !Array.isArray(reports)) { out.push({ site, release, action: 'error', reason: r.error || `the check did not run (exit ${r.code})` }); continue; }
const failing = failingChecks(reports);
-- const state = { site, origin, release, ok: r.code === 0, failing, ...(r.code === 0 ? {} : { details: failureDetails(reports) }), checkedAt: new Date(now).toISOString() };
++ const axeVersion = (reports.find((x) => x && x.axe_version) || {}).axe_version || null;
++ const state = { site, origin, release, ok: r.code === 0, failing, ...(r.code === 0 ? {} : { details: failureDetails(reports) }), ...(axeVersion ? { axeVersion } : {}), checkedAt: new Date(now).toISOString() };
await exec.writeFile(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o640 });
out.push({ site, release, action: 'checked', ok: state.ok, failing });
}
ddiff --git a/lib/links.js b/lib/links.js
nnew file mode 100644
iindex 0000000..9f8c13c
---- /dev/null
++++ b/lib/links.js
@@@ -0,0 +1,18 @@
++'use strict';
++/**
++ * Links built on the host for an operator alert (lib/alerts.js, lib/browser-watch.js).
++ * The browser check keeps the axe rule's help text but not axe's helpUrl, so the page is built from the rule id
++ * and the axe version the check ran (the same URL axe's helpUrl names), with a current-version fallback for a
++ * result file written before the version was kept.
++ */
++const AXE_FALLBACK_VERSION = '4.13';
++const RULE_RE = /^[A-Za-z0-9_-]{1,100}$/;
++
++/** The rule's Deque University page, or null when the rule id is not one. */
++function axeHelpUrl(rule, version = null) {
++ if (!RULE_RE.test(String(rule || ''))) return null;
++ const v = (/^(\d+\.\d+)/.exec(String(version || '')) || [])[1] || AXE_FALLBACK_VERSION;
++ return `https://dequeuniversity.com/rules/axe/${v}/${rule}?application=axeAPI`;
++}
++
++module.exports = { axeHelpUrl, AXE_FALLBACK_VERSION };
ddiff --git a/package-lock.json b/package-lock.json
iindex da9f48d..8ab57c7 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
@@@ -1615,9 +1615,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.384.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
-- "integrity": "sha512-tPQTkz3os5b2olND5LUICvMOKaH1EOz3usDxRMZCplDlMuvixgZpFbofXtGj1mt2acW3+H/5CBfLCObA1CWe6Q==",
++ "version": "0.399.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
++ "integrity": "sha512-7QqSrMH2BT6ZOJu+xxusjEPICaVKGrr+3ij3tg0leE3sy4jfPwvWVTwaXZSCPM1feoP7heUDnSO4/9QBmjguJw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 8b76440..ad7b054 100644
---- a/package.json
++++ b/package.json
@@@ -31,7 +31,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
ddiff --git a/scripts/browser-check.js b/scripts/browser-check.js
iindex b078c44..fa06474 100644
---- a/scripts/browser-check.js
++++ b/scripts/browser-check.js
@@@ -319,6 +319,8 @@ async function networkDown(h) {
ignoreErrors: has('strict-errors') ? [] : KNOWN_ERRORS,
});
rep.id = site.id;
++ // The result file keeps it, so a failure detail can build the rule's Deque help page.
++ rep.axe_version = has('no-axe') ? null : h.AXE.version;
reports.push(rep);
}
} finally { if (browser) await browser.close(); }
ddiff --git a/test/alerts.test.js b/test/alerts.test.js
iindex 79e5f92..f4d9007 100644
---- a/test/alerts.test.js
++++ b/test/alerts.test.js
@@@ -8,7 +8,7 @@
const assert = require('assert');
const contracts = require('openvibe-contracts');
const { scenario, test, runTests, SECRET } = require('./helpers');
--const { toReport, fingerprint } = require('../lib/alerts');
++const { toReport, enrichBrowser, networkDomains, fingerprint } = require('../lib/alerts');
const TOKEN = 'tok_ALERTS_TOKEN_MUST_NEVER_APPEAR_5d1e';
const HOST_SECRET = `${SECRET}-host`;
@@@ -125,6 +125,93 @@ runTests([
assert.strictEqual(r.code, 2);
assert.match(r.out, /ALERTS not delivered \(credentials\)/);
}),
++ // ── What a browser-check alert carries (WS-H task 11, the operator's explanation) ─────────────
++ test('browser-check alert: the result file\'s details, the full release, page and help links, allowed hosts only', async () => {
++ const host = scenario();
++ const sha = '74bdff4c0a1e9d2b7f3c5a8e6b4d2f1a0c9e8d7b';
++ host.put('/var/lib/openvibe-host/browser-watch/domains.json', JSON.stringify({
++ site: 'domains', origin: 'https://openvibe.domains', release: sha, ok: false, failing: [{ check: 'axe', fail: 1 }], axeVersion: '4.13.0',
++ checkedAt: '2026-11-10T09:12:00Z',
++ details: [
++ { route: '/', check: 'axe', rule: 'summary-name', impact: 'serious', targets: ['#dev-fold > summary', '#faq-fold > summary'], help: 'Ensures the summary element has a non-empty accessible name', help_url: 'https://dequeuniversity.com/rules/axe/4.13/summary-name?application=axeAPI' },
++ { route: '/', check: 'errors', width: 390, message: 'WebSocket closed' },
++ ],
++ }));
++ const a = { fingerprint: 'a'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'critical', summary: 'domains: the browser check of release 74bdff4 failed (1 failing check(s))', service: 'domains', started_at: '2026-11-10T09:12:30Z' };
++ const [out] = await enrichBrowser(host.ctx(), [a], { domains: networkDomains() });
++ assert.strictEqual(out.release, sha, 'the full release sha');
++ assert.strictEqual(out.release_url, `https://github.com/OpenVibers/OpenVibe.Domains/commit/${sha}`, 'GitHub when Forgejo does not answer');
++ assert.strictEqual(out.checked_at, '2026-11-10T09:12:00.000Z');
++ assert.strictEqual(out.failing_since, '2026-11-10T09:12:30Z');
++ assert.deepStrictEqual(out.details[0], { check: 'axe', route: '/', rule: 'summary-name', impact: 'serious', targets: ['#dev-fold > summary', '#faq-fold > summary'],
++ help: 'Ensures the summary element has a non-empty accessible name', help_url: 'https://dequeuniversity.com/rules/axe/4.13/summary-name?application=axeAPI', page_url: 'https://openvibe.domains/' });
++ assert.deepStrictEqual(out.details[1], { check: 'errors', route: '/', width: 390, message: 'WebSocket closed', page_url: 'https://openvibe.domains/' });
++ const v = contracts.validate('network.operator-alerts-request@1', { source: 'prometheus', alerts: [out] });
++ assert.ok(v.valid, JSON.stringify(v.errors));
++ }),
++ test('browser-check alert: Forgejo when the repository is mirrored there; an off-host page or http help link is dropped', async () => {
++ const host = scenario();
++ host.http.set('https://git.openvibe.codes/OpenVibers/OpenVibe.Domains', () => ({ status: 200, body: 'ok' }));
++ host.put('/var/lib/openvibe-host/browser-watch/domains.json', JSON.stringify({
++ site: 'domains', origin: 'https://openvibe.domains', release: '74bdff4', ok: false, axeVersion: '4.13.0', checkedAt: '2026-11-10T09:12:00Z',
++ details: [
++ { route: '//evil.example/x', check: 'axe', rule: 'summary-name', impact: 'serious', targets: ['#a'], help: 'x', help_url: 'http://dequeuniversity.com/rules/axe/4.13/summary-name' },
++ { route: '/two', check: 'axe', rule: 'not a rule!', targets: ['#b'] },
++ ],
++ }));
++ const [out] = await enrichBrowser(host.ctx(), [{ fingerprint: 'b'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'critical', summary: 'domains failed', service: 'domains', started_at: '2026-11-10T09:12:30Z' }], { domains: networkDomains() });
++ assert.strictEqual(out.release_url, 'https://git.openvibe.codes/OpenVibers/OpenVibe.Domains/commit/74bdff4', 'the network own Forgejo when the mirror answers');
++ assert.strictEqual(out.details[0].page_url, undefined, 'a route that escapes the origin gets no link');
++ assert.strictEqual(out.details[0].help_url, 'https://dequeuniversity.com/rules/axe/4.13/summary-name?application=axeAPI', 'an http helpUrl is replaced by the https Deque page');
++ assert.strictEqual(out.details[1].help_url, undefined, 'an unknown rule id gets no made-up URL');
++ }),
++ test('browser-check alert: the short release in /release.json becomes the full commit from the checkout; 5 details, 5 targets', async () => {
++ const host = scenario();
++ const repo = host.createRepo('/opt/openvibe.domains', { owner: 'ubuntu' });
++ const sha = repo.commit({ 'package.json': '{}' });
++ repo.publish(sha); repo.checkout(sha);
++ host.inv.services.domains = { repo: '/opt/openvibe.domains', owner: 'ubuntu' };
++ host.put('/var/lib/openvibe-host/browser-watch/domains.json', JSON.stringify({
++ site: 'domains', origin: 'https://openvibe.domains', release: sha.slice(0, 12), ok: false, checkedAt: '2026-11-10T09:12:00Z',
++ details: Array.from({ length: 7 }, (_, i) => ({ route: `/${i}`, check: 'axe', rule: 'summary-name', targets: Array.from({ length: 7 }, (_, j) => `#t${i}-${j}`) })),
++ }));
++ const [out] = await enrichBrowser(host.ctx(), [{ fingerprint: 'c'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'critical', summary: 'domains failed', service: 'domains', started_at: '2026-11-10T09:12:30Z' }], { domains: networkDomains() });
++ assert.strictEqual(out.release, sha);
++ assert.strictEqual(out.release_url, `https://github.com/OpenVibers/OpenVibe.Domains/commit/${sha}`);
++ assert.strictEqual(out.details.length, 5, 'at most 5 details');
++ assert.strictEqual(out.details[0].targets.length, 5, 'at most 5 targets');
++ }),
++ test('a Prometheus alert without a result file is sent unchanged (no details, no release)', async () => {
++ const host = scenario();
++ const [out] = await enrichBrowser(host.ctx(), [{ fingerprint: 'd'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'warning', summary: 'live failed', service: 'live', started_at: '2026-09-26T05:00:00Z' }], { domains: networkDomains() });
++ assert.deepStrictEqual(out, { fingerprint: 'd'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'warning', summary: 'live failed', service: 'live', started_at: '2026-09-26T05:00:00Z', failing_since: '2026-09-26T05:00:00Z' });
++ assert.ok(contracts.validate('network.operator-alerts-request@1', { source: 'prometheus', alerts: [out] }).valid);
++ const host2 = scenario();
++ host2.put('/var/lib/openvibe-host/browser-watch/live.json', JSON.stringify({ site: 'live', ok: true, release: 'abc1234', checkedAt: '2026-09-26T04:00:00Z' }));
++ const [pass] = await enrichBrowser(host2.ctx(), [{ fingerprint: 'e'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'warning', summary: 'live failed', service: 'live', started_at: '2026-09-26T05:00:00Z' }], { domains: networkDomains() });
++ assert.strictEqual(pass.details, undefined, 'a stale pass is not used for a firing alert');
++ }),
++ test('alerts relay delivers a browser-check alert with what the result file knows', async () => {
++ const sha = '74bdff4c0a1e9d2b7f3c5a8e6b4d2f1a0c9e8d7b';
++ const host = withNetwork({ prom: { status: 200, body: { status: 'success', data: { alerts: [{
++ labels: { alertname: 'OpenVibeBrowserCheckFailed', severity: 'page', service: 'domains' }, state: 'firing', activeAt: '2026-11-10T09:12:30Z',
++ annotations: { summary: 'domains: the browser check of release 74bdff4 failed (1 failing check(s))' },
++ }] } } } });
++ host.put('/var/lib/openvibe-host/browser-watch/domains.json', JSON.stringify({
++ site: 'domains', origin: 'https://openvibe.domains', release: sha, ok: false, checkedAt: '2026-11-10T09:12:00Z',
++ details: [{ route: '/', check: 'axe', rule: 'summary-name', impact: 'serious', targets: ['#dev-fold > summary', '#faq-fold > summary'], help: 'Ensures the summary element has a non-empty accessible name' }],
++ }));
++ const r = await host.cli('alerts', 'relay');
++ assert.strictEqual(r.code, 0, r.out);
++ const doc = host.delivered[0];
++ assert.ok(contracts.validate('network.operator-alerts-request@1', doc).valid);
++ const a = doc.alerts[0];
++ assert.deepStrictEqual([a.service, a.release, a.checked_at, a.failing_since], ['domains', sha, '2026-11-10T09:12:00.000Z', '2026-11-10T09:12:30.000Z']);
++ assert.strictEqual(a.details.length, 1);
++ assert.strictEqual(a.details[0].page_url, 'https://openvibe.domains/');
++ assert.strictEqual(a.details[0].help_url, 'https://dequeuniversity.com/rules/axe/4.13/summary-name?application=axeAPI');
++ noSecrets(host, r.out);
++ }),
test('alerts: usage', async () => {
const r = await scenario().cli('alerts');
assert.notStrictEqual(r.code, 0);
ddiff --git a/test/browser-watch.test.js b/test/browser-watch.test.js
iindex 03fdedd..a3e444a 100644
---- a/test/browser-watch.test.js
++++ b/test/browser-watch.test.js
@@@ -12,8 +12,8 @@ const { runBrowserCheck } = require('../lib/browser-check-hook');
const PROM = '/var/lib/prometheus/node-exporter/openvibe_browsercheck.prom';
const pass = [{ base: 'https://openvibe.live', ok: true, summary: { checks: { status: { fail: 0 }, console: { fail: 0 } } } }];
--const fail = [{ base: 'https://openvibe.live', ok: false, summary: { checks: { status: { fail: 0 }, console: { fail: 2 }, overflow: { fail: 1 } } },
-- routes: [{ path: '/', ok: false, axe: { violations: [{ id: 'color-contrast', impact: 'serious', targets: ['.live-card .badge', '.b', '.c', '.d'] }] }, errors: [], widths: [{ width: 390, errors: [{ text: 'WebSocket closed' }], overflow: true, offenders: ['.wide'] }] }, { path: '/ok', ok: true }] }];
++const fail = [{ base: 'https://openvibe.live', ok: false, axe_version: '4.13.0', summary: { checks: { status: { fail: 0 }, console: { fail: 2 }, overflow: { fail: 1 } } },
++ routes: [{ path: '/', ok: false, axe: { violations: [{ id: 'color-contrast', impact: 'serious', help: 'Ensures the contrast between foreground and background colors meets WCAG 2 AA contrast ratio thresholds', targets: ['.live-card .badge', '.b', '.c', '.d'] }] }, errors: [], widths: [{ width: 390, errors: [{ text: 'WebSocket closed' }], overflow: true, offenders: ['.wide'], duplicateScripts: [{ url: 'https://openvibe.live/js/app.js?v=1', count: 2 }] }] }, { path: '/ok', ok: true }] }];
function site({ release = 'abc1234def56' } = {}) {
const host = scenario();
@@@ -58,10 +58,12 @@ runTests([
assert.match(host.files.get(PROM).content, /^openvibe_browser_check_failing_checks\{service="live"\} 2$/m);
const st = JSON.parse(host.files.get('/var/lib/openvibe-host/browser-watch/live.json').content);
assert.deepStrictEqual(st.details, [
-- { route: '/', check: 'axe', rule: 'color-contrast', impact: 'serious', targets: ['.live-card .badge', '.b', '.c'] },
++ { route: '/', check: 'axe', rule: 'color-contrast', impact: 'serious', targets: ['.live-card .badge', '.b', '.c', '.d'], help: 'Ensures the contrast between foreground and background colors meets WCAG 2 AA contrast ratio thresholds', help_url: 'https://dequeuniversity.com/rules/axe/4.13/color-contrast?application=axeAPI' },
{ route: '/', check: 'errors', width: 390, message: 'WebSocket closed' },
{ route: '/', check: 'overflow', width: 390, offenders: ['.wide'] },
++ { route: '/', check: 'scripts', width: 390, message: 'https://openvibe.live/js/app.js?v=1' },
], 'what failed is kept for the operator');
++ assert.strictEqual(st.axeVersion, '4.13.0', 'the relay can build a help page for a detail written before the version was kept');
// The same failed release: left alone for 30 minutes, then checked again (a transient failure clears itself).
const t0 = Date.parse(st.checkedAt);
r = await watch(host.ctx(), { retryDelayMs: 0, check: host.check, now: t0 + 10 * 60000 });