openvibers/openvibe.network Commits

Operator alerts: say what the browser check found, with the facts and links (#768)

OP OpenVibers committed 3 days ago · 47a446e8e4 · 5 files, +256 −11

* Operator alerts: say what the browser check found, with the facts and links

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

The browser-check alert details (operator alert details in the contract) shipped in openvibe-contracts v0.399.0; v0.393.0 was never tagged. Lockfile regenerated with npm --package-lock-only. ov test test/operator-alerts.test.js passes.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 99ca2add..9f44c969 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -22,7 +22,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.391.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.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",
"otpauth": "9.6.0",
@@@ -2679,9 +2679,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.391.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.391.0",
-- "integrity": "sha512-YZZlJcszGWyy1TpEoVFj+mprnedvDWflOtkXWsLV406jVrCuStJXgK82ooJPr6llmC2KbAS55sGIxiYAieyqbg==",
++ "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 f626e3e0..64dfcf70 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.391.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.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",
"otpauth": "9.6.0",
ddiff --git a/server/notifications/email-service.js b/server/notifications/email-service.js
iindex 9da691d5..56b41948 100644
---- a/server/notifications/email-service.js
++++ b/server/notifications/email-service.js
@@@ -9,6 +9,14 @@
const https = require('https');
++/** A notification's structured data, whether the queue handed it JSON or an object. */
++function richOf(notification) {
++ const rc = notification && notification.rich_content;
++ if (!rc) return {};
++ if (typeof rc === 'object') return rc;
++ try { return JSON.parse(rc) || {}; } catch { return {}; }
++}
++
/**
* Send an email via the Resend REST API.
* @param {string} apiKey - Resend API key (re_xxxx)
@@@ -343,6 +351,9 @@ class EmailService {
// ─── Email Templates ───────────────────────────────────────
_buildEmailHtml({ username, notification }) {
++ // The facts, actions and next steps are passed through for the notification template; it renders them
++ // (and escapes every string) rather than this mailer reading the host's words into its own markup.
++ notification.rich_content = richOf(notification);
const priorityColor = { low: '#888', normal: '#8b5cf6', high: '#f39c12', critical: '#e74c3c' };
const color = priorityColor[notification.priority] || '#8b5cf6';
@@@ -387,6 +398,9 @@ ${notification.url ? `<a class="cta" href="${this._escapeHtml(notification.url)}
}
_buildEmailText({ username, notification }) {
++ const rich = richOf(notification);
++ const facts = Array.isArray(rich.facts) ? rich.facts.filter((f) => f && f.label && f.value) : [];
++ const actions = Array.isArray(rich.actions) ? rich.actions.filter((a) => a && a.label && a.href) : [];
return [
`Hey ${username || 'there'},`,
'',
@@@ -394,6 +408,9 @@ ${notification.url ? `<a class="cta" href="${this._escapeHtml(notification.url)}
'',
`${notification.icon || '🔔'} ${notification.title}`,
notification.message || '',
++ ...(facts.length ? ['', ...facts.map((f) => `${f.label}: ${f.value}${f.href ? ` (${f.href})` : ''}`)] : []),
++ ...(actions.length ? ['', ...actions.map((a) => `${a.label}: ${a.href}`)] : []),
++ ...(rich.next ? ['', String(rich.next)] : []),
'',
notification.url ? `View details: ${notification.url}` : '',
'',
ddiff --git a/server/operator/alerts.js b/server/operator/alerts.js
iindex 1927b7e7..8b555f47 100644
---- a/server/operator/alerts.js
++++ b/server/operator/alerts.js
@@@ -48,6 +48,154 @@ function humanize(name) {
return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : 'Alert';
}
++
++// ── What a browser-check alert says (network.operator-alerts-request@1 details) ───────────────────
++// The host sends the result file's facts; this is the one place they become words a person reads. Every
++// string here is text and every link is https on an allowed host, whatever the host sent.
++const LINK_HOSTS = new Set(['git.openvibe.codes', 'github.com', 'dequeuniversity.com']);
++const NUMBER_WORDS = ['zero', 'one', 'two', 'three', 'four', 'five'];
++let domainsCache = null;
++
++const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
++const count = (n, one, many) => `${NUMBER_WORDS[n] || String(n)} ${n === 1 ? one : many}`;
++const has = (n) => (n === 1 ? 'has' : 'have');
++const is = (n) => (n === 1 ? 'is' : 'are');
++
++/** The network's own public domains, from the contracts service manifests; no other site host may be linked. */
++function networkDomains() {
++ if (domainsCache) return domainsCache;
++ let lib = null;
++ try { lib = require('openvibe-contracts'); } catch { lib = null; }
++ const out = new Set();
++ for (const m of (lib && lib.services && lib.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. The host builds them; this refuses anything else it is sent. */
++function linked(href) {
++ try {
++ const u = new URL(String(href));
++ if (u.protocol !== 'https:') return null;
++ const host = u.hostname.toLowerCase();
++ if (!LINK_HOSTS.has(host) && !networkDomains().has(host)) return null;
++ return u.href;
++ } catch { return null; }
++}
++
++/** Rule id or check → one sentence a person understands. An unknown rule falls back to its id and axe's help. */
++const RULE_SENTENCES = {
++ 'summary-name': (n) => `${cap(count(n, 'fold heading', 'fold headings'))} ${has(n)} no name a screen reader can say`,
++ 'color-contrast': (n) => `${cap(count(n, 'piece', 'pieces'))} of text ${is(n)} too faint to read comfortably`,
++ 'image-alt': (n) => `${cap(count(n, 'image', 'images'))} ${has(n)} no text description`,
++ 'link-name': (n) => `${cap(count(n, 'link', 'links'))} ${has(n)} no name a screen reader can say`,
++ 'button-name': (n) => `${cap(count(n, 'button', 'buttons'))} ${has(n)} no name a screen reader can say`,
++ 'label': (n) => `${cap(count(n, 'form field', 'form fields'))} ${has(n)} no label`,
++ 'aria-hidden-focus': (n) => `${cap(count(n, 'hidden element', 'hidden elements'))} can still take keyboard focus`,
++ 'landmark-one-main': () => 'The page has no main landmark',
++ 'region': (n) => `${cap(count(n, 'part', 'parts'))} of the page ${is(n)} outside a landmark`,
++};
++
++function detailWhat(d) {
++ const n = Math.max(1, (Array.isArray(d.targets) && d.targets.length) || 1);
++ if (d.check === 'axe') {
++ const rule = String(d.rule || '').toLowerCase();
++ const sentence = RULE_SENTENCES[rule];
++ if (sentence) return sentence(n);
++ return `The page breaks the accessibility rule ${d.rule || 'it names'}${d.help ? ` (${d.help})` : ''}`;
++ }
++ if (d.check === 'errors') return d.message ? `The page logged a console error: ${d.message}` : 'The page logged a console error';
++ if (d.check === 'overflow') {
++ const offenders = Array.isArray(d.offenders) && d.offenders.length ? ` (${d.offenders.join(', ')})` : '';
++ return `${d.width ? `At ${d.width} px, the` : 'The'} page scrolls sideways${offenders}`;
++ }
++ if (d.check === 'scripts') return `The page loads a script twice${d.message ? `: ${d.message}` : ''}`;
++ if (d.check === 'jsonld') {
++ const m = String(d.message || '');
++ if (/^does not parse/i.test(m)) return `The page's structured data ${m.replace(/^does not parse:\s*/i, 'does not parse: ')}`;
++ return `The page's structured data names ${m ? `"${m}"` : 'a value'} but the page text does not show it`;
++ }
++ if (d.check === 'status') return `The page answered ${d.status}${d.width ? ` at ${d.width} px` : ''}`;
++ if (d.check === 'run') return 'The browser could not finish the check';
++ return `The ${d.check || 'browser'} check failed`;
++}
++
++function detailWhere(d) {
++ if (Array.isArray(d.targets) && d.targets.length) return d.targets.join(', ');
++ if (Array.isArray(d.offenders) && d.offenders.length) return d.offenders.join(', ');
++ return d.width ? `${d.width} px` : '';
++}
++
++const shortSha = (a) => {
++ const s = String(a.release || '');
++ if (/^[0-9a-f]{7,40}$/i.test(s)) return s.slice(0, 7);
++ return (/release ([0-9a-f]{7,40})/i.exec(String(a.summary || '')) || [])[1]?.slice(0, 7) || null;
++};
++
++function siteName(a, details) {
++ for (const d of details) {
++ const href = linked(d.page_url);
++ if (href) { try { return new URL(href).hostname; } catch { /* fall through */ } }
++ }
++ return String(a.service || 'the site');
++}
++
++function when(iso) {
++ const t = Date.parse(iso);
++ if (!Number.isFinite(t)) return null;
++ return new Date(t).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');
++}
++
++/** The notification's words and stored data for a browser-check alert, or null when it has no details. */
++function browserCheck(a) {
++ const details = (Array.isArray(a.details) ? a.details : []).filter((d) => d && typeof d === 'object').slice(0, 5);
++ if (!details.length) return null;
++ const site = siteName(a, details);
++ const release = shortSha(a);
++ const routes = [...new Set(details.map((d) => d.route).filter((r) => typeof r === 'string' && r))];
++ const countPhrase = details.length === 1 ? 'a problem' : `${details.length} problems`;
++ const where = routes.length === 1 ? ` on the page ${routes[0]}`
++ : routes.length > 1 ? ` on ${routes.length} pages (${routes.join(', ')})` : '';
++ const head = `${release ? `After release ${release}, the` : 'The'} automatic check of ${site} found ${countPhrase}${where}.`;
++ const sentences = details.map((d) => {
++ const targets = d.check === 'axe' && Array.isArray(d.targets) && d.targets.length ? ` (${d.targets.join(', ')})` : '';
++ return `${detailWhat(d)}${targets}.`;
++ });
++ const message = `${head} ${sentences.join(' ')}`.replace(/\s+/g, ' ').trim().slice(0, 500);
++
++ const first = details[0];
++ const targets = detailWhere(first);
++ const facts = [
++ { label: 'Site', value: site },
++ { label: 'Page', value: first.route || '/', href: linked(first.page_url) },
++ { label: 'What', value: `${detailWhat(first)}${details.length > 1 ? ` (and ${details.length - 1} more)` : ''}` },
++ ...(targets ? [{ label: 'Where', value: targets }] : []),
++ ...(release ? [{ label: 'Release', value: release, href: linked(a.release_url) }] : []),
++ ...(when(a.checked_at) ? [{ label: 'Checked', value: when(a.checked_at) }] : []),
++ ...(when(a.failing_since || a.started_at) ? [{ label: 'Failing since', value: when(a.failing_since || a.started_at) }] : []),
++ ].map((f) => (f.href ? f : { label: f.label, value: f.value }));
++
++ const page = linked((details.find((d) => d.page_url) || {}).page_url);
++ const help = linked((details.find((d) => d.check === 'axe' && d.help_url) || {}).help_url);
++ const releaseUrl = linked(a.release_url);
++ const actions = [
++ ...(page ? [{ label: 'Open the page', href: page }] : []),
++ ...(releaseUrl ? [{ label: 'See the release', href: releaseUrl }] : []),
++ ...(help ? [{ label: 'What this rule means', href: help }] : []),
++ ];
++
++ return {
++ title: `${site}: ${details.length} problem${details.length === 1 ? '' : 's'} found by the browser check`,
++ message,
++ facts,
++ actions,
++ next: 'The browser check runs again after every release and once a day. This alert closes itself when the check passes.',
++ };
++}
++
/**
* The parts of an alert a person reads, kept apart from the shell command its rule may carry (owner 10-28: raw
* "run: sudo ovhost …" text in the notification body). `rich_content` gives Shared's notification UI the name,
@@@ -67,8 +215,10 @@ function readable(a) {
}
function opsContent(a, state, r = readable(a)) {
++ const browser = browserCheck(a);
return { kind: 'ops_alert', name: a.name, state, severity: a.severity, sites: r.sites, release: r.release, summary: r.summary,
-- command: r.command, service: a.service || null, since: a.started_at || null, runbook_url: STATUS_URL };
++ command: r.command, service: a.service || null, since: a.started_at || null, runbook_url: STATUS_URL,
++ ...(browser ? { facts: browser.facts, actions: browser.actions, next: browser.next } : {}) };
}
/**
@@@ -128,11 +278,12 @@ async function receive(db, body, { notify, revise = null, now = Date.now(), env
if (kind === 'opened' || kind === 'reminded') {
if (typeof notify !== 'function') continue;
const r = readable(a);
++ const browser = browserCheck(a);
const n = {
type: 'OPERATOR_ALERT', category: 'admin', service: 'host', url: STATUS_URL,
priority: critical ? 'critical' : 'high', icon: critical ? '🚨' : '⚠️', silent: !critical,
-- title: `${kind === 'reminded' ? 'Still failing: ' : ''}${humanize(a.name)}`.slice(0, 120),
-- message: r.message || `${a.summary}. ${message(a)}`.slice(0, 500),
++ title: `${kind === 'reminded' ? 'Still failing: ' : ''}${browser ? browser.title : humanize(a.name)}`.slice(0, 120),
++ message: (browser ? browser.message : (r.message || `${a.summary}. ${message(a)}`)).slice(0, 500),
rich_content: opsContent(a, kind === 'reminded' ? 'reminded' : 'firing', r),
};
const kept = kind === 'reminded' ? notices(prev) : [];
@@@ -148,11 +299,12 @@ async function receive(db, body, { notify, revise = null, now = Date.now(), env
}
if (typeof revise !== 'function') continue;
const r = readable(a);
++ const browser = browserCheck(a);
const fields = kind === 'resolved'
? { title: `Resolved: ${humanize(a.name)}`.slice(0, 120), icon: '✅', is_read: 1, rich_content: opsContent(a, 'resolved', r),
message: `${r.message || a.summary} (resolved after ${ago(now - (prev.opened_at || now))}${prev.reopened ? `; it fired ${prev.reopened + 1} times` : ''}).`.slice(0, 500) }
-- : { title: humanize(a.name).slice(0, 120), icon: critical ? '🚨' : '⚠️', is_read: critical ? 0 : 1, rich_content: opsContent(a, 'firing', r),
-- message: `${r.message || a.summary}. Fired again ${(prev.reopened || 0) + 1} time(s) within 12 h of resolving.`.slice(0, 500) };
++ : { title: (browser ? browser.title : humanize(a.name)).slice(0, 120), icon: critical ? '🚨' : '⚠️', is_read: critical ? 0 : 1, rich_content: opsContent(a, 'firing', r),
++ message: `${browser ? browser.message : (r.message || a.summary)}. Fired again ${(prev.reopened || 0) + 1} time(s) within 12 h of resolving.`.slice(0, 500) };
for (const [uid, id] of notices(prev)) {
try { await revise(uid, id, fields); } catch (e) { console.warn('[operator-alerts] revise failed:', e.message); }
}
@@@ -166,4 +318,4 @@ async function list(db, { limit = 100 } = {}) {
return await db.prepare("SELECT source, fingerprint, name, severity, summary, service, state, started_at, opened_at, last_seen_at, notified_at, resolved_at FROM operator_alerts ORDER BY state = 'firing' DESC, COALESCE(resolved_at, last_seen_at) DESC LIMIT ?").all(limit);
}
--module.exports = { ensureTables, receive, list, operatorUserIds, humanize, readable, REMIND_MS, FLAP_MS };
++module.exports = { ensureTables, receive, list, operatorUserIds, humanize, readable, browserCheck, REMIND_MS, FLAP_MS };
ddiff --git a/test/operator-alerts.test.js b/test/operator-alerts.test.js
iindex 6fa6ab4e..9313ae8a 100644
---- a/test/operator-alerts.test.js
++++ b/test/operator-alerts.test.js
@@@ -13,6 +13,7 @@ const express = require('express');
const { validate } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const alerts = require('../server/operator/alerts');
++const { EmailService } = require('../server/notifications/email-service');
(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-opalerts-'));
@@@ -42,6 +43,18 @@ app.use('/internal', require('../server/internal/routes'));
const server = http.createServer(app);
const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').update(name).digest('hex').slice(0, 16), name, severity: 'critical', summary: `${name} summary`, started_at: '2026-09-26T05:00:00Z', ...extra });
++const browserAlert = {
++ fingerprint: 'f'.repeat(16), name: 'OpenVibeBrowserCheckFailed', severity: 'critical',
++ summary: 'domains: the browser check of release 74bdff4 failed (1 failing check(s)); run: sudo ovhost browser-watch --sites domains --force',
++ description: 'The automatic check of openvibe.domains found a problem after release 74bdff4.',
++ service: 'domains', started_at: '2026-11-10T09:12:30Z', failing_since: '2026-11-10T09:12:30Z',
++ release: '74bdff4c0a1e9d2b7f3c5a8e6b4d2f1a0c9e8d7b',
++ release_url: 'https://git.openvibe.codes/OpenVibers/OpenVibe.Domains/commit/74bdff4c0a1e9d2b7f3c5a8e6b4d2f1a0c9e8d7b',
++ checked_at: '2026-11-10T09:12:00Z',
++ details: [{ route: '/', check: 'axe', rule: 'summary-name', impact: 'serious', targets: ['#dev-fold', '#faq-fold'],
++ help: 'Ensures the summary element has a non-empty accessible name',
++ help_url: 'https://dequeuniversity.com/rules/axe/4.13/summary-name', page_url: 'https://openvibe.domains/' }],
++};
(async () => {
await new Promise((r) => server.listen(0, '127.0.0.1', r));
@@@ -71,6 +84,9 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
assert.strictEqual(r.status, 400);
r = await post({ source: 'grafana', alerts: [] }, auth);
assert.strictEqual(r.status, 400);
++ assert.ok(validate('network.operator-alerts-request@1', { source: 'prometheus', alerts: [browserAlert] }).valid, 'the browser-check details are part of the contract now');
++ assert.ok(!validate('network.operator-alerts-request@1', { source: 'prometheus', alerts: [{ ...browserAlert, details: [{ route: '/' }] }] }).valid, 'a detail must say which check it is');
++ assert.ok(!validate('network.operator-alerts-request@1', { source: 'prometheus', alerts: [{ ...browserAlert, details: [{ check: 'axe', page_url: 'not a url' }] }] }).valid, 'a link is a URI');
// Opens: both operators (owner by OWNER_USERNAME, case-insensitive; the extra username) are paged.
r = await post({ source: 'prometheus', alerts: [alert('OpenVibeBackupMissed', { service: 'host', description: 'No successful backup in 26 hours.' }), alert('OpenVibeBrowserCheckFailed', { severity: 'warning' })] }, auth);
@@@ -155,6 +171,66 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
process.env.OWNER_USERNAME = 'ghost'; process.env.OPERATOR_ALERT_USERNAMES = '';
r = await post({ source: 'prometheus', alerts: [alert('Lonely')] }, auth);
assert.deepStrictEqual([r.status, r.body.opened, r.body.notified], [200, 1, 0]);
++
++ // ── A browser-check alert says it in words (network.operator-alerts-request@1 details) ─────────
++ process.env.OWNER_USERNAME = 'Boss'; process.env.OPERATOR_ALERT_USERNAMES = '';
++ const bc = alerts.browserCheck(browserAlert);
++ assert.strictEqual(bc.title, 'openvibe.domains: 1 problem found by the browser check');
++ assert.strictEqual(bc.message, 'After release 74bdff4, the automatic check of openvibe.domains found a problem on the page /. Two fold headings have no name a screen reader can say (#dev-fold, #faq-fold).');
++ assert.deepStrictEqual(bc.facts.map((f) => f.label), ['Site', 'Page', 'What', 'Where', 'Release', 'Checked', 'Failing since']);
++ assert.deepStrictEqual(bc.facts.find((f) => f.label === 'Page'), { label: 'Page', value: '/', href: 'https://openvibe.domains/' });
++ assert.deepStrictEqual(bc.facts.find((f) => f.label === 'Release'), { label: 'Release', value: '74bdff4', href: browserAlert.release_url });
++ assert.deepStrictEqual(bc.actions.map((a) => a.label), ['Open the page', 'See the release', 'What this rule means']);
++ assert.match(bc.next, /runs again after every release and once a day.*closes itself when the check passes/);
++
++ // The notification itself carries the words and the stored facts/actions/next.
++ sent.length = 0;
++ out = await alerts.receive(db, { source: 'prometheus', alerts: [browserAlert] }, { notify: (u, n) => { sent.push({ user_id: u, ...n }); return { id: 'browser-1' }; } });
++ assert.deepStrictEqual([out.opened, out.notified], [1, 1]);
++ assert.strictEqual(sent[0].title, bc.title);
++ assert.strictEqual(sent[0].message, bc.message);
++ assert.deepStrictEqual(sent[0].rich_content.facts, bc.facts);
++ assert.deepStrictEqual(sent[0].rich_content.actions, bc.actions);
++ assert.strictEqual(sent[0].rich_content.next, bc.next);
++
++ // A reminder says "Still failing" in front, and keeps the details.
++ sent.length = 0;
++ out = await alerts.receive(db, { source: 'prometheus', alerts: [browserAlert] }, { notify: (u, n) => { sent.push(n); return { id: 'browser-2' }; }, now: Date.now() + alerts.REMIND_MS + 1000 });
++ assert.strictEqual(out.reminded, 1);
++ assert.strictEqual(sent[0].title, 'Still failing: openvibe.domains: 1 problem found by the browser check');
++
++ // An unknown axe rule falls back to its id and axe's help text.
++ const unknown = alerts.browserCheck({ ...browserAlert, details: [{ route: '/x', check: 'axe', rule: 'mystery-rule', help: 'Makes sure the widget has a name', targets: ['#widget'], page_url: 'https://openvibe.domains/x' }] });
++ assert.match(unknown.message, /The page breaks the accessibility rule mystery-rule \(Makes sure the widget has a name\) \(#widget\)\./);
++ const consoleError = alerts.browserCheck({ ...browserAlert, details: [{ route: '/', check: 'errors', message: 'WebSocket closed', width: 390, page_url: 'https://openvibe.domains/' }, { route: '/', check: 'overflow', width: 390, offenders: ['.wide'], page_url: 'https://openvibe.domains/' }] });
++ assert.match(consoleError.message, /found 2 problems on the page \/.*console error: WebSocket closed.*At 390 px, the page scrolls sideways \(\.wide\)/);
++
++ // Links that are not https or not on an allowed host are dropped; text is kept as text.
++ const unsafe = alerts.browserCheck({ ...browserAlert,
++ release_url: 'http://github.com/OpenVibers/OpenVibe.Domains/commit/74bdff4',
++ details: [{ route: '/', check: 'axe', rule: 'summary-name', targets: ['<img onerror=alert(1)>'], help: 'x', help_url: 'javascript:alert(1)', page_url: 'https://evil.example/' }] });
++ assert.deepStrictEqual(unsafe.actions, [], 'no http, javascript: or off-network link becomes an action');
++ assert.strictEqual(unsafe.facts.find((f) => f.label === 'Page').href, undefined);
++ assert.strictEqual(unsafe.facts.find((f) => f.label === 'Site').value, 'domains', 'an off-network page does not rename the site');
++ assert.ok(unsafe.message.includes('<img onerror=alert(1)>'), 'the target stays text (the UI escapes it)');
++
++ // A plain Prometheus alert with no details still reads as before.
++ sent.length = 0;
++ out = await alerts.receive(db, { source: 'prometheus', alerts: [{ fingerprint: '0'.repeat(16), name: 'OpenVibeDiskFull', severity: 'warning', summary: 'The host disk is 95% full', description: 'over 90% for an hour', service: 'host', started_at: '2026-11-10T09:00:00Z' }] }, { notify: (u, n) => { sent.push(n); return { id: 'plain-1' }; } });
++ assert.deepStrictEqual([out.opened, out.notified], [1, 1]);
++ assert.strictEqual(sent[0].title, 'Disk full');
++ assert.strictEqual(sent[0].message, 'host · The host disk is 95% full · over 90% for an hour');
++ assert.strictEqual(sent[0].rich_content.facts, undefined);
++ assert.strictEqual(sent[0].rich_content.actions, undefined);
++ assert.strictEqual(sent[0].rich_content.next, undefined);
++
++ // The email's plain-text part shows the facts, the actions' links and the next step.
++ const text = EmailService.prototype._buildEmailText({ username: 'Ada', notification: { title: bc.title, message: bc.message, rich_content: bc } });
++ assert.match(text, /Site: openvibe\.domains/);
++ assert.match(text, /Open the page: https:\/\/openvibe\.domains\//);
++ assert.match(text, /See the release: https:\/\/git\.openvibe\.codes\//);
++ assert.match(text, /What this rule means: https:\/\/dequeuniversity\.com\//);
++ assert.match(text, /The browser check runs again after every release/);
} finally {
server.close();
fs.rmSync(dir, { recursive: true, force: true });