oficina / meet Public

Calls web: Report a problem sheet, 30-second quality reports, Devices toggle and holding-screen disclosure (spec 15.26)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 6d95ad073e70b51bb7ccd77dc533473b11f061b8 parent 1bcc1c6
10 files changed, +1732 −13
  • Makefile +1 −0
  • internal/calls/handlers_room.go +5 −0
  • internal/calls/static/calls.css +56 −0
  • internal/calls/static/report.js +355 −0
  • internal/calls/static/room.js +726 −13
  • internal/calls/templates/join.html +10 −0
  • internal/calls/templates/partials/report.html +48 −0
  • internal/calls/templates/room.html +11 −0
  • test/browser/call.mjs +139 −0
  • test/browser/room-quality.test.mjs +381 −0
diff --git a/Makefile b/Makefile
index d44b73b..01b91f6 100644
--- a/Makefile
+++ b/Makefile
@@ -157,6 +157,7 @@ browser:
node --test test/browser/room-layout.test.mjs
node --test test/browser/room-window.test.mjs
node --test test/browser/room-lane.test.mjs
+ node --test test/browser/room-quality.test.mjs
node --test test/browser/media.test.mjs
node --test test/browser/audio.test.mjs
node --test test/browser/devices.test.mjs
diff --git a/internal/calls/handlers_room.go b/internal/calls/handlers_room.go
index fef57a3..fd61ea1 100644
--- a/internal/calls/handlers_room.go
+++ b/internal/calls/handlers_room.go
@@ -6,6 +6,8 @@ import (
"github.com/go-chi/chi/v5"
+ "amadan.net/rastrillo/rastrillo"
+
"amadan.net/oficina/calls/internal/calls/hub"
)
@@ -78,6 +80,9 @@ func (a *app) room(w http.ResponseWriter, r *http.Request) {
// between the two failed-tile sentences. The list the
// server actually mints at /ice is the authority.
"Floor": !a.relayConfigured(), "Relay": a.relayConfigured(), "Boot": a.hub.Boot(),
+ // The build a web quality or problem report names as its
+ // client's version (spec §15.26): the page is the client.
+ "Version": rastrillo.BuildVersion,
})
data["Menu"] = a.roomMenuFor(data["Frame"].(Frame))
render(w, "room", data)
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css
index da75362..a91b888 100644
--- a/internal/calls/static/calls.css
+++ b/internal/calls/static/calls.css
@@ -1431,3 +1431,59 @@ body:has(> .calls-window) { background: var(--rst-bg); }
.calls-window #controls .calls-sheet[data-open] > button,
.calls-window #controls .calls-sheet[data-open] > form > button { min-block-size: 64px; }
}
+
+/* ── "Report a problem" (spec §15.26) ──
+
+ report.js's sheet: a modal <dialog> over the call or the holding
+ screen, in the page's own theme, which inside the call window is the
+ window's dark one (color-scheme: dark is inherited into the top
+ layer). Centred by the browser, and never wider than the screen less
+ a margin, so a phone gets the whole width. */
+.calls-report {
+ box-sizing: border-box;
+ inline-size: min(28rem, calc(100vw - 32px));
+ max-block-size: calc(100dvh - 32px);
+ overflow: auto;
+ padding: var(--rst-sp-4);
+ border: 1px solid var(--rst-line);
+ border-radius: 16px;
+ background: var(--rst-surface);
+ color: var(--rst-text);
+ box-shadow: var(--rst-shadow-pop);
+}
+.calls-report::backdrop { background: rgb(0 0 0 / 0.55); }
+.calls-report-form { margin: 0; }
+.calls-report-title { margin: 0 0 var(--rst-sp-3); font-size: 1.0625rem; font-weight: 650; }
+.calls-report textarea { box-sizing: border-box; inline-size: 100%; resize: vertical; }
+.calls-report-disclosure { margin: var(--rst-sp-3) 0 0; color: var(--rst-text-muted); font-size: var(--rst-fs-sm); }
+.calls-report-error { margin: var(--rst-sp-3) 0 0; color: var(--rst-tone-negative-fg); font-size: var(--rst-fs-sm); }
+.calls-report-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: var(--rst-sp-2);
+ margin-block-start: var(--rst-sp-4);
+}
+.calls-report-done { border-radius: var(--rst-radius-sm); outline-offset: 4px; }
+.calls-report-done p { margin: 0 0 var(--rst-sp-2); }
+.calls-report-thanks { font-weight: 600; }
+/* One click selects the whole reference, for copying by hand. */
+.calls-report-ref {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ letter-spacing: 0.06em;
+ -webkit-user-select: all;
+ user-select: all;
+}
+.calls-report-copied { margin: 0; min-block-size: 1.2em; color: var(--rst-text-muted); font-size: var(--rst-fs-sm); }
+/* Beside the way out of a call that is over, read like its links. */
+.calls-report-after {
+ padding: 0;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ text-decoration: underline;
+ cursor: pointer;
+}
+.calls-window .calls-status .calls-report-after { margin-inline-start: 8px; color: #fff; font-weight: 600; }
+.calls-quality-row .calls-raw-help { margin-block-start: var(--rst-sp-1); }
diff --git a/internal/calls/static/report.js b/internal/calls/static/report.js
new file mode 100644
index 0000000..fc31a73
--- /dev/null
+++ b/internal/calls/static/report.js
@@ -0,0 +1,355 @@
+// report.js: "Report a problem" (spec §15.26, 2026-10-03) — the sheet,
+// what kind of client is reporting, and the offer on the holding screen
+// for ten minutes after leaving a call.
+//
+// The room (room.js) opens the sheet from its More menu, from the
+// Devices panel beside "Copy call diagnostics", and from the words that
+// say a call is over; it hands over the report token from its `hello`
+// and the call's diagnostics with the people named by short peer id.
+// Leaving the call navigates to the holding screen, and the page that
+// held both is gone, so room.js leaves them here, in this tab's
+// sessionStorage, for as long as the server will still take a report
+// (hub.ReportAfterLeave); the holding screen offers the sheet with them.
+//
+// What is sent is what the sheet says: the note, if any, and the
+// diagnostics — connection states and counts, no audio, no video, no
+// names. Nothing is sent until Send is pressed.
+//
+// House rules as room.js's: no library, no inline script, nothing built
+// with innerHTML.
+(() => {
+ 'use strict';
+
+ // ---- the rules, lifted by name in test/browser/room-quality.test.mjs ----
+
+ // reportClient is protocol.ReportClient for this browser: enums, and
+ // versions as digits and dots — never the user agent string itself.
+ // `ua` is navigator.userAgent; `hints` what navigator.userAgentData
+ // says ({platform, platformVersion}), where it says anything — Chromium
+ // freezes the operating system's version in the user agent string, and
+ // only the hints carry the real one; `version` is the server's build
+ // (rastrillo.BuildVersion); `touch` is navigator.maxTouchPoints, the
+ // one way to tell an iPad asking for the desktop site from a Mac.
+ // Anything the server would refuse is left out instead.
+ function reportClient(ua, hints, version, touch) {
+ const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,23}$/;
+ const NUMERIC = /^[0-9]{1,4}([._][0-9]{1,6}){0,3}$/;
+ const c = { app: 'web' };
+ if (typeof version === 'string' && VERSION.test(version)) c.version = version;
+ const s = String(ua || '');
+ const dotted = (m) => (m ? m.slice(1).filter((x) => x !== undefined && x !== '').join('.') : '');
+ let m = null;
+ let browser = 'other';
+ let browserVersion = '';
+ if ((m = s.match(/\bEdg(?:e|A|iOS)?\/(\d{1,4})(?:\.(\d{1,6}))?/))) {
+ browser = 'edge';
+ } else if ((m = s.match(/\b(?:Firefox|FxiOS)\/(\d{1,4})(?:\.(\d{1,6}))?/))) {
+ browser = 'firefox';
+ } else if (/\b(?:OPR|OPiOS|SamsungBrowser|YaBrowser|Vivaldi)\//.test(s)) {
+ m = null;
+ } else if ((m = s.match(/\b(?:HeadlessChrome|Chrome|CriOS|Chromium)\/(\d{1,4})(?:\.(\d{1,6}))?/))) {
+ browser = 'chrome';
+ } else if ((m = s.match(/\bVersion\/(\d{1,4})(?:\.(\d{1,6}))?.*\bSafari\//))) {
+ browser = 'safari';
+ }
+ browserVersion = dotted(m);
+ c.browser = browser;
+ if (NUMERIC.test(browserVersion)) c.browserVersion = browserVersion;
+
+ let os = 'other';
+ let osVersion = '';
+ const ios = s.match(/\bOS (\d{1,4})_(\d{1,6})(?:_(\d{1,6}))?/);
+ if (/\biPad\b/.test(s) || (/\bMacintosh\b/.test(s) && touch > 1)) {
+ os = 'ipados';
+ osVersion = dotted(ios);
+ } else if (/\b(?:iPhone|iPod)\b/.test(s)) {
+ os = 'ios';
+ osVersion = dotted(ios);
+ } else if (/\bAndroid\b/.test(s)) {
+ os = 'android';
+ osVersion = dotted(s.match(/\bAndroid (\d{1,4})(?:\.(\d{1,6}))?(?:\.(\d{1,6}))?/));
+ } else if (/\bCrOS\b/.test(s)) {
+ os = 'chromeos';
+ } else if (/\bMacintosh\b|\bMac OS X\b/.test(s)) {
+ // The user agent string's macOS version has said 10_15_7 since
+ // 2020 whatever the machine runs: not a version, so not sent.
+ os = 'macos';
+ } else if (/\bWindows\b/.test(s)) {
+ os = 'windows';
+ } else if (/\bLinux\b|\bX11\b/.test(s)) {
+ os = 'linux';
+ }
+ const pv = hints && typeof hints.platformVersion === 'string' ? hints.platformVersion : '';
+ if (pv && ['macos', 'windows', 'chromeos', 'android', 'linux'].includes(os)) {
+ const parts = pv.split('.').slice(0, 3).join('.');
+ if (NUMERIC.test(parts)) osVersion = parts;
+ }
+ c.os = os;
+ if (NUMERIC.test(osVersion)) c.osVersion = osVersion;
+ return c;
+ }
+
+ // problemAnswer is what the sheet says for the server's answer to a
+ // problem report: the reference to quote, or the sentence for why not.
+ function problemAnswer(status, data) {
+ if (status === 200 && data && typeof data.ref === 'string' && /^[0-9A-Z]{8}$/.test(data.ref)) {
+ return { ref: data.ref, text: '' };
+ }
+ if (status === 429) return { ref: '', text: 'Too many reports from this call — try again in a minute.' };
+ if (status === 404) return { ref: '', text: 'This call can no longer be reported.' };
+ return { ref: '', text: 'The report could not be sent.' };
+ }
+
+ // problemBody is protocol.ProblemReport as JSON, at most `max` bytes:
+ // the note to its thousand characters, and the diagnostics cut short
+ // from the end — the state of the call comes first and the oldest of
+ // the screen's log last — with a line saying so, if the whole would not
+ // fit the server's 64 KB.
+ function problemBody(note, diagnostics, client, max) {
+ const size = (x) => (typeof TextEncoder === 'function' ? new TextEncoder().encode(x).length : x.length * 3);
+ const n = Array.from(String(note || '')).slice(0, 1000).join('');
+ let d = String(diagnostics || '');
+ let body = JSON.stringify({ note: n, diagnostics: d, client });
+ while (size(body) > max && d.length > 0) {
+ d = d.slice(0, Math.floor(d.length * 0.9));
+ body = JSON.stringify({ note: n, diagnostics: d + '\n[cut short to fit]', client });
+ }
+ return body;
+ }
+
+ // ---- this browser ----
+
+ const LAST_KEY = 'calls.lastCall';
+ // hub.ReportAfterLeave: how long the server takes a report for after
+ // the participation ends.
+ const AFTER_LEAVE = 10 * 60 * 1000;
+ const NOTE_MAX = 1000;
+ const BODY_MAX = 64 * 1024 - 512;
+
+ const $ = (id) => document.getElementById(id);
+ const sheet = $('report-sheet');
+
+ // The operating system's real version, where the browser will say it.
+ // Asked once, at load, and used by every report after it answers.
+ let hints = null;
+ try {
+ const uad = navigator.userAgentData;
+ if (uad) {
+ hints = { platform: uad.platform || '' };
+ if (typeof uad.getHighEntropyValues === 'function') {
+ uad.getHighEntropyValues(['platformVersion']).then((h) => {
+ hints = { platform: h.platform || hints.platform, platformVersion: h.platformVersion || '' };
+ }, () => {});
+ }
+ }
+ } catch {
+ hints = null;
+ }
+
+ function client() {
+ const version = (sheet && sheet.dataset.callsVersion) || '';
+ return reportClient(navigator.userAgent, hints, version, navigator.maxTouchPoints || 0);
+ }
+
+ // remember is room.js's, at leave: what the holding screen needs to
+ // offer the sheet for the next ten minutes.
+ function remember(rec) {
+ try {
+ sessionStorage.setItem(LAST_KEY, JSON.stringify({
+ sqid: rec.sqid, token: rec.token, at: Date.now(), diagnostics: rec.diagnostics, client: rec.client,
+ }));
+ } catch {
+ // Private browsing, or full: the holding screen simply has no offer.
+ }
+ }
+
+ function recall(sqid) {
+ let rec = null;
+ try {
+ rec = JSON.parse(sessionStorage.getItem(LAST_KEY) || 'null');
+ } catch {
+ rec = null;
+ }
+ if (!rec || typeof rec.token !== 'string' || typeof rec.at !== 'number') return null;
+ if (Date.now() - rec.at >= AFTER_LEAVE) {
+ try { sessionStorage.removeItem(LAST_KEY); } catch { /* nothing to forget */ }
+ return null;
+ }
+ return rec.sqid === sqid ? rec : null;
+ }
+
+ // ---- the sheet ----
+
+ const form = $('report-form');
+ const noteEl = $('report-note');
+ const hint = $('report-note-hint');
+ const ask = $('report-ask');
+ const error = $('report-error');
+ const send = $('report-send');
+ const cancel = $('report-cancel');
+ const done = $('report-done');
+ const doneTitle = $('report-done-title');
+ const refEl = $('report-ref');
+ const copy = $('report-copy');
+ const copied = $('report-copied');
+ const closeButton = $('report-close');
+
+ let current = null; // the open sheet's options
+ let round = 0; // which opening a POST belongs to
+
+ function hintText() {
+ const left = NOTE_MAX - (noteEl ? noteEl.value.length : 0);
+ return 'Optional. ' + left.toLocaleString('en-GB') + ' of 1,000 characters left.';
+ }
+
+ function show(which) {
+ ask.hidden = which !== 'ask';
+ done.hidden = which !== 'done';
+ }
+
+ function close() {
+ if (!sheet) return;
+ if (typeof sheet.close === 'function' && sheet.open) sheet.close();
+ else sheet.removeAttribute('open');
+ }
+
+ function open(opts) {
+ if (!sheet || !form) return;
+ round += 1;
+ current = opts;
+ noteEl.value = '';
+ hint.textContent = hintText();
+ error.hidden = true;
+ error.textContent = '';
+ send.disabled = false;
+ send.textContent = 'Send';
+ copied.textContent = '';
+ show('ask');
+ if (typeof sheet.showModal === 'function') {
+ if (!sheet.open) sheet.showModal();
+ } else {
+ sheet.setAttribute('open', '');
+ }
+ noteEl.focus();
+ }
+
+ async function submit() {
+ const opts = current;
+ if (!opts) return;
+ const mine = round;
+ send.disabled = true;
+ send.textContent = 'Sending…';
+ error.hidden = true;
+ const token = typeof opts.token === 'function' ? opts.token() : opts.token;
+ let answer;
+ if (!token) {
+ answer = problemAnswer(404, null);
+ } else {
+ const body = problemBody(noteEl.value, opts.diagnostics(), opts.client(), BODY_MAX);
+ try {
+ const res = await fetch('/m/' + encodeURIComponent(opts.sqid) + '/problem', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json', 'X-Calls-Report': token },
+ body,
+ });
+ let data = null;
+ try {
+ data = await res.json();
+ } catch {
+ data = null;
+ }
+ answer = problemAnswer(res.status, data);
+ } catch {
+ answer = problemAnswer(0, null);
+ }
+ }
+ // Closed, or opened again, while that was in flight: the answer
+ // belongs to a sheet nobody is looking at.
+ if (mine !== round || !sheet.hasAttribute('open')) return;
+ send.disabled = false;
+ send.textContent = 'Send';
+ if (answer.ref) {
+ refEl.textContent = answer.ref;
+ show('done');
+ doneTitle.focus();
+ return;
+ }
+ error.textContent = answer.text;
+ error.hidden = false;
+ }
+
+ if (sheet && form) {
+ hint.textContent = hintText();
+ noteEl.addEventListener('input', () => {
+ hint.textContent = hintText();
+ });
+ form.addEventListener('submit', (ev) => {
+ ev.preventDefault();
+ submit().catch(() => {
+ error.textContent = problemAnswer(0, null).text;
+ error.hidden = false;
+ send.disabled = false;
+ send.textContent = 'Send';
+ });
+ });
+ cancel.addEventListener('click', close);
+ closeButton.addEventListener('click', close);
+ copy.addEventListener('click', () => {
+ const ref = refEl.textContent || '';
+ const ok = () => { copied.textContent = 'Reference copied.'; };
+ const failed = () => { copied.textContent = 'Select the reference to copy it.'; };
+ try {
+ navigator.clipboard.writeText(ref).then(ok, failed);
+ } catch {
+ failed();
+ }
+ });
+ // The sheet's keys are its own. Escape closes it and nothing behind
+ // it (the call window closes its popovers on Escape too), and Space
+ // on the sheet is never the room's mute.
+ sheet.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Escape') {
+ ev.preventDefault();
+ close();
+ }
+ ev.stopPropagation();
+ });
+ // The keyboard goes back to what opened the sheet — or, when that was
+ // in a panel that has since closed, to the button the panel hung from.
+ sheet.addEventListener('close', () => {
+ round += 1;
+ const back = current && current.returnFocus;
+ current = null;
+ if (back && back.isConnected && !back.hidden && typeof back.focus === 'function') back.focus();
+ });
+ }
+
+ // ---- the holding screen, after leaving ----
+
+ const last = $('report-last');
+ const lastOpen = $('report-last-open');
+ const devices = $('devices');
+ if (last && lastOpen && devices) {
+ const rec = recall(devices.dataset.sqid || '');
+ if (rec && sheet) {
+ last.hidden = false;
+ lastOpen.addEventListener('click', () => {
+ open({
+ sqid: rec.sqid,
+ token: () => (Date.now() - rec.at < AFTER_LEAVE ? rec.token : ''),
+ diagnostics: () => String(rec.diagnostics || ''),
+ client: () => (rec.client && rec.client.app === 'web' ? rec.client : client()),
+ returnFocus: lastOpen,
+ });
+ });
+ // Gone when the server would no longer take it.
+ setTimeout(() => {
+ if (document.activeElement === lastOpen) lastOpen.blur();
+ last.hidden = true;
+ }, Math.max(0, AFTER_LEAVE - (Date.now() - rec.at)));
+ }
+ }
+
+ window.CallsReport = { client, open, remember };
+})();
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js
index 75abb55..279221c 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -30,6 +30,9 @@
// §15.19 media state who is muted and whose camera is off: our own
// reported on the broadcast lane, everyone else's
// read from the roster and `media`
+// §15.26 reports the anonymous quality report every thirty
+// seconds and at leave, and "Report a problem"
+// (report.js's sheet) with the diagnostics
//
// House rules this file is written under: no library, no inline script
// (the page's CSP is `default-src 'self'`), nothing built with
@@ -72,6 +75,15 @@
const shareStop = $('share-stop');
const shareRecoveryEl = $('share-recovery');
const copyDiagnostics = $('copy-diagnostics');
+ // §15.26: "Report a problem", in the Devices panel beside Copy call
+ // diagnostics and in the More menu, and the person's own switch for
+ // the automatic reports.
+ const reportProblem = $('report-problem');
+ const reportProblemMenu = $('report-problem-menu');
+ const moreToggle = $('call-more-toggle');
+ const morePanel = $('call-more');
+ const qualityRow = $('quality-row');
+ const qualityInput = qualityRow ? qualityRow.querySelector('input[type="checkbox"]') : null;
// §15.19: the tile marks, rendered by the server so they are the
// vendored badge and the app's own icons rather than a copy of either,
// and the polite region that says a change out loud. #status is no
@@ -322,6 +334,28 @@
// CONNECTING_CAP, after which §15.20's and §15.23's words take over.
const CONNECTING_GRACE = 1000;
const CONNECTING_CAP = 20000;
+ // §15.26 (2026-10-03), the automatic quality report: one every
+ // QUALITY_EVERY while admitted, and one more at leave. The gate
+ // shortens the cadence through window.meet.qualityEveryForTest, never
+ // below QUALITY_EVERY_MIN. QUALITY_KEY is the person's own switch
+ // ("Send anonymous quality reports"), stored as "off" when off.
+ // The bounds are protocol/quality.go's, so a report is clamped here to
+ // what the server would accept rather than refused there.
+ const QUALITY_EVERY = 30000;
+ const QUALITY_EVERY_MIN = 1000;
+ const QUALITY_KEY = 'calls.qualityReports';
+ const QUALITY_MAX_PEERS = 4;
+ const QUALITY_MAX_SECONDS = 600;
+ const QUALITY_MAX_MILLIS = 600000;
+ const QUALITY_MAX_COUNT = 1e9;
+ const QUALITY_MAX_KBPS = 1e6;
+ const QUALITY_MAX_PIXELS = 16384;
+ const QUALITY_MAX_FPS = 240;
+ const QUALITY_MAX_BODY = 16384;
+ // The longest stretch between two looks of the event watch that is
+ // counted as time a notice was showing: a suspended tab's timers stop,
+ // and the gap when they start again is not a notice someone saw.
+ const QUALITY_LOOK_MAX = 1000;
// §5.4, sending: "Optimise shared screen for". What a screen gives up
// first when the uplink or the encoder cannot carry all of it. The
// first is the default; sharePlan says what each one means.
@@ -397,6 +431,11 @@
let shareResharedAt = 0;
const shareCounts = { keyframes: 0, rebuilds: 0, reshares: 0, failures: 0 };
const screenLines = [];
+ // §15.26: the same ladder and mending, per peer, taken by each quality
+ // report (peer id -> counts). shareCounts above is the whole call's,
+ // for the diagnostics.
+ const shareCountsBy = new Map();
+ const reattachesBy = new Map();
// §15.23: when the stream went down (0 while it is up), and the watch.
let streamDownSince = 0;
const reconnect = reconnectRecord();
@@ -488,6 +527,24 @@
let renewing = false; // a renewal fetch is in flight
let renewDue = false; // the current run is a renewal, not a first fetch
let iceWarningEl = null;
+ // §15.26: the report token the latest `hello` carried — the authority
+ // for both report endpoints, kept after leaving because it goes on
+ // working for ten minutes after — and the quality report's own state:
+ // when the current window began (0 until admitted), each connection's
+ // reading at the last report (peer id -> {pc, reading}), the event
+ // watch, the output device's kind as last enumerated, and what has
+ // been sent. A 204 is the server's kill switch, and stops the reports
+ // for the rest of this page.
+ let reportToken = '';
+ let qualityEvery = QUALITY_EVERY;
+ let qualityFrom = 0;
+ const qualityPrev = new Map();
+ const qualityEvents = qualityWatchRecord();
+ let qualityOutput = 'unknown';
+ let qualityServerOff = false;
+ let qualityLocalOff = false; // the switch, where there is no storage to keep it in
+ let qualityFinalSent = false;
+ const quality = { sent: 0, accepted: 0, status: 0 };
// How many rosters this tab has applied. Nothing in the page reads
// it: it is the one fact a gate cannot infer from the DOM or from
// `peers`, because a connection can be closed locally without any
@@ -1450,6 +1507,10 @@
const fresh = !state.peer;
state.peer = msg.peer || null;
state.boot = msg.boot || state.boot;
+ // §15.26: a resumed stream's hello carries the same token, and a new
+ // participation's a new one; either way the latest is the one that
+ // works. A server from before reports sends none, and nothing is sent.
+ if (typeof msg.report === 'string' && /^[0-9a-f]{32}$/.test(msg.report)) reportToken = msg.report;
root.dataset.peer = state.peer || '';
if (fresh) join();
renderStatus();
@@ -4504,6 +4565,7 @@
const t = tiles.get(key);
if (t && t.video) {
screenReattaches += 1;
+ reattachesBy.set(id, (reattachesBy.get(id) || 0) + 1);
replayTile(key, t, entry, true);
}
}
@@ -4561,6 +4623,7 @@
ask.restUntil = now + SCREEN_KEYFRAME_REST;
}
shareCounts.keyframes += 1;
+ countShareStep(id, 'keyframes');
screenLog('keyframe for ' + id.slice(0, 6) + ', whose view of the screen stalled');
shareKeyframe(entry).catch(() => {});
}
@@ -4578,22 +4641,28 @@
if (action === 'rebuild') {
shareRebuiltAt.set(id, now);
shareCounts.rebuilds += 1;
+ countShareStep(id, 'rebuilds');
screenLog(id.slice(0, 6) + ' still reports a stall after keyframes: rebuilding its screen (new transceiver, same size and mode)');
if (entry) rebuildShareFor(entry);
} else if (action === 'reshare') {
reshare = true;
+ countShareStep(id, 'reshares');
screenLog(id.slice(0, 6) + ' still reports a stall' + (before === 'rebuilt' ? ' after the rebuild' : ', rebuilt under a minute ago') + ': sharing again');
} else if (action === 'failed') {
shareCounts.failures += 1;
+ countShareStep(id, 'failures');
screenLog(id.slice(0, 6) + ' still reports a stall and there is nothing left to try: giving up');
}
}
if (reshare) {
shareResharedAt = now;
shareCounts.reshares += 1;
- // One share again answers every report in flight.
- for (const ask of screenAsks.values()) {
+ // One share again answers every report in flight. For §15.26 it is
+ // counted against each of the people it answers: it was done for
+ // them, though it reached everyone.
+ for (const [askId, ask] of screenAsks) {
if (ask.stage === 'keyframes' || ask.stage === 'rebuilt') {
+ countShareStep(askId, 'reshares');
ask.stage = 'reshared';
ask.stageAt = now;
}
@@ -4622,14 +4691,23 @@
// has done, every screen arriving here as its statistics have it, and
// the timeline. Names, states and counts only: no media, no addresses,
// no credentials.
- function callDiagnostics() {
+ //
+ // §15.26: "Report a problem" sends the same text with `anonymous`, in
+ // which every person is "peer " and the first six characters of their
+ // peer id rather than their name — the server turns those into the
+ // call's random tags before anything is logged. "Copy call
+ // diagnostics" keeps the names: that text goes only where its owner
+ // pastes it.
+ function callDiagnostics(opts) {
+ const anonymous = !!(opts && opts.anonymous);
+ const who = (id) => (anonymous ? 'peer ' + id.slice(0, 6) : names.get(id) || 'Someone');
const lines = ['Calls web diagnostics, ' + new Date().toISOString()];
lines.push('Browser: ' + (navigator.userAgent || 'unknown'));
lines.push('');
lines.push('## Call');
lines.push('admitted ' + state.admitted + ', people here ' + (pcs.size + 1) + ', stopped ' + state.stopped);
for (const [id, entry] of pcs) {
- lines.push((names.get(id) || 'Someone') + ' (' + id.slice(0, 6) + '): connection ' + entry.pc.connectionState +
+ lines.push((anonymous ? who(id) : who(id) + ' (' + id.slice(0, 6) + ')') + ': connection ' + entry.pc.connectionState +
', signalling ' + entry.pc.signalingState + ', ICE ' + entry.pc.iceConnectionState);
}
lines.push('');
@@ -4638,17 +4716,16 @@
lines.push('Sharing (' + shareMode() + ')');
for (const [id, entry] of pcs) {
const r = entry.shareSend;
- const who = names.get(id) || 'Someone';
if (!r) {
- lines.push('to ' + who + ': ' + (entry.shareTx ? 'negotiating' : 'no screen transceiver'));
+ lines.push('to ' + who(id) + ': ' + (entry.shareTx ? 'negotiating' : 'no screen transceiver'));
continue;
}
- lines.push('to ' + who + ': ' + r.width + 'x' + r.height + ' at ' + Math.round(r.fps) + ' fps, encoder ' +
+ lines.push('to ' + who(id) + ': ' + r.width + 'x' + r.height + ' at ' + Math.round(r.fps) + ' fps, encoder ' +
(r.encoder || 'unknown') + ', encoded ' + r.framesEncoded + ', sent ' + r.framesSent + ', keyframes ' +
r.keyFramesEncoded + ', limitation ' + (r.limitation || 'none'));
}
for (const [id, ask] of screenAsks) {
- if (ask.stage) lines.push('recovering ' + (names.get(id) || 'Someone') + '\u2019s view: ' + ask.stage);
+ if (ask.stage) lines.push('recovering ' + who(id) + '\u2019s view: ' + ask.stage);
}
} else {
lines.push('Not sharing');
@@ -4657,9 +4734,9 @@
shareCounts.reshares + ' shares again, ' + shareCounts.failures + ' given up');
for (const [key, reading] of screenReadings) {
const id = key.slice(0, -SHARE_SUFFIX.length);
- lines.push('from ' + (names.get(id) || 'Someone') + ': ' + readingSummary(reading));
+ lines.push('from ' + who(id) + ': ' + readingSummary(reading));
}
- if (screenStalled.length) lines.push('stalled here: ' + screenStalled.map((id) => names.get(id) || 'Someone').join(', '));
+ if (screenStalled.length) lines.push('stalled here: ' + screenStalled.map(who).join(', '));
if (screenReattaches) lines.push('pictures handed to their tiles again here: ' + screenReattaches);
lines.push('');
lines.push('## Screen log (last ' + SCREEN_LOG_LINES + ' lines)');
@@ -4668,6 +4745,547 @@
return lines.join('\n');
}
+ // ---------------------------------------------------------------
+ // Quality reports and "Report a problem" (§15.26, 2026-10-03)
+ // ---------------------------------------------------------------
+ //
+ // Paul's ruling: every thirty seconds while admitted, and once more at
+ // leave, this tab tells the server how its connections went over the
+ // window — numbers and enums only, per remote peer, read off the stats
+ // walk pollFrames already makes once a second rather than a walk of its
+ // own. No name, no email, no title, no meeting, no device label, no
+ // address and nothing anyone typed: the report names a remote peer only
+ // by the peer id the server already knows, which it logs as the call's
+ // random tag in its place. The person can switch the reports off in the
+ // Devices panel; the server can switch them off for everyone (a 204).
+ //
+ // "Report a problem" is report.js's sheet; this file hands it the
+ // token, the diagnostics with the people named by short peer id, and
+ // what kind of client this is.
+
+ // qualityCeiling is a number's bound in protocol/quality.go, by its JSON
+ // name, as the server's numberCeiling has it.
+ function qualityCeiling(name) {
+ switch (name) {
+ case 'seconds': case 'reconnectingSeconds': case 'cantHearSeconds': case 'freezeSeconds':
+ return QUALITY_MAX_SECONDS;
+ case 'rtt': case 'jitter': case 'jitterBufferMs':
+ return QUALITY_MAX_MILLIS;
+ case 'kbps':
+ return QUALITY_MAX_KBPS;
+ case 'width': case 'height':
+ return QUALITY_MAX_PIXELS;
+ case 'fps':
+ return QUALITY_MAX_FPS;
+ default:
+ return QUALITY_MAX_COUNT;
+ }
+ }
+
+ // qualitySet writes one number into a report part: finite and not
+ // negative or it is left out (absent is "could not measure"), clamped
+ // to its ceiling, and to a tenth, which is all the server keeps.
+ function qualitySet(obj, name, v) {
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) return;
+ obj[name] = Math.round(Math.min(v, qualityCeiling(name)) * 10) / 10;
+ }
+
+ // qualityReading is one connection's getStats walk, kept as the next
+ // window's starting point: each RTP stream by its report id (a new SSRC
+ // is a new stream, whose counters start again from nothing) with its
+ // running counters and what it reads now, and the selected candidate
+ // pair's kinds, transport and round trip. `classify(r)` names an RTP
+ // report's stream — 'audio', 'video' or 'screen', or '' to leave it out
+ // — and a remote-inbound-rtp report (the far side's account of what we
+ // send) takes the name of the outbound stream it is about. Fields are
+ // copied by name from the lists below and nothing else is: a
+ // candidate's address and port never leave this function. Pure, and
+ // lifted by name in test/browser/room-quality.test.mjs.
+ function qualityReading(reports, classify) {
+ const COUNTERS = {
+ out: ['bytesSent', 'packetsSent'],
+ remote: ['packetsLost'],
+ in: ['bytesReceived', 'packetsReceived', 'packetsLost', 'totalSamplesReceived', 'concealedSamples',
+ 'insertedSamplesForDeceleration', 'removedSamplesForAcceleration', 'jitterBufferDelay',
+ 'jitterBufferEmittedCount', 'freezeCount', 'totalFreezesDuration', 'framesDecoded', 'framesDropped'],
+ };
+ const GAUGES = {
+ out: ['frameWidth', 'frameHeight', 'framesPerSecond', 'qualityLimitationReason'],
+ remote: ['roundTripTime', 'jitter'],
+ in: ['jitter', 'frameWidth', 'frameHeight', 'framesPerSecond'],
+ };
+ const all = [];
+ const byId = new Map();
+ if (reports && typeof reports.forEach === 'function') {
+ reports.forEach((r) => {
+ if (!r || typeof r !== 'object') return;
+ all.push(r);
+ if (r.id) byId.set(r.id, r);
+ });
+ }
+ const streams = new Map();
+ const keep = (key, dir, kind, r) => {
+ const counters = {};
+ const gauges = {};
+ for (const n of COUNTERS[dir]) if (typeof r[n] === 'number') counters[n] = r[n];
+ for (const n of GAUGES[dir]) if (typeof r[n] === 'number' || typeof r[n] === 'string') gauges[n] = r[n];
+ streams.set(key, { dir, kind, counters, gauges });
+ };
+ for (const r of all) {
+ const key = r.type + ':' + (r.id || r.ssrc || '');
+ if (r.type === 'outbound-rtp' || r.type === 'inbound-rtp') {
+ const kind = classify(r);
+ if (kind) keep(key, r.type === 'outbound-rtp' ? 'out' : 'in', kind, r);
+ } else if (r.type === 'remote-inbound-rtp') {
+ const sent = r.localId ? byId.get(r.localId) : null;
+ const kind = sent && sent.type === 'outbound-rtp' ? classify(sent) : '';
+ if (kind) keep(key, 'remote', kind, r);
+ }
+ }
+ let pair = null;
+ for (const r of all) {
+ if (r.type === 'transport' && r.selectedCandidatePairId && byId.has(r.selectedCandidatePairId)) {
+ pair = byId.get(r.selectedCandidatePairId);
+ break;
+ }
+ }
+ // Firefox has no transport report; it marks the pair itself.
+ if (!pair) pair = all.find((r) => r.type === 'candidate-pair' && r.selected === true) || null;
+ if (!pair) pair = all.find((r) => r.type === 'candidate-pair' && r.nominated && r.state === 'succeeded') || null;
+ let path = null;
+ if (pair) {
+ const kinds = ['host', 'srflx', 'prflx', 'relay'];
+ const local = byId.get(pair.localCandidateId) || {};
+ const remote = byId.get(pair.remoteCandidateId) || {};
+ // A relay candidate's own protocol is the relay's leg to the peer;
+ // how this browser reaches the relay is relayProtocol, and that is
+ // the one that says "TURN over TLS".
+ const transport = local.candidateType === 'relay' && ['udp', 'tcp', 'tls'].includes(local.relayProtocol)
+ ? local.relayProtocol
+ : ['udp', 'tcp'].includes(local.protocol) ? local.protocol : 'unknown';
+ path = {
+ local: kinds.includes(local.candidateType) ? local.candidateType : 'unknown',
+ remote: kinds.includes(remote.candidateType) ? remote.candidateType : 'unknown',
+ protocol: transport,
+ rtt: typeof pair.currentRoundTripTime === 'number' ? pair.currentRoundTripTime * 1000 : undefined,
+ };
+ }
+ return { streams, path };
+ }
+
+ // qualityPeer is one remote peer's statistics for a window: what moved
+ // between two readings `seconds` apart, per stream kind and direction.
+ // Counts are deltas — a stream `prev` does not have counts from zero,
+ // which is where its counters started — and rates are over the window;
+ // sizes, frame rates, jitter, round trips and the limitation are what
+ // the latest reading says. Two streams of one kind (a screen rebuilt on
+ // a new transceiver during the window) add up. Returns {path, rtt, out,
+ // in} with whatever parts there is something for. Pure, and lifted by
+ // name in the node test.
+ function qualityPeer(prev, now, seconds) {
+ const sums = new Map();
+ for (const [key, s] of now.streams) {
+ const p = prev && prev.streams ? prev.streams.get(key) : null;
+ const slot = s.dir + ':' + s.kind;
+ let b = sums.get(slot);
+ if (!b) {
+ b = { d: {}, g: {} };
+ sums.set(slot, b);
+ }
+ for (const n of Object.keys(s.counters)) {
+ const base = p && typeof p.counters[n] === 'number' ? p.counters[n] : 0;
+ b.d[n] = (b.d[n] || 0) + Math.max(0, s.counters[n] - base);
+ }
+ for (const n of Object.keys(s.gauges)) {
+ const v = s.gauges[n];
+ if (typeof v === 'string') {
+ if (!b.g[n] || b.g[n] === 'none') b.g[n] = v;
+ } else {
+ b.g[n] = typeof b.g[n] === 'number' ? Math.max(b.g[n], v) : v;
+ }
+ }
+ }
+ const kbps = (bytes) => (typeof bytes === 'number' && seconds > 0 ? (bytes * 8) / 1000 / seconds : undefined);
+ const ms = (s) => (typeof s === 'number' ? s * 1000 : undefined);
+ const limits = ['none', 'cpu', 'bandwidth', 'other'];
+ const out = {};
+ const inbound = {};
+ for (const kind of ['audio', 'video', 'screen']) {
+ const o = sums.get('out:' + kind);
+ const ri = sums.get('remote:' + kind);
+ if (o || ri) {
+ const s = {};
+ if (o) {
+ qualitySet(s, 'kbps', kbps(o.d.bytesSent));
+ qualitySet(s, 'packets', o.d.packetsSent);
+ if (kind !== 'audio') {
+ qualitySet(s, 'width', o.g.frameWidth);
+ qualitySet(s, 'height', o.g.frameHeight);
+ qualitySet(s, 'fps', o.g.framesPerSecond);
+ if (limits.includes(o.g.qualityLimitationReason)) s.limit = o.g.qualityLimitationReason;
+ }
+ }
+ if (ri) {
+ qualitySet(s, 'lost', ri.d.packetsLost);
+ qualitySet(s, 'rtt', ms(ri.g.roundTripTime));
+ qualitySet(s, 'jitter', ms(ri.g.jitter));
+ }
+ if (Object.keys(s).length) out[kind] = s;
+ }
+ const i = sums.get('in:' + kind);
+ if (i) {
+ const s = {};
+ qualitySet(s, 'kbps', kbps(i.d.bytesReceived));
+ qualitySet(s, 'packets', i.d.packetsReceived);
+ qualitySet(s, 'lost', i.d.packetsLost);
+ qualitySet(s, 'jitter', ms(i.g.jitter));
+ if (kind === 'audio') {
+ qualitySet(s, 'samples', i.d.totalSamplesReceived);
+ qualitySet(s, 'concealed', i.d.concealedSamples);
+ qualitySet(s, 'inserted', i.d.insertedSamplesForDeceleration);
+ qualitySet(s, 'removed', i.d.removedSamplesForAcceleration);
+ if (i.d.jitterBufferEmittedCount > 0) {
+ qualitySet(s, 'jitterBufferMs', (i.d.jitterBufferDelay / i.d.jitterBufferEmittedCount) * 1000);
+ }
+ } else {
+ qualitySet(s, 'freezes', i.d.freezeCount);
+ qualitySet(s, 'freezeSeconds', i.d.totalFreezesDuration);
+ qualitySet(s, 'decoded', i.d.framesDecoded);
+ qualitySet(s, 'dropped', i.d.framesDropped);
+ qualitySet(s, 'width', i.g.frameWidth);
+ qualitySet(s, 'height', i.g.frameHeight);
+ qualitySet(s, 'fps', i.g.framesPerSecond);
+ }
+ if (Object.keys(s).length) inbound[kind] = s;
+ }
+ }
+ const peer = {};
+ if (now.path) {
+ peer.path = { local: now.path.local, remote: now.path.remote, protocol: now.path.protocol };
+ const r = {};
+ qualitySet(r, 'rtt', now.path.rtt);
+ if ('rtt' in r) peer.rtt = r.rtt;
+ }
+ if (Object.keys(out).length) peer.out = out;
+ if (Object.keys(inbound).length) peer.in = inbound;
+ return peer;
+ }
+
+ // The notices a quality report counts (§15.23's "Reconnecting…", for
+ // the whole call and per person; §15.24's "can't hear you yet"), and
+ // the reports about sound and screens (§15.20, §15.21), watched five
+ // times a second by watchReconnect and taken by each report.
+ function qualityWatchRecord() {
+ return { at: null, call: { shown: false, count: 0, ms: 0, blocked: false }, peers: new Map() };
+ }
+
+ // qualityWatch is one look. `look` is {call, audioBlocked, peers}, peers
+ // being peer id -> {reconnecting, cantHear, noSound, noSoundFromUs,
+ // audioBlocked, screenStalled}, every one a boolean for what is true at
+ // this moment. A notice counts once each time it appears, and the time
+ // between two looks counts towards whatever was showing at the first of
+ // them, up to QUALITY_LOOK_MAX. Pure, and lifted by name in the node
+ // test.
+ function qualityWatch(rec, now, look) {
+ const dt = rec.at === null ? 0 : Math.min(Math.max(0, now - rec.at), QUALITY_LOOK_MAX);
+ rec.at = now;
+ const shown = (s, on) => {
+ if (s.shown) s.ms += dt;
+ if (on && !s.shown) s.count += 1;
+ s.shown = !!on;
+ };
+ shown(rec.call, look.call);
+ if (look.audioBlocked) rec.call.blocked = true;
+ for (const p of rec.peers.values()) p.present = false;
+ for (const [id, l] of look.peers) {
+ let p = rec.peers.get(id);
+ if (!p) {
+ p = { present: true, reconnecting: { shown: false, count: 0, ms: 0 }, cantHear: { shown: false, count: 0, ms: 0 },
+ noSound: false, noSoundFromUs: false, audioBlocked: false, screenStalled: false };
+ rec.peers.set(id, p);
+ }
+ p.present = true;
+ shown(p.reconnecting, l.reconnecting);
+ shown(p.cantHear, l.cantHear);
+ for (const k of ['noSound', 'noSoundFromUs', 'audioBlocked', 'screenStalled']) if (l[k]) p[k] = true;
+ }
+ for (const p of rec.peers.values()) {
+ if (p.present) continue;
+ shown(p.reconnecting, false);
+ shown(p.cantHear, false);
+ }
+ }
+
+ // qualityTake is the window's events, in the report's shape — only what
+ // happened, so a quiet window says nothing — and the record started on
+ // the next window. A notice still showing goes on counting there, and
+ // someone who has left is forgotten. Returns {call, peers: Map}. Pure,
+ // and lifted by name in the node test.
+ function qualityTake(rec) {
+ const secs = (ms) => ms / 1000;
+ const call = {};
+ if (rec.call.count) qualitySet(call, 'reconnecting', rec.call.count);
+ if (rec.call.ms) qualitySet(call, 'reconnectingSeconds', secs(rec.call.ms));
+ if (rec.call.blocked) call.audioBlocked = true;
+ rec.call.count = 0;
+ rec.call.ms = 0;
+ rec.call.blocked = false;
+ const peers = new Map();
+ for (const [id, p] of Array.from(rec.peers)) {
+ const e = {};
+ if (p.reconnecting.count) qualitySet(e, 'reconnecting', p.reconnecting.count);
+ if (p.reconnecting.ms) qualitySet(e, 'reconnectingSeconds', secs(p.reconnecting.ms));
+ if (p.cantHear.ms) qualitySet(e, 'cantHearSeconds', secs(p.cantHear.ms));
+ for (const k of ['noSound', 'noSoundFromUs', 'audioBlocked', 'screenStalled']) if (p[k]) e[k] = true;
+ if (Object.keys(e).length) peers.set(id, e);
+ if (!p.present) {
+ rec.peers.delete(id);
+ continue;
+ }
+ p.reconnecting.count = 0;
+ p.reconnecting.ms = 0;
+ p.cantHear.count = 0;
+ p.cantHear.ms = 0;
+ p.noSound = p.noSoundFromUs = p.audioBlocked = p.screenStalled = false;
+ }
+ return { call, peers };
+ }
+
+ // qualityReport is the body POSTed to /m/{sqid}/quality
+ // (protocol.QualityReport). `o.peers` is [{id, prev, now, ladder,
+ // reattaches, events}]: the two readings, the §15.22 ladder's steps
+ // toward that person this window ({keyframes, rebuilds, reshares,
+ // failures}), the times their screen was handed to its tile again, and
+ // qualityTake's events for them. At most QUALITY_MAX_PEERS, each by a
+ // well-formed peer id, and none with nothing to say. Steps and events
+ // that did not happen are left out rather than sent as zeros: the line
+ // the server writes has a size limit, and absent means none. Pure, and
+ // lifted by name in the node test.
+ function qualityReport(o) {
+ const report = { v: 1 };
+ if (o.final) report.final = true;
+ qualitySet(report, 'seconds', Math.max(0.1, o.seconds));
+ report.client = o.client;
+ if (o.devices) report.devices = o.devices;
+ if (o.events && Object.keys(o.events).length) report.events = o.events;
+ const peers = [];
+ for (const p of o.peers || []) {
+ if (peers.length >= QUALITY_MAX_PEERS) break;
+ if (typeof p.id !== 'string' || !/^[0-9a-f]{32}$/.test(p.id)) continue;
+ const q = qualityPeer(p.prev, p.now, o.seconds);
+ const ladder = {};
+ for (const k of ['keyframes', 'rebuilds', 'reshares', 'failures']) {
+ if (p.ladder && p.ladder[k] > 0) qualitySet(ladder, k, p.ladder[k]);
+ }
+ if (Object.keys(ladder).length) {
+ q.out = q.out || {};
+ q.out.screen = Object.assign(q.out.screen || {}, ladder);
+ }
+ if (p.reattaches > 0) {
+ q.in = q.in || {};
+ q.in.screen = q.in.screen || {};
+ qualitySet(q.in.screen, 'reattaches', p.reattaches);
+ }
+ if (p.events && Object.keys(p.events).length) q.events = p.events;
+ if (!Object.keys(q).length) continue;
+ peers.push(Object.assign({ peer: p.id }, q));
+ }
+ if (peers.length) report.peers = peers;
+ return report;
+ }
+
+ // deviceKind is a microphone's or speaker's kind, by its label, which
+ // never leaves this function: only the enum is reported. A heuristic,
+ // and an honest one — what it cannot place is 'unknown'. Pure, and
+ // lifted by name in the node test.
+ function deviceKind(label) {
+ const l = String(label || '');
+ if (!l) return 'unknown';
+ if (/airpods|bluetooth|beats|buds|hands-?free|bose|jabra|wh-1000|wf-1000|\bbt\b/i.test(l)) return 'bluetooth';
+ if (/airplay/i.test(l)) return 'airplay';
+ if (/continuity|iphone microphone/i.test(l)) return 'continuity';
+ if (/blackhole|loopback|soundflower|virtual|vb-audio|cable (?:in|out)put|zoomaudiodevice|teams audio/i.test(l)) return 'virtual';
+ if (/usb/i.test(l)) return 'usb';
+ if (/built-?in|macbook|imac|internal|microphone array|realtek/i.test(l)) return 'built-in';
+ if (/external (?:microphone|headphones)|headphones|line in|wired/i.test(l)) return 'wired';
+ return 'unknown';
+ }
+
+ // qualityOn: the person's own switch, on unless they turned it off.
+ function qualityOn() {
+ if (qualityLocalOff) return false;
+ try {
+ return localStorage.getItem(QUALITY_KEY) !== 'off';
+ } catch {
+ return true;
+ }
+ }
+
+ // countShareStep is one §15.22 step toward one person, for the report.
+ function countShareStep(id, step) {
+ let c = shareCountsBy.get(id);
+ if (!c) {
+ c = { keyframes: 0, rebuilds: 0, reshares: 0, failures: 0 };
+ shareCountsBy.set(id, c);
+ }
+ c[step] += 1;
+ }
+
+ // qualityKind classifies an RTP report on one connection: audio, the
+ // camera or the screen. Outbound by the screen transceiver's mid,
+ // inbound by frameKey, the same split the frame counts use.
+ function qualityKind(id, entry, r) {
+ const kind = r.kind || r.mediaType;
+ if (kind === 'audio') return 'audio';
+ if (kind !== 'video') return '';
+ if (r.type === 'outbound-rtp') {
+ return entry.shareTx && entry.shareTx.mid && r.mid === entry.shareTx.mid ? 'screen' : 'video';
+ }
+ const key = frameKey(id, entry, r.mid || '');
+ if (!key) return '';
+ return key === id ? 'video' : 'screen';
+ }
+
+ // refreshOutputKind finds the speaker this tab plays through — the one
+ // chosen, or the system's default — for the next report. Asynchronous
+ // (enumerateDevices), so a report carries what the last one found.
+ function refreshOutputKind() {
+ if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return;
+ navigator.mediaDevices.enumerateDevices().then((devices) => {
+ const outs = devices.filter((d) => d.kind === 'audiooutput');
+ const chosen = (HAS_SINK_ID && prefs.speaker && outs.find((d) => d.deviceId === prefs.speaker)) ||
+ outs.find((d) => d.deviceId === 'default') || outs[0] || null;
+ qualityOutput = chosen ? deviceKind(chosen.label) : 'unknown';
+ }, () => {});
+ }
+
+ function reportClient() {
+ const api = window.CallsReport;
+ return api && typeof api.client === 'function' ? api.client() : { app: 'web' };
+ }
+
+ // sendQuality closes the window and, when it may, sends its report. The
+ // window is closed whether or not anything is sent, so the next one
+ // starts from now whatever the switches said. `final` is the report at
+ // leave or at the end, sent once with keepalive so it survives the page
+ // going. Never retried, and never said to the person: a report that
+ // did not arrive is one fewer line in a log.
+ function sendQuality(final) {
+ if (!qualityFrom || (final && qualityFinalSent)) return;
+ if (final) qualityFinalSent = true;
+ const now = Date.now();
+ const seconds = (now - qualityFrom) / 1000;
+ qualityFrom = now;
+ const events = qualityTake(qualityEvents);
+ const peers = [];
+ for (const [id, entry] of pcs) {
+ if (!entry.statsReport) continue;
+ const reading = qualityReading(entry.statsReport, (r) => qualityKind(id, entry, r));
+ const was = qualityPrev.get(id);
+ qualityPrev.set(id, { pc: entry.pc, reading });
+ peers.push({
+ id,
+ prev: was && was.pc === entry.pc ? was.reading : null,
+ now: reading,
+ ladder: shareCountsBy.get(id) || null,
+ reattaches: reattachesBy.get(id) || 0,
+ events: events.peers.get(id) || null,
+ });
+ }
+ for (const id of Array.from(qualityPrev.keys())) if (!pcs.has(id)) qualityPrev.delete(id);
+ shareCountsBy.clear();
+ reattachesBy.clear();
+ refreshOutputKind();
+ if (!reportToken || qualityServerOff || !qualityOn()) return;
+ const track = local && local.getAudioTracks()[0];
+ const report = qualityReport({
+ final,
+ seconds,
+ client: reportClient(),
+ devices: {
+ input: !track || track.readyState === 'ended' ? 'none' : deviceKind(track.label),
+ output: qualityOutput,
+ },
+ events: events.call,
+ peers,
+ });
+ let body = JSON.stringify(report);
+ while (body.length > QUALITY_MAX_BODY && report.peers && report.peers.length) {
+ report.peers.pop();
+ body = JSON.stringify(report);
+ }
+ quality.sent += 1;
+ fetch('/m/' + encodeURIComponent(sqid) + '/quality', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json', 'X-Calls-Report': reportToken },
+ body,
+ keepalive: !!final,
+ }).then((res) => {
+ quality.status = res.status;
+ if (res.status === 202) quality.accepted += 1;
+ if (res.status === 204) qualityServerOff = true;
+ }, () => {
+ quality.status = 0;
+ });
+ }
+
+ // qualityTick runs after each stats walk: the window opens when this
+ // tab is first admitted, and a report closes it every qualityEvery.
+ function qualityTick() {
+ if (state.stopped || !state.admitted) return;
+ const now = Date.now();
+ if (!qualityFrom) {
+ qualityFrom = now;
+ refreshOutputKind();
+ return;
+ }
+ if (now - qualityFrom >= qualityEvery) sendQuality(false);
+ }
+
+ // qualityLook feeds the event watch what this tab is showing now.
+ function qualityLook(now) {
+ const peers = new Map();
+ for (const id of pcs.keys()) {
+ const m = remoteMedia.get(id) || null;
+ const r = connecting.peers.get(id);
+ const h = hearing.get(id);
+ peers.set(id, {
+ reconnecting: !reconnect.call && reconnect.peers.has(id),
+ cantHear: !!(r && r.shown),
+ noSound: !!(h && h.silent),
+ noSoundFromUs: !!(m && state.peer && Array.isArray(m.noSoundFrom) && m.noSoundFrom.includes(state.peer)),
+ audioBlocked: !!(m && m.audioBlocked),
+ screenStalled: screenStalled.includes(id),
+ });
+ }
+ qualityWatch(qualityEvents, now, { call: reconnect.call, audioBlocked, peers });
+ }
+
+ // problemOptions is what the sheet needs from this page: the token as
+ // it stands when Send is pressed, and the diagnostics as they stand
+ // then, with the people named by short peer id.
+ function problemOptions(returnFocus) {
+ return {
+ sqid,
+ token: () => reportToken,
+ diagnostics: () => callDiagnostics({ anonymous: true }),
+ client: reportClient,
+ returnFocus,
+ };
+ }
+
+ // openProblem opens the sheet, or says why it cannot: a page without
+ // report.js, or a server that never handed this tab a token.
+ function openProblem(returnFocus) {
+ const api = window.CallsReport;
+ if (!api || !reportToken) {
+ if (deviceNote) note('This call cannot be reported from here.');
+ return;
+ }
+ api.open(problemOptions(returnFocus));
+ }
+
// ---------------------------------------------------------------
// "Reconnecting…" (§15.23, 2026-09-30)
// ---------------------------------------------------------------
@@ -4944,6 +5562,7 @@
const wasCall = reconnect.call;
const wasPeers = Array.from(reconnect.peers).sort().join();
reconnectStep(reconnect, now, !!streamDownSince && !!state.peer, people);
+ qualityLook(now);
if (reconnect.call !== wasCall) {
console.info('calls: reconnecting ' + (reconnect.call ? 'shown (a problem that lasted)' : 'cleared'));
renderStatus();
@@ -5862,6 +6481,10 @@
function terminal(reason) {
if (state.stopped) return;
+ // §15.26: the last window's report, and the diagnostics for "Report a
+ // problem", while the connections are still there to read.
+ sendQuality(true);
+ const lastDiagnostics = callDiagnostics({ anonymous: true });
state.stopped = true;
state.terminal = reason;
// Admission ended with the participation. `peer` is kept: it names
@@ -5891,6 +6514,25 @@
chatEnabled(false);
say(TERMINAL[reason] || TERMINAL.ended);
wayOut(reason);
+ offerProblemAfter(reason, lastDiagnostics);
+ }
+
+ // offerProblemAfter puts "Report a problem" beside the way out of a
+ // call that is over (§15.26): the token goes on working for ten
+ // minutes, and the sheet carries the diagnostics as they were left.
+ // Not for a sign-in that lapsed — the report needs the session — nor
+ // for a browser that could never make a call.
+ function offerProblemAfter(reason, diagnostics) {
+ if (!reportToken || !window.CallsReport || reason === 'signedout' || reason === 'nowebrtc') return;
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.id = 'report-problem-after';
+ b.className = 'calls-report-after';
+ b.textContent = 'Report a problem';
+ b.addEventListener('click', () => {
+ window.CallsReport.open(Object.assign(problemOptions(b), { diagnostics: () => diagnostics }));
+ });
+ statusEl.append(' ', b);
}
// wayOut is the rule that no terminal screen is a dead end. Being
@@ -6482,6 +7124,46 @@
});
}
+ // §15.26: "Report a problem" from the Devices panel and from More. The
+ // panel that opened it closes first, so Escape in the sheet closes the
+ // sheet and nothing behind it, and the keyboard comes back to the bar
+ // button the panel hung from.
+ if (reportProblem) {
+ reportProblem.addEventListener('click', () => {
+ if (devicesPanel && !devicesPanel.hidden && devicesToggle) {
+ devicesPanel.hidden = true;
+ devicesToggle.setAttribute('aria-expanded', 'false');
+ }
+ openProblem(devicesToggle || reportProblem);
+ });
+ }
+ if (reportProblemMenu) {
+ reportProblemMenu.addEventListener('click', () => {
+ if (morePanel && !morePanel.hidden) {
+ morePanel.hidden = true;
+ if (moreToggle) moreToggle.setAttribute('aria-expanded', 'false');
+ }
+ openProblem(moreToggle || reportProblemMenu);
+ });
+ }
+ // "Send anonymous quality reports": on unless turned off, remembered in
+ // this browser, and read at every report, so turning it off stops the
+ // next one. "Report a problem" is a person's own choice each time and
+ // does not ask it.
+ if (qualityInput) {
+ if ($('quality-help')) qualityInput.setAttribute('aria-describedby', 'quality-help');
+ qualityInput.checked = qualityOn();
+ qualityInput.addEventListener('change', () => {
+ try {
+ if (qualityInput.checked) localStorage.removeItem(QUALITY_KEY);
+ else localStorage.setItem(QUALITY_KEY, 'off');
+ } catch {
+ // No storage: the switch holds for this page only.
+ }
+ qualityLocalOff = !qualityInput.checked;
+ });
+ }
+
leaveButton.addEventListener('click', () => {
leave();
go('/m/' + encodeURIComponent(sqid) + '/join');
@@ -6500,6 +7182,14 @@
function leave() {
if (state.leaveSent) return;
state.leaveSent = true;
+ // §15.26, before the connections go: the last window's report, and
+ // what the holding screen needs to offer "Report a problem" for the
+ // next ten minutes — the token and the diagnostics as they are now,
+ // because the page that has them is about to be gone.
+ sendQuality(true);
+ if (reportToken && window.CallsReport) {
+ window.CallsReport.remember({ sqid, token: reportToken, diagnostics: callDiagnostics({ anonymous: true }), client: reportClient() });
+ }
state.stopped = true;
clearTimeout(reconnectTimer);
clearTimeout(fullTimer);
@@ -6963,6 +7653,9 @@
for (const [id, entry] of pcs) {
try {
const stats = await entry.pc.getStats();
+ // §15.26: the quality report reads this walk rather than making
+ // one of its own.
+ entry.statsReport = stats;
const outbound = [];
// What the decoder is delivering, per tile: the fallback frame
// count where there is no requestVideoFrameCallback, and — in
@@ -7007,6 +7700,7 @@
}
watchScreens(screens, screenReports);
answerScreenStalls();
+ qualityTick();
}
function hearingState() {
@@ -7141,9 +7835,16 @@
// §15.21, for the gate: this tab's receiver watch treats every
// screen as frozen for `ms`, so its stall report and the sharer's
// keyframe can be driven end to end.
- // §15.22: "Copy call diagnostics" as it would be copied.
- diagnostics() {
- return callDiagnostics();
+ // §15.22: "Copy call diagnostics" as it would be copied; §15.26's
+ // {anonymous: true} is what "Report a problem" would send.
+ diagnostics(opts) {
+ return callDiagnostics(opts);
+ },
+ // §15.26, for the gate: the quality report's cadence, shortened from
+ // thirty seconds so a scenario sees reports in seconds. Never below
+ // QUALITY_EVERY_MIN, and nothing else about the report changes.
+ qualityEveryForTest(ms) {
+ qualityEvery = Math.max(QUALITY_EVERY_MIN, Math.min(Number(ms) || QUALITY_EVERY, QUALITY_EVERY));
},
stallScreenForTest(ms) {
screenStallTestUntil = Date.now() + Math.max(0, Math.min(Number(ms) || 0, 60000));
@@ -7257,6 +7958,18 @@
// Speaker view: who has the floor (speakerStep), by peer id.
speaker: stageSpeaker.current,
mediaExtras,
+ // §15.26: whether this tab holds a report token (never the token),
+ // the reports sent and accepted, the last answer, and the two
+ // switches — the server's (a 204) and the person's.
+ quality: {
+ token: !!reportToken,
+ sent: quality.sent,
+ accepted: quality.accepted,
+ status: quality.status,
+ serverOff: qualityServerOff,
+ on: qualityOn(),
+ every: qualityEvery,
+ },
};
},
};
diff --git a/internal/calls/templates/join.html b/internal/calls/templates/join.html
index 7de6f97..1c2bc73 100644
--- a/internal/calls/templates/join.html
+++ b/internal/calls/templates/join.html
@@ -4,6 +4,7 @@
sheet hides it, as the hidden attribute used to. */}}
<noscript><link rel="stylesheet" href="{{asset "static/noscript.css"}}"></noscript>
<script defer src="{{asset "static/devices.js"}}"></script>
+<script defer src="{{asset "static/report.js"}}"></script>
{{end}}
{{/* The holding screen. Its layout is fixed at the first paint (spec
§2's "reserved up front"): the controls and the status line come
@@ -76,8 +77,17 @@
</div>
</div>
<div class="calls-holding-more">
+ {{/* Spec §15.26: for ten minutes after leaving a call, the call can
+ still be reported from here. report.js reveals it only when this
+ tab left this meeting's call that recently, and never without
+ script, which the sheet needs. */}}
+ <div id="report-last" class="calls-report-last" hidden><button type="button" id="report-last-open" rst-btn>Report a problem with the last call</button></div>
<p><a href="{{.AppHref}}" rst-btn>Join in the app</a> <span class="calls-option-help">The Calls app must be installed to open it.</span></p>
{{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">{{icon "share"}}Share link</button></form>
<p class="calls-holding-links"><a href="/meetings/{{.Sqid}}/settings">Meeting settings</a> · <a href="/meetings/{{.Sqid}}/end">End meeting for everyone</a></p>{{end}}
+ {{/* §15.26's disclosure, where the small print is. The switch is in
+ the call's Devices panel. */}}
+ <p class="calls-option-help calls-quality-note">Calls sends anonymous connection quality reports to help fix call problems. You can turn them off under Devices during a call.</p>
</div>
+{{template "report-sheet" .}}
{{end}}
diff --git a/internal/calls/templates/partials/report.html b/internal/calls/templates/partials/report.html
new file mode 100644
index 0000000..3de6c12
--- /dev/null
+++ b/internal/calls/templates/partials/report.html
@@ -0,0 +1,48 @@
+{{/* report-sheet — "Report a problem" (spec §15.26, 2026-10-03): a
+ modal <dialog> that report.js opens, from the room (More, Devices,
+ and the words saying the call is over) and from the holding screen
+ for ten minutes after leaving. The browser's own modal brings the
+ focus trap, Escape and the inert page behind it.
+
+ Script only, like the call itself: it is never open without script,
+ and a closed <dialog> renders nothing.
+
+ The note is the vendored field envelope written out rather than
+ called, as chat's is: ui's field-textarea has no Maxlength key, and
+ the server takes a thousand characters. The hint under it is the
+ count left, and is named in aria-describedby with the sentence
+ saying what is sent, so both are read with the field.
+
+ data-calls-version is the server's build, the version a web report
+ carries (protocol.ReportClient), where the page has it. */}}
+{{define "report-sheet"}}<dialog id="report-sheet" class="calls-report" aria-labelledby="report-title"{{with .Version}} data-calls-version="{{.}}"{{end}}>
+ <form id="report-form" class="calls-report-form" novalidate>
+ <h2 id="report-title" class="calls-report-title">Report a problem</h2>
+ <div id="report-ask" class="calls-report-part">
+ <div rst-field>
+ <label rst-field-label for="report-note">What went wrong?</label>
+ <textarea rst-textarea id="report-note" name="note" rows="4" maxlength="1000" aria-describedby="report-note-hint report-disclosure"></textarea>
+ <small rst-field-hint id="report-note-hint">Optional. Up to 1,000 characters.</small>
+ </div>
+ <p id="report-disclosure" class="calls-report-disclosure">Sends this call’s connection diagnostics to the Calls server log. No audio or video.</p>
+ <p id="report-error" class="calls-report-error" role="alert" hidden></p>
+ <div class="calls-report-actions">
+ <button type="button" id="report-cancel" rst-btn>Cancel</button>
+ <button type="submit" id="report-send" rst-btn="primary">Send</button>
+ </div>
+ </div>
+ <div id="report-done" class="calls-report-part" hidden>
+ {{/* Focused when it appears, so the thanks and the reference are
+ read together. */}}
+ <div id="report-done-title" class="calls-report-done" tabindex="-1">
+ <p class="calls-report-thanks">Sent — thank you</p>
+ <p class="calls-report-reference">Reference <strong id="report-ref" class="calls-report-ref"></strong></p>
+ </div>
+ <p id="report-copied" class="calls-report-copied" role="status"></p>
+ <div class="calls-report-actions">
+ <button type="button" id="report-copy" rst-btn>Copy reference</button>
+ <button type="button" id="report-close" rst-btn="primary">Close</button>
+ </div>
+ </div>
+ </form>
+</dialog>{{end}}
diff --git a/internal/calls/templates/room.html b/internal/calls/templates/room.html
index 45cfda7..0f6b335 100644
--- a/internal/calls/templates/room.html
+++ b/internal/calls/templates/room.html
@@ -2,6 +2,7 @@
<noscript><link rel="stylesheet" href="{{asset "static/noscript.css"}}"></noscript>
<link rel="stylesheet" href="{{asset "static/chat.css"}}">
<script defer src="{{asset "static/chat.js"}}"></script>
+<script defer src="{{asset "static/report.js"}}"></script>
<script defer src="{{asset "static/room.js"}}"></script>
<script defer src="{{asset "static/call-layout.js"}}"></script>
<script defer src="{{asset "static/call-window.js"}}"></script>
@@ -170,6 +171,13 @@
states and counts only; room.js says in #device-note whether it
was copied. */}}
<button type="button" id="copy-diagnostics" rst-btn="ghost" class="calls-copy-diagnostics">Copy call diagnostics</button>
+ {{/* Spec §15.26: the same diagnostics, with the people named by
+ short peer id, sent to the server log with a note (report.js's
+ sheet); and the switch for the automatic anonymous quality
+ reports, on unless turned off in this browser. */}}
+ <button type="button" id="report-problem" rst-btn="ghost" class="calls-copy-diagnostics calls-report-problem">Report a problem</button>
+ <div id="quality-row" class="calls-quality-row">{{template "field-check" dict "Name" "quality-reports" "Label" "Send anonymous quality reports" "Checked" true}}
+ <p id="quality-help" class="calls-raw-help">Connection statistics every thirty seconds, to help fix call problems: no names, no audio or video.</p></div>
</div>
{{/* More: who you are in this call, then the account menu's items in
its order and its words (chrome.go, roomMenu) — Meetings,
@@ -185,6 +193,8 @@
{{if .Menu.Nav}}<hr>{{range .Menu.Nav}}<a href="{{.Href}}">{{.Label}}</a>{{end}}{{end}}
<hr><p class="calls-menu-heading">Help</p>
{{range .Menu.Help}}<a href="{{.Href}}">{{.Label}}</a>{{end}}
+ {{/* §15.26: report.js's sheet, opened by room.js. */}}
+ <button type="button" id="report-problem-menu">Report a problem</button>
{{with .Menu.SignOut}}<hr><form method="post" action="{{.Action}}"><button type="submit">{{.Label}}</button></form>{{end}}
{{if .IsMember}}<hr><a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener" class="calls-menu-danger">End meeting for everyone</a>{{end}}
</div>
@@ -225,5 +235,6 @@
#tile-menu for that tile. */}}
<template id="tile-tools"><button type="button" class="calls-tool calls-tool-pin" data-act="pin" aria-pressed="true">{{icon "pin"}}<span class="calls-tool-text">Pinned</span></button><button type="button" class="calls-tool calls-tool-more" aria-expanded="false" aria-controls="tile-menu">{{icon "ellipsis"}}</button></template>
</div>
+{{template "report-sheet" .}}
</main>
{{end}}
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 250627b..2362007 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -5817,6 +5817,145 @@ const run = {
await ctx.close();
},
+ // Spec §15.26: the anonymous quality reports and "Report a problem".
+ // Two members in a call, each sending its report every two seconds
+ // (the test hook; thirty in the product): Go reads its own report log
+ // and answers whether the lines share one call tag under two peer tags,
+ // name each other only by tag, and name nobody — no address, no display
+ // name, no meeting, no peer id. Then "Report a problem" the way a
+ // person reaches it, from More: the sheet, a note, Send, and the
+ // reference it shows is the one in the log. Then the switch in Devices
+ // stops the automatic reports. And last, B leaves for the holding
+ // screen, which offers the sheet for the call just left, and the report
+ // still lands: the token works for ten minutes after the participation.
+ async quality() {
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxB = await newContext('member B', { cookie: cookieB });
+ const pageA = await openRoom(ctxA);
+ const pageB = await openRoom(ctxB);
+ for (const page of [pageA, pageB]) await page.evaluate(() => window.meet.qualityEveryForTest(2000));
+ // What a report looks like on the wire, for the record, and the proof
+ // that the browser itself sends no name, address, label or meeting.
+ const bodies = [];
+ pageA.on('request', (req) => {
+ if (req.method() === 'POST' && where({ url: () => req.url() }) === '/m' && req.url().endsWith('/quality')) {
+ bodies.push(req.postData() || '');
+ }
+ });
+ const pair = await assertPair(pageA, pageB, 'member A', 'member B');
+ if (!pair) return;
+ const peers = [pair.a.peer, pair.b.peer];
+ const quality = (page) => page.evaluate(() => window.meet.state().quality);
+
+ for (const [page, label] of [[pageA, 'member A'], [pageB, 'member B']]) {
+ const q = await until(async () => {
+ const x = await quality(page);
+ return x.accepted >= 2 ? x : false;
+ }, 20000);
+ check(q, label + ' has had two quality reports accepted (' + JSON.stringify(q || (await quality(page))) + ')');
+ }
+ const wire = bodies.find((x) => x.includes('"peers"')) || bodies[0] || '';
+ line('a quality report as sent: ' + wire);
+ check(wire && !/paul|bea|example\.com|rslw|Board meeting/i.test(wire) && !wire.includes(sqid) &&
+ !/"(?:address|ip|port|label|name|url)"/.test(wire) && !/Mozilla|AppleWebKit/.test(wire),
+ 'the report on the wire carries no name, address, label, meeting or user agent');
+ const answer = await control('quality-check ' + peers.join(' '));
+ check(answer === 'ok', 'the report log has both browsers’ quality lines under one call tag, and names nobody (' + answer + ')');
+
+ // What "Report a problem" sends: people by short peer id, never by name.
+ const anon = await pageA.evaluate(() => window.meet.diagnostics({ anonymous: true }));
+ check(anon.includes('peer ' + pair.b.peer.slice(0, 6)) && !/\bbea\b|\bpaul\b/i.test(anon),
+ 'the anonymous diagnostics name the other person by short peer id and nobody by name');
+ const named = await pageA.evaluate(() => window.meet.diagnostics());
+ check(/\bbea\b/.test(named), '"Copy call diagnostics" still names people');
+
+ // The sheet, from More.
+ await pageA.click('#call-more-toggle');
+ await pageA.click('#report-problem-menu');
+ const opened = await until(() => pageA.evaluate(() => document.getElementById('report-sheet').open), 5000);
+ check(opened, 'Report a problem opens the sheet from More');
+ check(await pageA.evaluate(() => document.getElementById('call-more').hidden), 'the More menu closed behind it');
+ check(await pageA.evaluate(() => document.activeElement && document.activeElement.id === 'report-note'),
+ 'the note field has the keyboard');
+ const disclosure = await pageA.locator('#report-disclosure').textContent();
+ check(disclosure === 'Sends this call’s connection diagnostics to the Calls server log. No audio or video.',
+ 'the sheet says what it sends (' + disclosure + ')');
+ check(await pageA.getByLabel('What went wrong?').count() === 1, 'the note is labelled "What went wrong?"');
+ await pageA.fill('#report-note', 'The picture froze for a few seconds.');
+ check((await pageA.locator('#report-note-hint').textContent()).includes('964 of 1,000'),
+ 'the hint counts the characters left (' + (await pageA.locator('#report-note-hint').textContent()) + ')');
+ await pageA.click('#report-send');
+ const ref = await until(async () => {
+ const t = ((await pageA.locator('#report-ref').textContent()) || '').trim();
+ return /^[0-9A-Z]{8}$/.test(t) ? t : false;
+ }, 10000);
+ check(ref, 'the sheet shows a reference once sent (' + ((await pageA.locator('#report-ref').textContent()) || '') +
+ ' / ' + ((await pageA.locator('#report-error').textContent()) || '') + ')');
+ check((await pageA.locator('#report-done').textContent()).includes('Sent — thank you'), 'the sheet says thank you');
+ check(await pageA.evaluate(() => document.activeElement && document.activeElement.id === 'report-done-title'),
+ 'the thanks and the reference have the keyboard, so they are read');
+ if (ref) {
+ const got = await control('problem-check ' + ref + ' ' + peers.join(' '));
+ check(got.startsWith('ok'), 'the problem report is in the log under the reference shown (' + got + ')');
+ }
+ await pageA.keyboard.press('Escape');
+ check(await until(() => pageA.evaluate(() => !document.getElementById('report-sheet').open), 3000), 'Escape closes the sheet');
+ const back = await until(() => pageA.evaluate(() => {
+ const a = document.activeElement;
+ return a && a.id === 'call-more-toggle' ? a.id : false;
+ }), 2000);
+ check(back, 'the keyboard is back on More (' + (back || (await pageA.evaluate(() => {
+ const a = document.activeElement;
+ return a ? a.tagName + '#' + a.id + ' chrome=' + document.querySelector('.calls-window').dataset.chrome : 'none';
+ }))) + ')');
+ check(!(await state(pageA)).terminal && (await pageA.evaluate(() => window.meet.state().media.muted)) === false,
+ 'Escape and typing in the sheet touched neither the call nor the microphone');
+
+ // The person's switch: off, and the reports stop.
+ await pageA.click('#devices-toggle');
+ const toggle = pageA.locator('#quality-row input[type="checkbox"]');
+ check(await toggle.isChecked(), '"Send anonymous quality reports" is on by default');
+ await pageA.locator('#quality-row label').click();
+ check(!(await toggle.isChecked()) && (await pageA.evaluate(() => localStorage.getItem('calls.qualityReports'))) === 'off',
+ 'turning it off is remembered in this browser');
+ const before = (await quality(pageA)).sent;
+ await sleep(5000);
+ const after = await quality(pageA);
+ check(after.sent === before && after.on === false, 'no reports are sent while it is off (' + before + ' -> ' + after.sent + ')');
+ await pageA.locator('#quality-row label').click();
+ await pageA.click('#devices-toggle');
+
+ // After leaving: the holding screen offers the sheet for that call.
+ await leaveRoom(pageB);
+ const offered = await until(async () => await pageB.locator('#report-last-open').isVisible(), 5000);
+ check(offered, 'the holding screen offers "Report a problem with the last call" after leaving');
+ if (offered) {
+ await pageB.click('#report-last-open');
+ await pageB.fill('#report-note', 'Reported after leaving.');
+ await pageB.click('#report-send');
+ const after = await until(async () => {
+ const t = ((await pageB.locator('#report-ref').textContent()) || '').trim();
+ return /^[0-9A-Z]{8}$/.test(t) ? t : false;
+ }, 10000);
+ check(after, 'a report from the holding screen is accepted (' + ((await pageB.locator('#report-error').textContent()) || '') + ')');
+ if (after) {
+ const got = await control('problem-check ' + after + ' ' + peers.join(' '));
+ check(got.startsWith('ok') && got.includes('live false'),
+ 'the after-leave report is in the log, marked as from a participation that has ended (' + got + ')');
+ }
+ await pageB.click('#report-close');
+ }
+ const final = await control('quality-final');
+ check(final === 'ok', 'leaving sent a final quality report (' + final + ')');
+ check(await pageB.locator('.calls-quality-note').isVisible(), 'the holding screen discloses the quality reports');
+
+ check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
+ check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
+ await leaveRoom(pageA);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
async scriptless() {
const ctx = await newContext('scriptless', { cookie: cookieA, javaScriptEnabled: false });
const sentence = 'The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it.';
diff --git a/test/browser/room-quality.test.mjs b/test/browser/room-quality.test.mjs
new file mode 100644
index 0000000..cab399c
--- /dev/null
+++ b/test/browser/room-quality.test.mjs
@@ -0,0 +1,381 @@
+// room-quality.test.mjs — the anonymous quality report and "Report a
+// problem" (spec §15.26, 2026-10-03), gated without a browser.
+//
+// node --test test/browser/room-quality.test.mjs
+//
+// The report's builders are lifted out of the shipped room.js by name,
+// with the constants they read, as room-reconnect.test.mjs does; the
+// client's description and the sheet's answers out of report.js. The
+// fixtures are getStats walks as Chromium writes them, two windows
+// apart, with the fields a report must never carry — candidate
+// addresses and ports — present in them, so their absence from the
+// report is a finding and not an accident of the fixture.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const roomSrc = readFileSync(fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)), 'utf8');
+const reportSrc = readFileSync(fileURLToPath(new URL('../../internal/calls/static/report.js', import.meta.url)), 'utf8');
+
+function lift(src, file, name) {
+ const start = src.indexOf('function ' + name + '(');
+ assert.notEqual(start, -1, name + ' is not in ' + file + ' any more — this test is stale, not passing');
+ let depth = 0;
+ for (let i = src.indexOf('{', start); i < src.length; i++) {
+ if (src[i] === '{') depth += 1;
+ else if (src[i] === '}') {
+ depth -= 1;
+ if (depth === 0) return src.slice(start, i + 1);
+ }
+ }
+ assert.fail('unbalanced braces reading ' + name);
+}
+function liftConst(name) {
+ const m = roomSrc.match(new RegExp('const ' + name + ' = ([0-9.e]+);'));
+ assert.ok(m, name + ' is not a numeric const in room.js any more');
+ return 'const ' + name + ' = ' + m[1] + ';';
+}
+
+const CONSTS = ['QUALITY_MAX_PEERS', 'QUALITY_MAX_SECONDS', 'QUALITY_MAX_MILLIS', 'QUALITY_MAX_COUNT',
+ 'QUALITY_MAX_KBPS', 'QUALITY_MAX_PIXELS', 'QUALITY_MAX_FPS', 'QUALITY_LOOK_MAX'].map(liftConst).join('\n');
+const ROOM = ['qualityCeiling', 'qualitySet', 'qualityReading', 'qualityPeer', 'qualityWatchRecord',
+ 'qualityWatch', 'qualityTake', 'qualityReport', 'deviceKind'];
+// eslint-disable-next-line no-new-func
+const q = new Function(CONSTS + '\n' + ROOM.map((n) => lift(roomSrc, 'room.js', n)).join('\n') +
+ '; return { ' + ROOM.join(', ') + ' };')();
+const REPORT = ['reportClient', 'problemAnswer', 'problemBody'];
+// eslint-disable-next-line no-new-func
+const r = new Function(REPORT.map((n) => lift(reportSrc, 'report.js', n)).join('\n') +
+ '; return { ' + REPORT.join(', ') + ' };')();
+
+// The bounds must be protocol/quality.go's, or the server refuses what
+// this clamps to.
+test('the bounds are the protocol package\'s', () => {
+ const go = readFileSync(fileURLToPath(new URL('../../protocol/quality.go', import.meta.url)), 'utf8');
+ const bound = (name) => {
+ const m = go.match(new RegExp(name + '\\s*=\\s*([0-9_.e]+)(?:\\s*<<\\s*([0-9]+))?'));
+ assert.ok(m, name + ' is not in protocol/quality.go');
+ return Number(m[1].replace(/_/g, '')) * 2 ** Number(m[2] || 0);
+ };
+ const room = (name) => Number(liftConst(name).match(/= ([0-9.e]+);/)[1]);
+ assert.equal(room('QUALITY_MAX_PEERS'), bound('MaxQualityPeers'));
+ assert.equal(room('QUALITY_MAX_SECONDS'), bound('MaxQualitySeconds'));
+ assert.equal(room('QUALITY_MAX_MILLIS'), bound('MaxQualityMillis'));
+ assert.equal(room('QUALITY_MAX_COUNT'), bound('MaxQualityCount'));
+ assert.equal(room('QUALITY_MAX_KBPS'), bound('MaxQualityKbps'));
+ assert.equal(room('QUALITY_MAX_PIXELS'), bound('MaxQualityPixels'));
+ assert.equal(room('QUALITY_MAX_FPS'), bound('MaxQualityFPS'));
+ assert.equal(room('QUALITY_MAX_BODY'), bound('MaxQualityBody'));
+});
+
+// ── fixtures ─────────────────────────────────────────────────────────
+
+const PEER = 'a1b2c3d4e5f60718293a4b5c6d7e8f90';
+const OTHER = '0f1e2d3c4b5a69788796a5b4c3d2e1f0';
+
+// One connection's walk, Chromium's shape. `t` scales the counters, so
+// window two is window one plus what happened in thirty seconds.
+function walk(t) {
+ const reports = [
+ { id: 'OA', type: 'outbound-rtp', kind: 'audio', mid: '0', ssrc: 11, bytesSent: 10000 + t * 4000, packetsSent: 100 + t * 50 },
+ { id: 'OV', type: 'outbound-rtp', kind: 'video', mid: '1', ssrc: 12, bytesSent: 500000 + t * 100000, packetsSent: 1000 + t * 300,
+ frameWidth: 640, frameHeight: 480, framesPerSecond: 29.97, qualityLimitationReason: t ? 'bandwidth' : 'none' },
+ { id: 'RIA', type: 'remote-inbound-rtp', kind: 'audio', localId: 'OA', ssrc: 11, packetsLost: 2 + t * 3, roundTripTime: 0.0423, jitter: 0.0021 },
+ { id: 'RIV', type: 'remote-inbound-rtp', kind: 'video', localId: 'OV', ssrc: 12, packetsLost: 5 + t * 7, roundTripTime: 0.05, jitter: 0.004 },
+ { id: 'IA', type: 'inbound-rtp', kind: 'audio', mid: '0', ssrc: 21, bytesReceived: 20000 + t * 3750, packetsReceived: 200 + t * 1500,
+ packetsLost: 1 + t * 15, jitter: 0.0031, totalSamplesReceived: 48000 + t * 1440000, concealedSamples: 100 + t * 4800,
+ insertedSamplesForDeceleration: 10 + t * 20, removedSamplesForAcceleration: 5 + t * 30,
+ jitterBufferDelay: 2880 + t * 86400, jitterBufferEmittedCount: 48000 + t * 1440000 },
+ { id: 'IV', type: 'inbound-rtp', kind: 'video', mid: '1', ssrc: 22, bytesReceived: 400000 + t * 3000000, packetsReceived: 900 + t * 3000,
+ packetsLost: 0 + t * 30, jitter: 0.012, freezeCount: 1 + t * 2, totalFreezesDuration: 0.5 + t * 1.25, framesDecoded: 300 + t * 900,
+ framesDropped: 2 + t * 4, frameWidth: 1280, frameHeight: 720, framesPerSecond: 30 },
+ { id: 'T', type: 'transport', selectedCandidatePairId: 'CP' },
+ { id: 'CP', type: 'candidate-pair', localCandidateId: 'L', remoteCandidateId: 'R', state: 'succeeded', nominated: true,
+ currentRoundTripTime: 0.031 },
+ { id: 'L', type: 'local-candidate', candidateType: 'relay', relayProtocol: 'tls', protocol: 'udp', address: '192.0.2.44', port: 54321,
+ ip: '192.0.2.44', url: 'turns:relay.example.net:443' },
+ { id: 'R', type: 'remote-candidate', candidateType: 'srflx', protocol: 'udp', address: '198.51.100.7', port: 61000, ip: '198.51.100.7' },
+ ];
+ if (t) {
+ // A screen arriving in window two only: new streams count from zero.
+ reports.push({ id: 'IS', type: 'inbound-rtp', kind: 'video', mid: '3', ssrc: 23, bytesReceived: 750000, packetsReceived: 800,
+ packetsLost: 4, jitter: 0.02, freezeCount: 3, totalFreezesDuration: 6, framesDecoded: 150, framesDropped: 9,
+ frameWidth: 1920, frameHeight: 1080, framesPerSecond: 5 });
+ }
+ return new Map(reports.map((x) => [x.id, x]));
+}
+const classify = (x) => (x.kind === 'audio' ? 'audio' : x.mid === '3' ? 'screen' : 'video');
+const FORBIDDEN = ['192.0.2.44', '198.51.100.7', '54321', '61000', 'relay.example.net', 'address', 'port', '"ip"', 'url'];
+
+// ── the report ───────────────────────────────────────────────────────
+
+test('two windows: deltas, rates over the window, gauges as they stand', () => {
+ const one = q.qualityReading(walk(0), classify);
+ const two = q.qualityReading(walk(1), classify);
+ const p = q.qualityPeer(one, two, 30);
+
+ assert.deepEqual(p.path, { local: 'relay', remote: 'srflx', protocol: 'tls' });
+ assert.equal(p.rtt, 31);
+
+ assert.deepEqual(p.out.audio, { kbps: 1.1, packets: 50, lost: 3, rtt: 42.3, jitter: 2.1 });
+ assert.deepEqual(p.out.video, { kbps: 26.7, packets: 300, width: 640, height: 480, fps: 30, limit: 'bandwidth', lost: 7, rtt: 50, jitter: 4 });
+
+ assert.deepEqual(p.in.audio, {
+ kbps: 1, packets: 1500, lost: 15, jitter: 3.1, samples: 1440000, concealed: 4800, inserted: 20, removed: 30,
+ // 86,400 s of buffer delay summed over 1,440,000 emitted samples: 60 ms each.
+ jitterBufferMs: 60,
+ });
+ assert.deepEqual(p.in.video, {
+ kbps: 800, packets: 3000, lost: 30, jitter: 12, freezes: 2, freezeSeconds: 1.3, decoded: 900, dropped: 4,
+ width: 1280, height: 720, fps: 30,
+ });
+ // The screen was new in window two: everything it has counted is this
+ // window's.
+ assert.deepEqual(p.in.screen, {
+ kbps: 200, packets: 800, lost: 4, jitter: 20, freezes: 3, freezeSeconds: 6, decoded: 150, dropped: 9,
+ width: 1920, height: 1080, fps: 5,
+ });
+});
+
+test('the first window counts from zero, and a counter that goes back counts nothing', () => {
+ const first = q.qualityPeer(null, q.qualityReading(walk(0), classify), 10);
+ assert.equal(first.out.audio.packets, 100);
+ assert.equal(first.in.video.decoded, 300);
+ const back = q.qualityPeer(q.qualityReading(walk(1), classify), q.qualityReading(walk(0), classify), 30);
+ assert.equal(back.out.audio.packets, 0);
+ assert.equal(back.in.audio.lost, 0);
+});
+
+test('never an address, a port or a URL, whatever the walk carries', () => {
+ const report = q.qualityReport({
+ seconds: 30,
+ client: { app: 'web', browser: 'chrome' },
+ devices: { input: 'bluetooth', output: 'built-in' },
+ peers: [{ id: PEER, prev: q.qualityReading(walk(0), classify), now: q.qualityReading(walk(1), classify) }],
+ });
+ const json = JSON.stringify(report);
+ for (const bad of FORBIDDEN) assert.ok(!json.includes(bad), 'the report carries ' + bad + ': ' + json);
+ assert.equal(report.v, 1);
+ assert.equal(report.seconds, 30);
+ assert.equal(report.peers[0].peer, PEER);
+ assert.equal(report.final, undefined);
+});
+
+test('clamped to the protocol\'s ceilings; NaN, infinite and negative left out', () => {
+ const now = {
+ streams: new Map([
+ ['in:x', { dir: 'in', kind: 'video', counters: { bytesReceived: 1e12, packetsReceived: 5e9, packetsLost: -3, framesDecoded: NaN },
+ gauges: { jitter: -0.5, frameWidth: 99999, frameHeight: Infinity, framesPerSecond: 1000 } }],
+ ['remote:y', { dir: 'remote', kind: 'audio', counters: { packetsLost: 2 }, gauges: { roundTripTime: 1e6, jitter: NaN } }],
+ ]),
+ path: { local: 'host', remote: 'host', protocol: 'udp', rtt: -1 },
+ };
+ const p = q.qualityPeer(null, now, 1);
+ assert.deepEqual(p.in.video, { kbps: 1e6, packets: 1e9, lost: 0, width: 16384, fps: 240 });
+ assert.deepEqual(p.out.audio, { lost: 2, rtt: 600000 });
+ assert.equal(p.rtt, undefined);
+ const report = q.qualityReport({ seconds: 5000, client: { app: 'web' }, peers: [] });
+ assert.equal(report.seconds, 600);
+ assert.equal(report.peers, undefined);
+});
+
+test('an unknown candidate type, an unknown transport, and Firefox\'s selected pair', () => {
+ const ff = new Map([
+ ['P', { id: 'P', type: 'candidate-pair', selected: true, localCandidateId: 'L', remoteCandidateId: 'R' }],
+ ['L', { id: 'L', type: 'local-candidate', candidateType: 'weird', protocol: 'sctp', address: '10.0.0.1' }],
+ ['R', { id: 'R', type: 'remote-candidate', candidateType: 'prflx', protocol: 'tcp' }],
+ ]);
+ const p = q.qualityPeer(null, q.qualityReading(ff, classify), 30);
+ assert.deepEqual(p.path, { local: 'unknown', remote: 'prflx', protocol: 'unknown' });
+ const tcpRelay = new Map([
+ ['T', { id: 'T', type: 'transport', selectedCandidatePairId: 'P' }],
+ ['P', { id: 'P', type: 'candidate-pair', localCandidateId: 'L', remoteCandidateId: 'R' }],
+ ['L', { id: 'L', type: 'local-candidate', candidateType: 'host', protocol: 'tcp' }],
+ ['R', { id: 'R', type: 'remote-candidate', candidateType: 'host' }],
+ ]);
+ assert.equal(q.qualityPeer(null, q.qualityReading(tcpRelay, classify), 30).path.protocol, 'tcp');
+});
+
+test('at most four peers, each a well-formed peer id, none with nothing to say', () => {
+ const now = q.qualityReading(walk(0), classify);
+ const ids = ['1', '2', '3', '4', '5', '6'].map((d) => d.repeat(32));
+ const report = q.qualityReport({
+ seconds: 30,
+ client: { app: 'web' },
+ peers: [
+ { id: 'not a peer id', prev: null, now },
+ { id: OTHER, prev: null, now: { streams: new Map(), path: null } },
+ ...ids.map((id) => ({ id, prev: null, now })),
+ ],
+ });
+ assert.deepEqual(report.peers.map((p) => p.peer), ids.slice(0, 4));
+});
+
+test('the screen ladder and the mending by person, and only what happened', () => {
+ const now = q.qualityReading(walk(1), classify);
+ const report = q.qualityReport({
+ seconds: 30,
+ final: true,
+ client: { app: 'web' },
+ events: { reconnecting: 1, reconnectingSeconds: 4.2 },
+ peers: [{ id: PEER, prev: null, now, ladder: { keyframes: 3, rebuilds: 1, reshares: 0, failures: 0 }, reattaches: 1,
+ events: { cantHearSeconds: 2.5, noSoundFromUs: true } }],
+ });
+ assert.equal(report.final, true);
+ assert.deepEqual(report.events, { reconnecting: 1, reconnectingSeconds: 4.2 });
+ const p = report.peers[0];
+ assert.deepEqual(p.out.screen, { keyframes: 3, rebuilds: 1 });
+ assert.equal(p.in.screen.reattaches, 1);
+ assert.deepEqual(p.events, { cantHearSeconds: 2.5, noSoundFromUs: true });
+});
+
+// ── the notices ──────────────────────────────────────────────────────
+
+test('the notices: counted once each time they appear, timed while they show', () => {
+ const rec = q.qualityWatchRecord();
+ const quiet = { reconnecting: false, cantHear: false };
+ const look = (t, call, peer) => q.qualityWatch(rec, t, { call, audioBlocked: false, peers: new Map([[PEER, peer]]) });
+ // Can't hear us for the first 1.4 s; then a call-wide "Reconnecting…"
+ // from 5 s to 8 s; then one on this person from 10 s to 11 s.
+ for (let t = 0; t <= 15000; t += 200) {
+ look(t, t >= 5000 && t < 8000, {
+ reconnecting: t >= 10000 && t < 11000,
+ cantHear: t < 1400,
+ noSound: t === 12000,
+ });
+ }
+ const got = q.qualityTake(rec);
+ assert.deepEqual(got.call, { reconnecting: 1, reconnectingSeconds: 3 });
+ assert.deepEqual(got.peers.get(PEER), { reconnecting: 1, reconnectingSeconds: 1, cantHearSeconds: 1.4, noSound: true });
+ // A quiet window says nothing.
+ look(15200, false, quiet);
+ look(15400, false, quiet);
+ const next = q.qualityTake(rec);
+ assert.deepEqual(next.call, {});
+ assert.equal(next.peers.size, 0);
+});
+
+test('a suspended tab\'s gap is not a notice someone saw, and someone gone is forgotten', () => {
+ const rec = q.qualityWatchRecord();
+ q.qualityWatch(rec, 0, { call: true, audioBlocked: true, peers: new Map([[PEER, { reconnecting: false, cantHear: true }]]) });
+ q.qualityWatch(rec, 60000, { call: true, audioBlocked: false, peers: new Map() });
+ const got = q.qualityTake(rec);
+ assert.deepEqual(got.call, { reconnecting: 1, reconnectingSeconds: 1, audioBlocked: true });
+ assert.deepEqual(got.peers.get(PEER), { cantHearSeconds: 1 });
+ assert.equal(rec.peers.size, 0);
+});
+
+// ── the devices and the client ───────────────────────────────────────
+
+test('a device\'s kind by its label, which never leaves', () => {
+ const cases = [
+ ['Paul’s AirPods Pro', 'bluetooth'],
+ ['Bose QC35 II', 'bluetooth'],
+ ['Galaxy Buds2 Pro (Bluetooth)', 'bluetooth'],
+ ['MacBook Pro Microphone (Built-in)', 'built-in'],
+ ['Default - MacBook Air Speakers (Built-in)', 'built-in'],
+ ['Microphone Array (Realtek(R) Audio)', 'built-in'],
+ ['Scarlett 2i2 USB', 'usb'],
+ ['USB Audio Device (0d8c:0014)', 'usb'],
+ ['External Headphones', 'wired'],
+ ['BlackHole 2ch (Virtual)', 'virtual'],
+ ['Paul’s iPhone Microphone', 'continuity'],
+ ['Living Room (AirPlay)', 'airplay'],
+ ['Studio Display Microphone', 'unknown'],
+ ['', 'unknown'],
+ [undefined, 'unknown'],
+ ];
+ for (const [label, kind] of cases) assert.equal(q.deviceKind(label), kind, String(label));
+});
+
+test('the client: enums and digits from the user agent, never the string', () => {
+ const MAC_CHROME = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.50 Safari/537.36';
+ const cases = [
+ [MAC_CHROME, { platform: 'macOS', platformVersion: '15.5.0' }, 'dev', 0,
+ { app: 'web', version: 'dev', browser: 'chrome', browserVersion: '138.0', os: 'macos', osVersion: '15.5.0' }],
+ // No hints: macOS's frozen 10_15_7 is not a version.
+ [MAC_CHROME, null, 'de04e2d', 0, { app: 'web', version: 'de04e2d', browser: 'chrome', browserVersion: '138.0', os: 'macos' }],
+ ['Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1',
+ null, '', 5, { app: 'web', browser: 'safari', browserVersion: '17.4', os: 'ios', osVersion: '17.4.1' }],
+ // An iPad asking for the desktop site says Macintosh; its touch points say otherwise.
+ ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15',
+ null, '', 5, { app: 'web', browser: 'safari', browserVersion: '18.2', os: 'ipados' }],
+ ['Mozilla/5.0 (iPad; CPU OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1',
+ null, '', 5, { app: 'web', browser: 'chrome', browserVersion: '120.0', os: 'ipados', osVersion: '16.6' }],
+ ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.3351.55',
+ { platform: 'Windows', platformVersion: '19.0.0' }, '', 0,
+ { app: 'web', browser: 'edge', browserVersion: '138.0', os: 'windows', osVersion: '19.0.0' }],
+ ['Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0', null, '', 0,
+ { app: 'web', browser: 'firefox', browserVersion: '139.0', os: 'linux' }],
+ ['Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36', null, '', 5,
+ { app: 'web', browser: 'chrome', browserVersion: '137.0', os: 'android', osVersion: '10' }],
+ ['Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 OPR/120.0.0.0',
+ null, 'not a version!', 0, { app: 'web', browser: 'other', os: 'chromeos' }],
+ ['', null, '', 0, { app: 'web', browser: 'other', os: 'other' }],
+ ];
+ for (const [ua, hints, version, touch, want] of cases) {
+ const got = r.reportClient(ua, hints, version, touch);
+ assert.deepEqual(got, want, ua);
+ const json = JSON.stringify(got);
+ assert.ok(!json.includes('Mozilla') && !json.includes('AppleWebKit'), json);
+ }
+});
+
+// ── "Report a problem" ───────────────────────────────────────────────
+
+test('the sheet\'s answers', () => {
+ assert.deepEqual(r.problemAnswer(200, { ref: 'K7Q2M9PD' }), { ref: 'K7Q2M9PD', text: '' });
+ assert.equal(r.problemAnswer(200, { ref: '<b>x</b>' }).text, 'The report could not be sent.');
+ assert.equal(r.problemAnswer(429, null).text, 'Too many reports from this call — try again in a minute.');
+ assert.equal(r.problemAnswer(404, {}).text, 'This call can no longer be reported.');
+ assert.equal(r.problemAnswer(403, {}).text, 'The report could not be sent.');
+ assert.equal(r.problemAnswer(0, null).text, 'The report could not be sent.');
+});
+
+test('the problem body fits the server\'s 64 KB, cutting the diagnostics and never the note', () => {
+ const client = { app: 'web' };
+ const small = JSON.parse(r.problemBody('It froze', 'all fine', client, 65000));
+ assert.deepEqual(small, { note: 'It froze', diagnostics: 'all fine', client });
+ const big = r.problemBody('x'.repeat(1200), 'line\n'.repeat(40000), client, 65000);
+ assert.ok(new TextEncoder().encode(big).length <= 65000);
+ const parsed = JSON.parse(big);
+ assert.equal(parsed.note.length, 1000);
+ assert.ok(parsed.diagnostics.endsWith('[cut short to fit]'));
+});
+
+test('the diagnostics a problem report sends name nobody', () => {
+ const names = new Map([[PEER, 'Paul Campbell'], [OTHER, 'bea']]);
+ const pc = { connectionState: 'connected', signalingState: 'stable', iceConnectionState: 'connected' };
+ const pcs = new Map([[PEER, { pc, shareSend: { width: 1280, height: 720, fps: 30, framesEncoded: 9, framesSent: 9, keyFramesEncoded: 1 } },
+ ], [OTHER, { pc, shareSend: null, shareTx: {} }]]);
+ const env = {
+ names, pcs,
+ state: { admitted: true, stopped: false },
+ share: { stream: {}, track: {} },
+ shareMode: () => 'balanced',
+ screenAsks: new Map([[OTHER, { stage: 'keyframes' }]]),
+ shareCounts: { keyframes: 1, rebuilds: 0, reshares: 0, failures: 0 },
+ screenReadings: new Map([[PEER + '#share', { mid: '3', width: 1, height: 1 }]]),
+ readingSummary: () => 'a reading',
+ screenStalled: [PEER],
+ screenReattaches: 0,
+ screenLines: ['10:00:00.000 keyframe for ' + OTHER.slice(0, 6)],
+ SCREEN_LOG_LINES: 200,
+ SHARE_SUFFIX: '#share',
+ navigator: { userAgent: 'Test/1.0' },
+ };
+ // eslint-disable-next-line no-new-func
+ const callDiagnostics = new Function(...Object.keys(env), lift(roomSrc, 'room.js', 'callDiagnostics') +
+ '; return callDiagnostics;')(...Object.values(env));
+ const anonymous = callDiagnostics({ anonymous: true });
+ assert.ok(!/Paul|Campbell|bea\b/.test(anonymous), anonymous);
+ assert.ok(anonymous.includes('peer ' + PEER.slice(0, 6) + ': connection connected'), anonymous);
+ assert.ok(anonymous.includes('to peer ' + OTHER.slice(0, 6)), anonymous);
+ assert.ok(anonymous.includes('recovering peer ' + OTHER.slice(0, 6)), anonymous);
+ assert.ok(anonymous.includes('stalled here: peer ' + PEER.slice(0, 6)), anonymous);
+ // "Copy call diagnostics" keeps the names.
+ const named = callDiagnostics();
+ assert.ok(named.includes('Paul Campbell (' + PEER.slice(0, 6) + ')'), named);
+ assert.ok(named.includes('recovering bea'), named);
+});