oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

File too large to show in full — download it above (showing a truncated preview below).

1// call.mjs — a real call, in a real browser.
2//
3// Driven by TestCallInARealBrowser, which starts the app on a real
4// port, signs the two members in through the magic-link flow, creates
5// the meeting, and hands this script the origin, the meeting id and the
6// scenario to run.
7//
8// node test/browser/call.mjs <origin> <sqid> <scenario>
9//
10// The secrets do not travel on argv, where every process on the box can
11// read them off /proc: the session cookies and the meeting's join-link
12// token arrive in the environment, as CALLS_COOKIE_NAME, CALLS_COOKIE_A,
13// CALLS_COOKIE_B and CALLS_JOIN_TOKEN.
14//
15// One scenario per process. Every assertion prints one line; the exit
16// code is the gate.
17//
18// Two things this script cannot do for itself, and asks Go for over its
19// own stdin and stdout: read the mail recorder (a guest signs itself in
20// from the browser, and only Go can see the link that was mailed), and
21// restart the app. Both go out as a line beginning "CONTROL " and come
22// back as one line.
23//
24// Chromium's flags are not decoration. --use-fake-device-for-media-stream
25// gives every context a camera and a microphone that always work;
26// --use-fake-ui-for-media-stream answers the permission prompt;
27// --autoplay-policy=no-user-gesture-required lets a remote <video> play
28// without a click; --disable-features=WebRtcHideLocalIpsWithMdns
29// turns off mDNS candidate obfuscation, which in a container resolves to
30// nothing at all — with no ICE servers in this slice (spec §5.5), host
31// candidates are the only ones there are, and an obfuscated one connects
32// to nobody; and --auto-accept-this-tab-capture answers getDisplayMedia
33// with no picker, which the fake-ui flag does not cover (§5.4).
34import { writeSync, mkdirSync, writeFileSync } from 'node:fs';
35import { inflateSync } from 'node:zlib';
36import { createInterface } from 'node:readline';
37import { chromium } from 'playwright';
38
39const [origin, sqid, scenario] = process.argv.slice(2);
40const cookieName = process.env.CALLS_COOKIE_NAME;
41const cookieA = process.env.CALLS_COOKIE_A;
42const cookieB = process.env.CALLS_COOKIE_B;
43const token = process.env.CALLS_JOIN_TOKEN;
44
45if (!origin || !sqid || !scenario || !cookieName || !cookieA || !cookieB) {
46 console.error('usage: call.mjs <origin> <sqid> <scenario>');
47 console.error(' with CALLS_COOKIE_NAME, CALLS_COOKIE_A, CALLS_COOKIE_B and CALLS_JOIN_TOKEN in the environment');
48 process.exit(2);
49}
50
51const GUEST = 'guest@example.com';
52// A second outsider, whose only job is to be knocking while somebody
53// else is let in: the guest-isolation filters (§4.3, ruling 9) leak
54// nothing when the guest under test is the only guest in the room, so
55// whole-branch review W-1 found this scenario passing with the filter
56// deleted. Same link, so it costs one of the five sign-ins that link
57// allows in fifteen minutes.
58const WAITER = 'waiter@example.com';
59
60// The Go driver gives up at three minutes. This gives up first, so a
61// stuck scenario prints why instead of being killed silently. unref'd:
62// it must not be the reason the process stays alive.
63const HARNESS_DEADLINE = 165000;
64
65const LAUNCH_ARGS = [
66 '--use-fake-device-for-media-stream',
67 '--use-fake-ui-for-media-stream',
68 '--autoplay-policy=no-user-gesture-required',
69 '--disable-features=WebRtcHideLocalIpsWithMdns',
70 // §5.4's half of the permission story. --use-fake-ui-for-media-stream
71 // answers getUserMedia and nothing else; getDisplayMedia has its own
72 // picker, and in headless Chromium this is what accepts it without
73 // one. It picks a fake 1280x720 monitor that really produces frames —
74 // watched here at ~20/s over a loopback peer connection — so the
75 // share scenario asserts on pixels and not only on a message. The
76 // older --auto-select-desktop-capture-source needs a real display and
77 // is not reached for.
78 '--auto-accept-this-tab-capture',
79 // The relay host in the ICE scenarios' contract does not exist, and
80 // this is what makes it not exist *here*, rather than out on the
81 // internet: the name never leaves the machine, and Chromium fails it
82 // at once instead of waiting on a resolver. Both halves matter. §13's
83 // last sentence says no test touches a relay, and a DNS lookup of the
84 // platform's own relay host is touching one; and a resolution that
85 // hangs takes Chromium's network thread with it — two runs of the ICE
86 // scenario were watched failing exactly that way, with an answer that
87 // was posted and 200'd never applied by the browser that had offered.
88 // Every other scenario runs with no ICE servers at all, so this maps
89 // a name none of them ever look up.
90 '--host-resolver-rules=MAP relay.eu-west-1.oncarlos.com ~NOTFOUND',
91];
92
93// ── the scoreboard ───────────────────────────────────────────────────
94
95const failures = [];
96
97function line(s) {
98 // Written synchronously to fd 1: an assertion is evidence, and
99 // evidence that is still in a pipe buffer when the process ends is
100 // evidence nobody has. Never a line Go would read as a control
101 // request, and never a newline inside one.
102 writeSync(1, String(s).replace(/\r?\n/g, ' | ') + '\n');
103}
104
105function check(cond, what) {
106 if (cond) {
107 line('ok ' + what);
108 return true;
109 }
110 failures.push(what);
111 line('FAIL ' + what);
112 return false;
113}
114
115// where names a page without quoting a secret. A join link and a magic
116// link both live in a URL, and these lines are written to a log that
117// outlives the run: only the first path segment goes in.
118function where(page) {
119 try {
120 return '/' + (new URL(page.url()).pathname.split('/')[1] || '');
121 } catch {
122 return '(no url)';
123 }
124}
125
126// ── the control channel back to Go ───────────────────────────────────
127
128let rl = null;
129const inbox = [];
130const waiters = [];
131
132function control(verb) {
133 if (!rl) {
134 rl = createInterface({ input: process.stdin });
135 rl.on('line', (l) => {
136 const w = waiters.shift();
137 if (w) w(l);
138 else inbox.push(l);
139 });
140 }
141 line('CONTROL ' + verb);
142 return new Promise((resolve) => {
143 if (inbox.length) resolve(inbox.shift());
144 else waiters.push(resolve);
145 });
146}
147
148// ── waiting ──────────────────────────────────────────────────────────
149
150const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
151
152// until polls rather than reading once: every state this gate asserts on
153// arrives over a network and a peer connection, and a single read is a
154// race dressed up as an assertion.
155async function until(fn, ms) {
156 const deadline = Date.now() + ms;
157 for (;;) {
158 try {
159 const got = await fn();
160 if (got) return got;
161 } catch {
162 // A page mid-navigation: try again until the deadline.
163 }
164 if (Date.now() > deadline) return null;
165 await sleep(250);
166 }
167}
168
169// stayed is until's opposite, for the assertions that are about nothing
170// happening: it polls for the whole window and returns null if the
171// predicate held throughout, or the first state that broke it. A single
172// read cannot tell "did not happen" from "has not happened yet".
173async function stayed(page, ms, pred) {
174 const deadline = Date.now() + ms;
175 for (;;) {
176 const s = await state(page);
177 if (!pred(s)) return s;
178 if (Date.now() > deadline) return null;
179 await sleep(250);
180 }
181}
182
183// bothStable is `stayed` for a pair of connections rather than one
184// page's state: it watches both for the whole window and returns the
185// first side that was not settled, or null if neither ever wobbled. A
186// renegotiation still in flight leaves signalingState somewhere other
187// than `stable`, and the shape worth catching is a rollback that never
188// comes back — which a single read taken too early and one taken too
189// late would both miss, in opposite directions.
190async function bothStable(watch, ms) {
191 const deadline = Date.now() + ms;
192 for (;;) {
193 for (const w of watch) {
194 const s = await state(w.page);
195 if (s.signaling[w.other] !== 'stable') {
196 return { label: w.label, signaling: s.signaling, connection: s.connection };
197 }
198 }
199 if (Date.now() > deadline) return null;
200 await sleep(250);
201 }
202}
203
204// frameGrowth is the only honest way to ask "is this media arriving
205// *now*" (Codex S4-6). It reads the counter, and only then starts
206// waiting for it to rise, so nothing delivered before the question was
207// asked can answer it.
208//
209// Two ways the gate got this wrong. `frames[key] > 0` reads a counter
210// `dropShare` deliberately leaves behind, so a second round of sharing
211// could pass on the first round's frames with no media at all. And a
212// baseline taken *before* a gesture is answered by the frames that
213// arrived between the snapshot and the stall the gesture caused —
214// which is exactly the shape of the camera failure this slice spent two
215// fix rounds on.
216//
217// The answer carries both numbers, because a failure message that says
218// only "no frames" cannot tell a stall from a screen that never
219// started.
220async function frameGrowth(page, key, ms) {
221 const base = (await state(page)).frames[key] || 0;
222 const grown = await until(async () => {
223 const now = (await state(page)).frames[key] || 0;
224 return now > base ? now : false;
225 }, ms);
226 return { ok: grown !== null, base, now: grown === null ? (await state(page)).frames[key] || 0 : grown };
227}
228
229// growth is frameGrowth's answer as a sentence, for the check that
230// reads it.
231function growth(g) {
232 return g.base + ' -> ' + g.now;
233}
234
235// MEDIA_WINDOWS and MEDIA_WINDOW_MS are the shape of an observation:
236// three subwindows of a second and a half, and growth required in every
237// one of them. Four and a half seconds of watching, not four and a half
238// seconds of deadline.
239const MEDIA_WINDOWS = 3;
240const MEDIA_WINDOW_MS = 1500;
241
242// observe watches a set of counters over a fixed duration and requires
243// each of them to move in *every* subwindow (Codex S4-14).
244//
245// The helper it replaces was two early-return waits in a row, which is
246// a deadline and not an observation: counters reading 0, 1, 2, 3 and
247// then frozen for ever satisfied it, because it stopped looking the
248// moment it saw an increase. The failure this whole slice is about is a
249// tile that runs for a moment after a renegotiation and then stops, so
250// "it moved once" is precisely the answer that must not count.
251//
252// `read` returns every counter at once, so streams and peers are
253// watched together rather than one after another — a check that walks
254// them in turn gives each one a different window, and the last one gets
255// its window after the others have had theirs. Lifted by name in
256// test/browser/media.test.mjs, with a burst-then-freeze control.
257async function observe(read, keys, opts) {
258 const o = opts || {};
259 const windows = o.windows === undefined ? MEDIA_WINDOWS : o.windows;
260 const windowMs = o.windowMs === undefined ? MEDIA_WINDOW_MS : o.windowMs;
261 const wait = o.sleep || sleep;
262 if (!keys.length) return { ok: false, stalled: [['(nothing was expected)', 0]], series: [] };
263 const seen = [await read()];
264 const stalled = new Map();
265 for (let w = 1; w <= windows; w++) {
266 await wait(windowMs);
267 const now = await read();
268 const before = seen[seen.length - 1];
269 for (const key of keys) {
270 if ((now[key] || 0) > (before[key] || 0)) continue;
271 if (!stalled.has(key)) stalled.set(key, w);
272 }
273 seen.push(now);
274 }
275 return {
276 ok: stalled.size === 0,
277 stalled: Array.from(stalled.entries()),
278 series: keys.map((k) => k + ' ' + seen.map((s) => s[k] || 0).join('->')),
279 };
280}
281
282// watchMedia is `observe` over what a set of sides should be receiving,
283// named explicitly by the caller rather than read back off the page:
284// a check that asks the page which screens it is showing and then
285// requires those to move cannot fail for a screen that never arrived.
286//
287// Each side is {page, label, keys} — every frame key that side should
288// be receiving, the other peer's camera included.
289async function watchMedia(sides, at, opts) {
290 for (const side of sides) {
291 const correct = await side.page.evaluate(() => [...document.querySelectorAll('.calls-tile')].every(tile => {
292 const video = tile.querySelector('video');
293 if (!video) return true;
294 const transform = new DOMMatrix(getComputedStyle(video).transform);
295 const expected = tile.classList.contains('calls-tile-self') && !tile.classList.contains('calls-tile-share') ? -1 : 1;
296 return transform.a === expected;
297 }));
298 check(correct, side.label + ': only the local camera display is mirrored');
299 }
300 const keys = [];
301 for (const side of sides) {
302 for (const key of side.keys) keys.push(side.label + ' | ' + key);
303 }
304 const read = async () => {
305 const snaps = await Promise.all(sides.map((side) => state(side.page)));
306 const out = {};
307 sides.forEach((side, i) => {
308 for (const key of side.keys) out[side.label + ' | ' + key] = snaps[i].frames[key] || 0;
309 });
310 return out;
311 };
312 const seen = await observe(read, keys, opts);
313 const ok = check(seen.ok, at + 'every stream kept arriving through ' +
314 ((opts && opts.windows) || MEDIA_WINDOWS) + ' windows of ' +
315 (((opts && opts.windowMs) || MEDIA_WINDOW_MS) / 1000) + ' s (' + seen.series.join('; ') + ')');
316 if (!ok) {
317 for (const [key, window] of seen.stalled) line(' ' + key + ' stopped in window ' + window);
318 for (const side of sides) line(' ' + (await diagnose(side.page, side.label)));
319 }
320 return ok;
321}
322
323// ── the browser ──────────────────────────────────────────────────────
324
325const browser = await chromium.launch({ args: LAUNCH_ARGS });
326
327const consoleErrors = new Map(); // label -> string[]
328// Every Content-Security-Policy refusal any page reported, in any
329// context. The rig serves the app through rastrillo's production stack
330// (calls.ServeOptions), so this is the policy a deployed instance sends,
331// and a style attribute or script it blocks is a failure of the scenario
332// even when the check that would have noticed the damage is not written.
333const cspViolations = [];
334// Every console line, not only the errors: §5.2 promises that nothing
335// about the servers is logged, and a promise about logging cannot be
336// measured against the error channel alone.
337const consoleLines = new Map(); // label -> string[]
338
339// `via` is the browser to open the context in, for the one scenario
340// (autoplay) that needs a Chromium launched under a different policy.
341async function newContext(label, { cookie = null, javaScriptEnabled = true, via = browser, options = {} } = {}) {
342 const ctx = await via.newContext({ javaScriptEnabled, ...options });
343 if (cookie) {
344 await ctx.addCookies([{ name: cookieName, value: cookie, url: origin }]);
345 }
346 consoleErrors.set(label, []);
347 consoleLines.set(label, []);
348 ctx.on('page', (p) => watch(p, label));
349 // Diagnostic only, and on every context rather than the one scenario
350 // that walks getStats by hand: when a pair does not come up, what the
351 // browser thinks of its own connections is the evidence, and it
352 // cannot be collected after the fact. The trap only records what is
353 // constructed — no page behaviour changes, and nothing reads it
354 // except diagnose() and the renewal scenario's own ufrag walk.
355 if (javaScriptEnabled) await ctx.addInitScript(pcTap);
356 return ctx;
357}
358
359function watch(page, label) {
360 const errs = consoleErrors.get(label);
361 const all = consoleLines.get(label);
362 page.on('console', (m) => {
363 const text = m.text();
364 all.push(m.type() + ': ' + text);
365 if (/Content Security Policy/i.test(text)) cspViolations.push(label + ': ' + text);
366 if (m.type() === 'error') errs.push(text);
367 });
368 page.on('pageerror', (e) => {
369 const text = String(e && e.message ? e.message : e);
370 all.push('pageerror: ' + text);
371 errs.push(text);
372 });
373}
374
375function errorsFor(label) {
376 return (consoleErrors.get(label) || []).join('; ');
377}
378
379function linesFor(label) {
380 return (consoleLines.get(label) || []).join('\n');
381}
382
383// state reads window.meet.state(). frames, signaling and connection are
384// Maps in the page, so they are flattened here rather than trusting a
385// serializer to carry them.
386function state(page) {
387 return page.evaluate(() => {
388 const s = window.meet.state();
389 return {
390 peer: s.peer,
391 boot: s.boot,
392 admitted: s.admitted,
393 terminal: s.terminal,
394 rosters: s.rosters,
395 stale: s.stale,
396 peers: s.peers,
397 frames: Object.fromEntries(s.frames),
398 signaling: Object.fromEntries(s.signaling),
399 connection: Object.fromEntries(s.connection),
400 candidates: s.candidates,
401 // §5.2 and §5.3. Both are plain objects in the page — `ice`
402 // carries counts and three clocks in milliseconds since the
403 // epoch, `renewals` is keyed by peer id — so they survive
404 // page.evaluate as they are and are handed straight out.
405 ice: s.ice,
406 renewals: s.renewals,
407 // §6 and §5.4. `chat` is two counts, `shares` a list of peer ids
408 // and `sharing` a boolean, so all three survive page.evaluate as
409 // they are. `frames` above is keyed by TILE rather than by peer
410 // now, so a peer sharing a screen has a second entry under
411 // `<peer>#share`.
412 chat: s.chat,
413 sharing: s.sharing,
414 shares: s.shares,
415 // §15.20. `hearing` is keyed by tile ('self' for this tab's own):
416 // what each tile says about its audio, its levels between 0 and
417 // 1, its lit bars and any notice. All plain values.
418 hearing: s.hearing,
419 audioBlocked: s.audioBlocked,
420 soundBanner: s.soundBanner,
421 noSoundFrom: s.noSoundFrom,
422 remoteMedia: s.remoteMedia,
423 // Speaker view: who has the floor, by peer id ('' before anyone).
424 speaker: s.speaker,
425 // §15.24: who this tab says it hears, and per peer whether they
426 // are still in their first moments and the words are showing.
427 hearingList: s.hearingList,
428 connecting: s.connecting,
429 };
430 });
431}
432
433function statusText(page) {
434 return page.evaluate(() => (document.getElementById('status') || {}).textContent || '');
435}
436
437// diagnose is what a failure prints. It is deliberately more than the
438// assertion needed: this output is a fix round's evidence, and the
439// difference between "frames stayed 0" and "frames stayed 0 while the
440// connection said connected, the video element had a stream, 640px of
441// width and was not paused" is the whole diagnosis. Only the first path
442// segment of the URL goes in — see where().
443async function diagnose(page, label) {
444 try {
445 const d = await page.evaluate(async () => {
446 const s = window.meet ? window.meet.state() : null;
447 const tiles = Array.from(document.querySelectorAll('#tiles figure')).map((f) => {
448 const v = f.querySelector('video');
449 return {
450 name: (f.querySelector('figcaption span') || {}).textContent || '',
451 note: (f.querySelector('.calls-tile-note') || {}).textContent || '',
452 // §5.4: a screen and a face are the same <figure> with
453 // different content, and only the class says which.
454 share: f.classList.contains('calls-tile-share'),
455 hasStream: !!(v && v.srcObject),
456 readyState: v ? v.readyState : null,
457 videoWidth: v ? v.videoWidth : null,
458 paused: v ? v.paused : null,
459 currentTime: v ? Number(v.currentTime.toFixed(2)) : null,
460 };
461 });
462 // Every connection this page still holds, as the browser sees it
463 // rather than as room.js reports it. `local` is the decisive one
464 // when a pair does not come up: a null localDescription with a
465 // signalingState of `stable` means this connection never
466 // negotiated at all — nothing was offered, so nothing gathered —
467 // which is a different fault from one that negotiated and could
468 // not find a pair. The rest says whether there was anything to
469 // offer: a transceiver per direction and kind, and the state of
470 // the tracks behind them.
471 //
472 // And, under each connection, what the receiver actually got. A
473 // connected transport carrying no pixels is the failure this gate
474 // meets most often, and `frames: {}` alone cannot tell its three
475 // causes apart: framesReceived at zero means nothing arrived;
476 // framesReceived climbing while framesDecoded stays flat means the
477 // decoder is the problem; freezeCount and pliCount rising mean the
478 // pixels arrived and then stopped. The receiving track's own
479 // `muted` sits beside them because a muted track decodes nothing
480 // and looks, from the tile, exactly like a dead one.
481 const pcState = [];
482 for (const pc of window.__pcs || []) {
483 try {
484 if (pc.connectionState === 'closed') continue;
485 const muted = new Map();
486 for (const r of pc.getReceivers()) {
487 if (r.track) muted.set(r.track.id, r.track.muted);
488 }
489 const inbound = [];
490 const outbound = [];
491 try {
492 const report = await pc.getStats();
493 report.forEach((v) => {
494 if (v.kind !== 'video') return;
495 if (v.type === 'inbound-rtp') {
496 inbound.push({
497 ssrc: v.ssrc,
498 framesReceived: v.framesReceived,
499 framesDecoded: v.framesDecoded,
500 framesDropped: v.framesDropped,
501 pliCount: v.pliCount,
502 freezeCount: v.freezeCount,
503 muted: muted.has(v.trackIdentifier) ? muted.get(v.trackIdentifier) : null,
504 });
505 } else if (v.type === 'outbound-rtp') {
506 // The other half of the same question. Inbound alone
507 // cannot tell "this browser stopped sending" from "this
508 // browser stopped receiving", and with two video streams
509 // per connection that is the first thing to know.
510 outbound.push({
511 ssrc: v.ssrc,
512 framesSent: v.framesSent,
513 framesEncoded: v.framesEncoded,
514 active: v.active,
515 limited: v.qualityLimitationReason,
516 });
517 }
518 });
519 } catch {
520 // getStats on a connection that is closing: nothing to add.
521 }
522 pcState.push({
523 signaling: pc.signalingState,
524 connection: pc.connectionState,
525 ice: pc.iceConnectionState,
526 gathering: pc.iceGatheringState,
527 local: pc.localDescription && pc.localDescription.type,
528 remote: pc.remoteDescription && pc.remoteDescription.type,
529 senders: pc.getSenders().length,
530 // mid/wanted/negotiated:kind. `currentDirection` is the
531 // one that decides whether media flows — `direction` is
532 // only what this side asked for — and the mid says which
533 // m-line each transceiver ended up on, which is the whole
534 // question when two peers renegotiate at once.
535 transceivers: pc.getTransceivers().map((t) => (t.mid == null ? '-' : t.mid) + ' ' +
536 t.direction + '/' + (t.currentDirection || '-') + ':' +
537 ((t.receiver && t.receiver.track && t.receiver.track.kind) ||
538 (t.sender && t.sender.track && t.sender.track.kind) || '?')),
539 tracks: pc.getSenders().filter((sn) => sn.track)
540 .map((sn) => sn.track.kind + ':' + sn.track.readyState),
541 inbound,
542 outbound,
543 });
544 } catch {
545 // A connection that went away under the walk.
546 }
547 }
548
549 // §5.4: what this tab is capturing, when it is capturing at all.
550 // Read off the sender that carries it, because room.js does not
551 // put its capture on `window` and should not; `displaySurface` is
552 // the one settings key only getDisplayMedia produces, so it is
553 // also how a display track is told from a camera. Null on every
554 // page that is not sharing.
555 let displaySurface = null;
556 for (const pc of window.__pcs || []) {
557 try {
558 if (pc.connectionState === 'closed') continue;
559 for (const sn of pc.getSenders()) {
560 if (!sn.track || sn.track.kind !== 'video' || !sn.track.getSettings) continue;
561 const settings = sn.track.getSettings();
562 if (settings && settings.displaySurface) displaySurface = settings.displaySurface;
563 }
564 } catch {
565 // Same connection, same reason.
566 }
567 }
568
569 return {
570 at: '/' + (location.pathname.split('/')[1] || ''),
571 status: (document.getElementById('status') || {}).textContent || '',
572 roomHidden: (document.getElementById('room') || {}).hidden,
573 displaySurface,
574 pcState,
575 state: s
576 ? {
577 peer: s.peer,
578 admitted: s.admitted,
579 terminal: s.terminal,
580 peers: s.peers,
581 stale: s.stale,
582 rosters: s.rosters,
583 frames: Object.fromEntries(s.frames),
584 signaling: Object.fromEntries(s.signaling),
585 connection: Object.fromEntries(s.connection),
586 ice: s.ice,
587 renewals: s.renewals,
588 // Where ICE got to, per peer. The pcState above says
589 // negotiation finished; these say whether anything was
590 // ever gathered, delivered to the hub, received or added
591 // afterwards, which is the next question and the one
592 // nothing else answers.
593 candidates: s.candidates,
594 // §6 and §5.4: lines in and out, and whose screens this
595 // page is showing. A share tile that never arrived and one
596 // that arrived and stopped read the same in `tiles` below;
597 // these two say which it was.
598 chat: s.chat,
599 sharing: s.sharing,
600 shares: s.shares,
601 }
602 : null,
603 tiles,
604 queue: Array.from(document.querySelectorAll('#knocks li')).map((li) => li.textContent),
605 };
606 });
607 return label + ' ' + JSON.stringify(d) + ' console=[' + errorsFor(label) + ']';
608 } catch (e) {
609 return label + ' could not be diagnosed: ' + e.message + ' console=[' + errorsFor(label) + ']';
610 }
611}
612
613// ── the room ─────────────────────────────────────────────────────────
614
615async function openRoom(ctx) {
616 const page = await ctx.newPage();
617 await page.goto(origin + '/m/' + sqid, { waitUntil: 'domcontentloaded' });
618 // window.meet exists only once #room is in the document and the
619 // script has run to its end.
620 await page.waitForFunction(() => !!window.meet, null, { timeout: 20000 });
621 return page;
622}
623
624// connected is admitted, with at least one live peer connection. It is
625// never asserted tighter than a couple of seconds: a pair negotiates,
626// and glare runs on nearly every one of them.
627async function waitConnected(page, ms = 25000) {
628 return until(async () => {
629 const s = await state(page);
630 return s.admitted && s.peers.length >= 1 ? s : false;
631 }, ms);
632}
633
634async function waitFrames(page, peer, ms = 25000) {
635 return until(async () => {
636 const s = await state(page);
637 return (s.frames[peer] || 0) > 0 ? s : false;
638 }, ms);
639}
640
641async function waitStatus(page, text, ms = 20000) {
642 return until(async () => ((await statusText(page)).includes(text) ? true : false), ms);
643}
644
645// A pair is asserted from both ends: a call where only one side sees
646// video is not a call.
647async function assertPair(pageA, pageB, labelA, labelB, ms = 25000) {
648 const a = await waitConnected(pageA, ms);
649 if (!check(a, labelA + ' is admitted and has a peer connection — ' + (await diagnose(pageA, labelA)))) return null;
650 const b = await waitConnected(pageB, ms);
651 if (!check(b, labelB + ' is admitted and has a peer connection — ' + (await diagnose(pageB, labelB)))) return null;
652
653 check(a.peers.includes(b.peer), labelA + ' knows ' + labelB + "'s peer id (" + a.peers + ' vs ' + b.peer + ')');
654 check(b.peers.includes(a.peer), labelB + ' knows ' + labelA + "'s peer id (" + b.peers + ' vs ' + a.peer + ')');
655
656 // Negotiation settled and the transport came up, read straight off the
657 // RTCPeerConnection. Glare runs on nearly every pair here, so a
658 // signalingState still at have-local-offer is the shape a collision
659 // that never resolved would leave behind.
660 for (const [page, label, other, otherLabel] of [
661 [pageA, labelA, b.peer, labelB],
662 [pageB, labelB, a.peer, labelA],
663 ]) {
664 const settled = await until(async () => {
665 const s = await state(page);
666 return s.signaling[other] === 'stable' && s.connection[other] === 'connected' ? s : false;
667 }, ms);
668 check(settled, label + ' settled negotiation and connected to ' + otherLabel +
669 ' — ' + (await diagnose(page, label)));
670 }
671
672 const fa = await waitFrames(pageA, b.peer, ms);
673 check(fa, labelA + ' decoded a video frame from ' + labelB + ' — ' + (await diagnose(pageA, labelA)));
674 const fb = await waitFrames(pageB, a.peer, ms);
675 check(fb, labelB + ' decoded a video frame from ' + labelA + ' — ' + (await diagnose(pageB, labelB)));
676
677 return { a, b };
678}
679
680// leave is preferred over closing the context: `leave` is posted with
681// keepalive on pagehide, and a closing context may never flush it —
682// which would leave this session's one peer slot held into whatever runs
683// next.
684async function leaveRoom(page) {
685 try {
686 await page.click('#leave', { timeout: 5000 });
687 await page.waitForURL('**/m/' + sqid + '/join', { timeout: 10000 });
688 } catch {
689 // A terminal page has its controls disabled; there is nothing to
690 // leave, and the participation is already gone.
691 }
692}
693
694// The call window (2026-09-28) keeps chat, the layout and the devices
695// behind the bar's buttons, so a scenario opens them the way a person
696// does. chatMounted is whether room.js has made the panel usable — the
697// hidden attribute it owns — which is not whether the sidebar is open.
698function chatMounted(page) {
699 return page.evaluate(() => {
700 const c = document.getElementById('chat');
701 return !!c && !c.hidden;
702 });
703}
704
705async function openChat(page) {
706 await until(() => chatMounted(page), 10000);
707 if ((await page.locator('#toggle-chat').getAttribute('aria-expanded')) !== 'true') {
708 await page.locator('#toggle-chat').click();
709 }
710 await page.locator('#chat-text').waitFor({ state: 'visible', timeout: 5000 });
711}
712
713// openTileMenu opens a tile's ⋯ menu (2026-09-29): Pin, the moves and,
714// for a member, Remove live there rather than on the tile.
715async function openTileMenu(page, key) {
716 const more = page.locator('#tiles > figure[data-tile="' + key + '"] .calls-tool-more');
717 if ((await more.getAttribute('aria-expanded')) !== 'true') {
718 await page.locator('#tiles > figure[data-tile="' + key + '"]').hover();
719 await more.click();
720 }
721 await page.locator('#tile-menu').waitFor({ state: 'visible', timeout: 3000 });
722}
723
724// chooseLayout picks Speaker or Grid from the bar's layout popover, and
725// closes it again.
726async function chooseLayout(page, mode) {
727 // On a phone the layout button is in the controls sheet.
728 if (!(await page.locator('#layout-toggle').isVisible()) && await page.locator('#more-controls').isVisible()) {
729 await page.locator('#more-controls').click();
730 }
731 if (await page.locator('#layout-panel').isHidden()) await page.locator('#layout-toggle').click();
732 await page.locator('#layout-' + mode).click();
733 if (!(await page.locator('#layout-panel').isHidden())) await page.keyboard.press('Escape');
734}
735
736// cuttableStream is the instrument the three resume gates share: A's
737// *attaching* stream GET is aborted while `cut.on` is true, and only
738// that one. The probe forms go through untouched, because the whole
739// ladder §4.5 asks the client to walk — probe, read the status, then
740// attach — depends on the probe still answering; abort that too and the
741// scenario would be testing a dead network rather than a dead stream.
742//
743// The TTL header rides along for the same reason the renewal scenario
744// sends it: a stream that has already attached is not routed again, so
745// without a short life a cut would take effect five minutes from now.
746// Two seconds here rather than the renewal scenario's production-shaped
747// six, because these scenarios cut and restore within a few seconds and
748// want the cut to bite promptly.
749async function cuttableStream(ctx, ttl = '2s') {
750 const cut = { on: false, aborted: 0, attaches: 0, probeFail: false, probesFailed: 0 };
751 await ctx.route('**/stream*', async (route) => {
752 if (route.request().url().includes('probe=1')) {
753 // probeFail is the other half of the instrument: a probe that
754 // answers 503 proves nothing about anybody's participation, and a
755 // client must not act on it (§4.5's ladder, Codex S2-12).
756 if (cut.probeFail) {
757 cut.probesFailed += 1;
758 await route.fulfill({ status: 503, contentType: 'application/json', body: '{"error":"full"}' });
759 return;
760 }
761 await route.continue();
762 return;
763 }
764 if (cut.on) {
765 cut.aborted += 1;
766 await route.abort();
767 return;
768 }
769 cut.attaches += 1;
770 await route.continue({ headers: { ...route.request().headers(), 'x-calls-test-ttl': ttl } });
771 });
772 return cut;
773}
774
775// biteCut waits until the cut has actually taken an attaching GET away.
776// Setting the flag is not the cut: the stream that is already open runs
777// to its own TTL first.
778async function biteCut(cut, ms = 20000) {
779 return until(() => (cut.aborted > 0 ? cut.aborted : false), ms);
780}
781
782// ── the relay list (spec §5.2, §5.3) ─────────────────────────────────
783
784// The two sentences §5.3 puts on a screen, quoted whole rather than
785// matched loosely: a gate that grepped for "relay" would pass on either
786// of them, and they mean different things — one says this browser cannot
787// reach the relay at all, the other that one connection's credential ran
788// out before a new one landed.
789const ICE_WARNING = 'Calls could not reach the relay. Calls may not connect until it can.';
790const ICE_EXPIRED = 'The relay credential expired for this connection.';
791const ICE_STUCK = 'Could not renew this connection through the relay.';
792
793// iceRoute is the instrument the three ICE scenarios share. Every
794// GET /m/{sqid}/ice from this context goes through it, which buys three
795// things the page cannot give: the response body (the credential never
796// reaches state(), and rightly), the header that shortens the server's
797// schedule from hours to seconds, and the ability to take the relay away
798// from the second fetch on.
799//
800// The header is added to the request rather than faked into a response,
801// because /ice rides room.js's own plain `probe` fetch and what these
802// scenarios assert on is the list the real endpoint minted. Nothing here
803// bursts /ice: it spends /signal's per-session, per-room bucket, and a
804// gate that hammered it would be measuring the rate limiter.
805//
806// The TTL is always a multiple of six seconds. The server names
807// `renewAt` at five sixths of the life and `expiresAt` at the whole of
808// it, both truncated to whole seconds, and the client schedules from the
809// difference — so a life that is not a multiple of six loses the margin
810// to the truncation.
811async function iceRoute(ctx, { ttl = null, failFrom = 0 } = {}) {
812 const seen = { requests: 0, served: 0, refused: 0, servers: [], turn: [], credentials: [] };
813 await ctx.route('**/ice*', async (route) => {
814 seen.requests += 1;
815 if (failFrom && seen.requests >= failFrom) {
816 seen.refused += 1;
817 await route.fulfill({ status: 503, contentType: 'application/json', body: '{"error":"relay"}' });
818 return;
819 }
820 const headers = { ...route.request().headers() };
821 if (ttl) headers['x-calls-test-ice-ttl'] = ttl;
822 let res;
823 try {
824 res = await route.fetch({ headers });
825 } catch {
826 // The context closing under a fetch in flight: aborting is the
827 // truthful answer, and `served` shows it never landed.
828 try { await route.abort(); } catch { /* already gone */ }
829 return;
830 }
831 const body = await res.text();
832 try {
833 const list = JSON.parse(body).iceServers || [];
834 seen.served += 1;
835 seen.servers.push(list.length);
836 for (const s of list) {
837 if (s.username) seen.turn.push(s.username);
838 if (s.credential) seen.credentials.push(s.credential);
839 }
840 } catch {
841 // Not JSON at all: `served` stays where it is, and the assertions
842 // that count it say so.
843 }
844 await route.fulfill({ response: res, body });
845 });
846 return seen;
847}
848
849// pcTap keeps a reference to every RTCPeerConnection the page builds, so
850// the gate can walk getStats itself rather than taking room.js's word
851// for the generation it is on. room.js exposes state() and not
852// connections, and rightly — a peer connection handed out on `window` is
853// one anything can close. A construct trap rather than a wrapper
854// function, so the prototype and every static stay the browser's own.
855const pcTap = () => {
856 const Real = window.RTCPeerConnection;
857 if (!Real) return;
858 // Idempotent: it is installed on every context for diagnose()'s sake,
859 // and once more by hand where a scenario walks the connections itself.
860 // Wrapping the proxy in another proxy would work and reads like a bug.
861 if (window.__pcs) return;
862 window.__pcs = [];
863 // And every ICE configuration the page actually installs, which is a
864 // different fact from the list room.js says it holds: state().ice
865 // would read the same if the constructor were handed an empty array
866 // and setConfiguration were deleted (Codex S3-5). Recorded by shape
867 // and by digest — a 32-bit FNV-1a of the usernames and credentials —
868 // so the gate can say "a different credential is installed" without
869 // anything anywhere printing one.
870 window.__iceConfigs = [];
871 const digest = (servers) => {
872 const text = servers.map((v) => (v.username || '') + '|' + (v.credential || '')).join('\n');
873 let h = 2166136261;
874 for (let i = 0; i < text.length; i++) {
875 h ^= text.charCodeAt(i);
876 h = Math.imul(h, 16777619);
877 }
878 return (h >>> 0).toString(16);
879 };
880 // `pc` is the connection's index in __pcs, so a configuration can be
881 // attributed to the connection that took it: without that, repeated
882 // updates to one connection could stand in for another left on the
883 // empty list (Codex S3-10).
884 const note = (at, pc, config) => {
885 const servers = (config && config.iceServers) || [];
886 window.__iceConfigs.push({
887 at,
888 pc,
889 servers: servers.length,
890 credentialed: servers.filter((v) => v.credential).length,
891 digest: digest(servers),
892 });
893 };
894 window.RTCPeerConnection = new Proxy(Real, {
895 construct(target, args) {
896 const pc = Reflect.construct(target, args);
897 const index = window.__pcs.length;
898 window.__pcs.push(pc);
899 note('construct', index, args[0]);
900 const real = pc.setConfiguration && pc.setConfiguration.bind(pc);
901 if (real) {
902 pc.setConfiguration = (config) => {
903 // After the real call returns, never before: a
904 // setConfiguration that throws installs nothing, and
905 // recording it first counted an attempt as an installation.
906 const out = real(config);
907 note('setConfiguration', index, config);
908 return out;
909 };
910 }
911 // Every remote description this connection applies, and the
912 // state it was in when it applied it. That is what a rollback
913 // looks like from outside: perfect negotiation's polite side
914 // applies a remote *offer* while it is in `have-local-offer`, and
915 // setRemoteDescription rolls its own offer back to do it. Nothing
916 // in room.js records that, and a scenario about a late collision
917 // has to be able to prove the collision happened rather than
918 // assume a delay caused one (Codex round 2).
919 window.__rollbacks = window.__rollbacks || [];
920 const setRemote = pc.setRemoteDescription && pc.setRemoteDescription.bind(pc);
921 if (setRemote) {
922 pc.setRemoteDescription = (desc) => {
923 const before = pc.signalingState;
924 const out = setRemote(desc);
925 const type = (desc && desc.type) || '';
926 if (type === 'offer' && before === 'have-local-offer') {
927 window.__rollbacks.push({ pc: index, at: Date.now() });
928 }
929 return out;
930 };
931 }
932 return pc;
933 },
934 });
935};
936
937// rollbacks is what that trap saw: one entry per remote offer applied
938// over this side's own outstanding offer.
939function rollbacks(page) {
940 return page.evaluate(() => (window.__rollbacks || []).length);
941}
942
943// mediaIdentity is room.js's own answer to "what is this tile bound
944// to": the track each tile holds, the tracks its element is holding,
945// and what the connection's receivers say. The comparison is made in
946// the page, because object identity does not survive an evaluate().
947function mediaIdentity(page) {
948 return page.evaluate(() => (window.meet && window.meet.media && window.meet.media()) || {});
949}
950
951// installedPerConnection folds the trap's log into "what each live
952// connection is actually configured with": the last configuration it
953// took, keyed by the connection's own index.
954function installedPerConnection(configs, live) {
955 const out = new Map();
956 for (const c of configs) {
957 if (live && !live.includes(c.pc)) continue;
958 out.set(c.pc, c);
959 }
960 return Array.from(out.entries()).map(([pc, c]) => ({ pc, ...c }));
961}
962
963// iceConfigs is what the trap recorded: one entry per configuration the
964// page installed, in order, with a digest standing in for the
965// credentials it carried.
966function iceConfigs(page) {
967 return page.evaluate(() => window.__iceConfigs || []);
968}
969
970// livePcs is the indices of the connections the page still holds open,
971// so a configuration belonging to one that has since been closed and
972// rebuilt is not read as the current state of anything.
973function livePcs(page) {
974 return page.evaluate(() => (window.__pcs || [])
975 .map((pc, i) => (pc.connectionState === 'closed' ? -1 : i))
976 .filter((i) => i >= 0));
977}
978
979// selectedUfrags is §5.3 step 2's reading taken independently of the
980// client's: the transport names the selected pair, the pair names its
981// local candidate, and the candidate carries the ICE generation's
982// username fragment. A generation name, not a credential — and the same
983// walk room.js's own proof does, so the two can be compared.
984function selectedUfrags(page) {
985 return page.evaluate(async () => {
986 const out = [];
987 for (const pc of window.__pcs || []) {
988 if (pc.connectionState === 'closed') continue;
989 let report = null;
990 try {
991 report = await pc.getStats();
992 } catch {
993 continue;
994 }
995 let pairId = null;
996 report.forEach((r) => {
997 if (r.type === 'transport' && r.selectedCandidatePairId) pairId = r.selectedCandidatePairId;
998 });
999 if (!pairId) continue;
1000 const pair = report.get(pairId);
1001 if (!pair || pair.state !== 'succeeded') continue;
1002 const local = report.get(pair.localCandidateId);
1003 out.push((local && local.usernameFragment) || '');
1004 }
1005 return out;
1006 });
1007}
1008
1009// warningRegion is §5.3 step 1's live region: whether it is there, what
1010// it says, and whether a reader would be told at all — a <p> that is
1011// hidden says nothing however much text is in it.
1012function warningRegion(page) {
1013 return page.evaluate(() => {
1014 const el = document.getElementById('ice-warning');
1015 if (!el) return null;
1016 return { text: (el.textContent || '').trim(), hidden: !!el.hidden, role: el.getAttribute('role') };
1017 });
1018}
1019
1020// tileNotes is every sentence currently under a tile. There are only
1021// four it can ever be — the expired credential, the renewal that would
1022// not land, and the two failure sentences — so the whole list goes into
1023// the assertion's message.
1024function tileNotes(page) {
1025 return page.evaluate(() =>
1026 Array.from(document.querySelectorAll('#tiles figure .calls-tile-note'))
1027 .map((n) => (n.textContent || '').trim())
1028 .filter(Boolean));
1029}
1030
1031// leakedCredential answers which minted credential, if any, turned up in
1032// a console line — by index, never by value: a gate that printed the
1033// credential it caught would be the leak it was written to catch.
1034function leakedCredential(credentials, lines) {
1035 for (let i = 0; i < credentials.length; i++) {
1036 if (credentials[i] && lines.includes(credentials[i])) return i;
1037 }
1038 return -1;
1039}
1040
1041// ── the guest ────────────────────────────────────────────────────────
1042
1043// signInAsGuest is the whole outsider path from spec §3.2, driven in the
1044// browser: follow the emailed link signed out, ask for a sign-in link,
1045// and click the one that was actually mailed.
1046async function signInAsGuest(ctx, address = GUEST) {
1047 const member = await newContext('configure waiting room', { cookie: cookieA });
1048 await member.request.post(origin + '/meetings/' + sqid + '/settings', {
1049 headers: { Origin: origin }, form: { waiting_room: 'on' },
1050 });
1051 await member.close();
1052 const page = await ctx.newPage();
1053 await page.goto(origin + '/j/' + token, { waitUntil: 'domcontentloaded' });
1054 // The invitation page's way to sign in is the bar's corner (shared
1055 // chrome R2: guests get the same "Sign in" as anyone signed out).
1056 if (page.url().includes('/j/')) await page.locator('[estilo-bar-signin]').click();
1057 check(page.url().startsWith(origin + '/signin'),
1058 'the link sends a signed-out outsider to sign in (at ' + where(page) + ')');
1059
1060 await page.fill('input[name="address"]', address);
1061 await Promise.all([
1062 page.waitForURL('**/signin?sent=1', { timeout: 15000 }),
1063 page.click('form[rst-form] button[type="submit"]'),
1064 ]);
1065 check(page.url() === origin + '/signin?sent=1',
1066 'asking for a sign-in link gives the same answer every time (at ' + where(page) + ')');
1067
1068 const link = await control('guest-link ' + address);
1069 // The emailed link opens the confirm door, which spends nothing (a
1070 // mail scanner's fetch must not); its one Sign in button POSTs the
1071 // link back, and that is what signs in.
1072 await page.goto(link, { waitUntil: 'domcontentloaded' });
1073 check(await page.locator('input[name="address"]').count() === 0 &&
1074 await page.locator('form[action="/auth/verify"] button[type="submit"]').count() === 1,
1075 'the emailed link opens the confirm door: one Sign in button, no address field (at ' + where(page) + ')');
1076 await Promise.all([
1077 page.waitForURL((u) => !u.pathname.startsWith('/auth/verify'), { timeout: 15000, waitUntil: 'domcontentloaded' }),
1078 page.click('form[action="/auth/verify"] button[type="submit"]'),
1079 ]);
1080 check(page.url() === origin + '/m/' + sqid + '/join',
1081 'the emailed link lands the guest on this meeting (at ' + where(page) + ')');
1082 await page.close();
1083}
1084
1085// admitKnock clicks the Admit button on the queue entry with this
1086// display name. The queue holds more than one entry in the guest
1087// scenario, and clicking the first one would admit whoever happened to
1088// knock first.
1089async function admitKnock(page, name) {
1090 const lis = page.locator('#knocks li');
1091 const n = await lis.count();
1092 for (let i = 0; i < n; i++) {
1093 const who = ((await lis.nth(i).locator('span').first().textContent()) || '').trim();
1094 if (who === name) {
1095 await lis.nth(i).locator('button').click();
1096 return true;
1097 }
1098 }
1099 return false;
1100}
1101
1102// previewState is the holding screen's own <video>: whether it has a
1103// stream, and whether that stream is actually being decoded and played.
1104function previewState(page) {
1105 return page.evaluate(() => {
1106 const v = document.getElementById('preview');
1107 if (!v) return null;
1108 return { hasStream: !!v.srcObject, width: v.videoWidth, time: v.currentTime, paused: v.paused };
1109 });
1110}
1111
1112// remoteTile reads the first remote tile's <video> the same way, for the
1113// scenarios about a tile that is playing or not.
1114function remoteTile(page) {
1115 return page.evaluate(() => {
1116 const v = document.querySelector('#tiles figure:not(.calls-tile-self) video');
1117 if (!v) return null;
1118 return { hasStream: !!v.srcObject, width: v.videoWidth, time: v.currentTime, paused: v.paused };
1119 });
1120}
1121
1122// queueNames is the admission queue as this page has it, which is
1123// exactly what the server decided this page may know: room.js fills it
1124// from knock, knock-gone and roster.waiting and from nothing else.
1125async function queueNames(page) {
1126 return page.evaluate(() =>
1127 Array.from(document.querySelectorAll('#knocks li span')).map((s) => s.textContent.trim()));
1128}
1129
1130// ── the chat panel and the share tile ────────────────────────────────
1131
1132// chatLog is the panel's log as a reader has it: who each line is from,
1133// what it says, and whether the panel drew it as this tab's own. Read
1134// through the class names chat.js builds rather than through the ids, so
1135// the panel keeps the freedom its own report claims — a host may rename
1136// every `chat-*` id, and the classes are what the stylesheet and this
1137// gate both hang on.
1138function chatLog(page) {
1139 return page.evaluate(() =>
1140 Array.from(document.querySelectorAll('[data-chat-log] li')).map((li) => ({
1141 name: ((li.querySelector('.chat-name') || {}).textContent || '').trim(),
1142 text: ((li.querySelector('.chat-text') || {}).textContent || '').trim(),
1143 self: li.classList.contains('chat-line-self'),
1144 })));
1145}
1146
1147// selfName is the server's word for the person in this tab, read off
1148// the room element the page was rendered with. Not off their own tile:
1149// that caption is this name with " (you)" appended, and what the other
1150// contexts see — the name on an arriving chat line, the name in
1151// "<name>’s screen" — is the name without it. Reading the dataset is
1152// reading exactly what room.js reads.
1153function selfName(page) {
1154 return page.evaluate(() => {
1155 const root = document.getElementById('room');
1156 return root ? (root.dataset.selfName || '') : '';
1157 });
1158}
1159
1160// shareTiles is every screen currently on this page: its caption and
1161// whether the <video> under it has anything to play. A tile with a
1162// caption and no stream is exactly what `share {on:true}` draws before
1163// the media arrives, so the two are read together and never conflated.
1164function shareTiles(page) {
1165 return page.evaluate(() =>
1166 Array.from(document.querySelectorAll('#tiles figure.calls-tile-share')).map((f) => {
1167 const v = f.querySelector('video');
1168 return {
1169 name: ((f.querySelector('figcaption span') || {}).textContent || '').trim(),
1170 hasStream: !!(v && v.srcObject),
1171 width: v ? v.videoWidth : null,
1172 paused: v ? v.paused : null,
1173 };
1174 }));
1175}
1176
1177// capturedSurface is what this tab is actually sending as a screen:
1178// `displaySurface` off the settings of a video track it has out, which
1179// is the one key only getDisplayMedia produces. A tab sharing its
1180// camera down the share m-line would answer null here, and every other
1181// assertion about sharing would still hold — which is what makes this
1182// worth reading. Null on a page that is not sharing.
1183function capturedSurface(page) {
1184 return page.evaluate(() => {
1185 for (const pc of window.__pcs || []) {
1186 try {
1187 if (pc.connectionState === 'closed') continue;
1188 for (const sn of pc.getSenders()) {
1189 if (!sn.track || sn.track.kind !== 'video' || !sn.track.getSettings) continue;
1190 const settings = sn.track.getSettings();
1191 if (settings && settings.displaySurface) return settings.displaySurface;
1192 }
1193 } catch {
1194 // A connection that went away under the walk.
1195 }
1196 }
1197 return null;
1198 });
1199}
1200
1201// shareControl is the Share button's three cues at once. Colour is not
1202// one of them (AGENTS.md), so what is read here is the word and the
1203// attribute a screen reader announces — never the outline.
1204function shareControl(page) {
1205 return page.evaluate(() => {
1206 const b = document.getElementById('share');
1207 if (!b) return null;
1208 const s = document.getElementById('share-state');
1209 return {
1210 pressed: b.getAttribute('aria-pressed'),
1211 state: s ? (s.textContent || '').trim() : null,
1212 disabled: !!b.disabled,
1213 };
1214 });
1215}
1216
1217// tileMarks is what spec §15.19 draws on one peer's tile, read the way a
1218// person reads it: whether the muted badge and the camera-off cover are
1219// showing, and the words each carries — the words, not the colour or
1220// the icon, being the part AGENTS.md makes the contract.
1221function tileMarks(page, key) {
1222 return page.evaluate((k) => {
1223 const f = document.querySelector('#tiles figure[data-tile="' + k + '"]');
1224 if (!f) return null;
1225 const muted = f.querySelector('.calls-tile-muted');
1226 const off = f.querySelector('.calls-tile-off');
1227 const shown = (el) => !!(el && !el.hidden && el.getClientRects().length);
1228 return {
1229 muted: shown(muted),
1230 mutedText: muted ? (muted.textContent || '').trim() : '',
1231 cameraOff: shown(off),
1232 offText: off ? (off.textContent || '').trim() : '',
1233 caption: ((f.querySelector('figcaption') || {}).textContent || '').trim(),
1234 };
1235 }, key);
1236}
1237
1238// refuseFirstPlay is an init script: the first unmuted play() on the
1239// page is refused the way a blocking autoplay policy refuses it, and the
1240// element held paused until a later play() — which only a gesture
1241// should be asking for.
1242const refuseFirstPlay = () => {
1243 const real = HTMLMediaElement.prototype.play;
1244 let refused = 0;
1245 let blocked = null;
1246 // A rejected promise on its own is not what a blocking autoplay
1247 // policy does: the element also stays paused. Rejecting and
1248 // letting the element play anyway (its own `autoplay` attribute
1249 // does that, and the launch flag permits it) left every "the
1250 // gesture started the video" assertion true before the gesture
1251 // as well, which is the hole Codex S2-9 is about. So the refusal
1252 // pauses the element and keeps it paused until the gesture.
1253 const holdPaused = (e) => {
1254 if (blocked === e.target) e.target.pause();
1255 };
1256 HTMLMediaElement.prototype.play = function play() {
1257 if (!this.muted && refused < 1) {
1258 refused += 1;
1259 blocked = this;
1260 this.autoplay = false;
1261 this.pause();
1262 this.addEventListener('play', holdPaused);
1263 return Promise.reject(new DOMException('autoplay refused', 'NotAllowedError'));
1264 }
1265 if (blocked === this) {
1266 blocked = null;
1267 this.removeEventListener('play', holdPaused);
1268 }
1269 return real.apply(this, arguments);
1270 };
1271};
1272
1273// glowOf is the warm rim on a tile's name label as it is drawn (0.4.3):
1274// whether it is lit, its --lvl and --on, the shadow the browser computed
1275// for the label's ::after from them, and the most opaque of that shadow's
1276// layers — plus whether anything of the ring or the meter it replaced is
1277// left on the tile.
1278function glowOf(page, key) {
1279 return page.evaluate((k) => {
1280 const figure = document.querySelector('#tiles figure[data-tile="' + k + '"]');
1281 const label = figure && figure.querySelector('.calls-tile-name');
1282 if (!label) return null;
1283 const style = getComputedStyle(label);
1284 const shadow = getComputedStyle(label, '::after').boxShadow;
1285 let alpha = 0;
1286 for (const m of shadow.matchAll(/rgba?\(([^)]*)\)/g)) {
1287 const parts = m[1].split(',').map(Number);
1288 alpha = Math.max(alpha, parts.length === 4 ? parts[3] : 1);
1289 }
1290 return {
1291 lit: label.dataset.glow === 'on',
1292 glow: Number(style.getPropertyValue('--lvl')) || 0,
1293 on: Number(style.getPropertyValue('--on')) || 0,
1294 shadow,
1295 alpha,
1296 // The old ring was an inset shadow on the figure (a floating self
1297 // view's own drop shadow is not one).
1298 ring: figure.classList.contains('calls-tile-speaking') || /inset/.test(getComputedStyle(figure).boxShadow),
1299 meter: !!figure.querySelector('.calls-meter'),
1300 };
1301 }, key);
1302}
1303
1304// speakInto replaces a page's microphone on every connection with a
1305// steady tone, loud enough to be speech to the glow's gate. Chromium's
1306// fake microphone only beeps, and a beep is exactly the brief noise the
1307// glow is built to ignore. stopSpeaking puts the microphone back.
1308function speakInto(page) {
1309 return page.evaluate(async () => {
1310 const ctx = new AudioContext();
1311 const osc = ctx.createOscillator();
1312 const gain = ctx.createGain();
1313 gain.gain.value = 0.3;
1314 osc.frequency.value = 220;
1315 const dest = ctx.createMediaStreamDestination();
1316 osc.connect(gain).connect(dest);
1317 osc.start();
1318 await ctx.resume();
1319 const tone = dest.stream.getAudioTracks()[0];
1320 window.__toneRestore = [];
1321 for (const pc of window.__pcs || []) {
1322 for (const sn of pc.getSenders()) {
1323 if (sn.track && sn.track.kind === 'audio') {
1324 window.__toneRestore.push([sn, sn.track]);
1325 await sn.replaceTrack(tone);
1326 }
1327 }
1328 }
1329 window.__toneCtx = ctx;
1330 return window.__toneRestore.length;
1331 });
1332}
1333function stopSpeaking(page) {
1334 return page.evaluate(async () => {
1335 for (const [sn, track] of window.__toneRestore || []) await sn.replaceTrack(track);
1336 if (window.__toneCtx) await window.__toneCtx.close();
1337 window.__toneRestore = [];
1338 });
1339}
1340
1341function mediaLive(page) {
1342 return page.evaluate(() => ((document.getElementById('media-live') || {}).textContent || '').trim());
1343}
1344
1345// ── ink ───────────────────────────────────────────────────────────────
1346//
1347// Where a glyph's ink is, read off a screenshot the way Paul read the
1348// Mac bar's (0.4.1): the centroid of the pixels brighter than the pane
1349// under them, weighted by how much brighter. The PNG is Chromium's own
1350// (8-bit, RGB or RGBA, not interlaced), so a decoder this small reads it.
1351
1352function decodePNG(buf) {
1353 let at = 8;
1354 let width = 0, height = 0, type = 0;
1355 const idat = [];
1356 while (at < buf.length) {
1357 const len = buf.readUInt32BE(at);
1358 const kind = buf.toString('latin1', at + 4, at + 8);
1359 const body = buf.subarray(at + 8, at + 8 + len);
1360 if (kind === 'IHDR') {
1361 width = body.readUInt32BE(0);
1362 height = body.readUInt32BE(4);
1363 if (body[8] !== 8 || body[12] !== 0) throw new Error('decodePNG: not 8-bit non-interlaced');
1364 type = body[9];
1365 } else if (kind === 'IDAT') idat.push(body);
1366 at += 12 + len;
1367 }
1368 const bpp = type === 6 ? 4 : type === 2 ? 3 : 0;
1369 if (!bpp) throw new Error('decodePNG: colour type ' + type);
1370 const raw = inflateSync(Buffer.concat(idat));
1371 const stride = width * bpp;
1372 const px = Buffer.alloc(height * stride);
1373 for (let y = 0; y < height; y++) {
1374 const f = raw[y * (stride + 1)];
1375 const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
1376 for (let x = 0; x < stride; x++) {
1377 const a = x >= bpp ? px[y * stride + x - bpp] : 0;
1378 const b = y > 0 ? px[(y - 1) * stride + x] : 0;
1379 const c = x >= bpp && y > 0 ? px[(y - 1) * stride + x - bpp] : 0;
1380 let v = row[x];
1381 if (f === 1) v += a;
1382 else if (f === 2) v += b;
1383 else if (f === 3) v += (a + b) >> 1;
1384 else if (f === 4) {
1385 const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
1386 v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
1387 }
1388 px[y * stride + x] = v & 255;
1389 }
1390 }
1391 const lum = (x, y) => {
1392 const i = y * stride + x * bpp;
1393 return 0.2126 * px[i] + 0.7152 * px[i + 1] + 0.0722 * px[i + 2];
1394 };
1395 const rgb = (x, y) => {
1396 const i = y * stride + x * bpp;
1397 return [px[i], px[i + 1], px[i + 2]];
1398 };
1399 return { width, height, lum, rgb };
1400}
1401
1402// contrastOnWhite is WCAG's contrast ratio of white text on a colour.
1403function contrastOnWhite([r, g, b]) {
1404 const lin = (c) => {
1405 const v = c / 255;
1406 return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
1407 };
1408 const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
1409 return 1.05 / (L + 0.05);
1410}
1411
1412// sampleColour is the average colour of a small square of the page as
1413// drawn, in page CSS pixels.
1414async function sampleColour(page, x, y, size = 4) {
1415 const img = decodePNG(await page.screenshot({ clip: { x: x - size / 2, y: y - size / 2, width: size, height: size } }));
1416 const sum = [0, 0, 0];
1417 for (let j = 0; j < img.height; j++) {
1418 for (let i = 0; i < img.width; i++) {
1419 const c = img.rgb(i, j);
1420 sum[0] += c[0]; sum[1] += c[1]; sum[2] += c[2];
1421 }
1422 }
1423 const n = img.width * img.height;
1424 return sum.map((v) => Math.round(v / n));
1425}
1426
1427// inkCentroid screenshots `box` (page CSS pixels, at the page's own
1428// device scale) and returns the ink's vertical centroid in page CSS
1429// pixels, or null if there was no ink.
1430async function inkCentroid(page, box, scale = 1) {
1431 const pad = 2;
1432 const clip = { x: box.x - pad, y: box.y - pad, width: box.width + 2 * pad, height: box.height + 2 * pad };
1433 const img = decodePNG(await page.screenshot({ clip }));
1434 let floor = 255;
1435 for (let y = 0; y < img.height; y++) for (let x = 0; x < img.width; x++) floor = Math.min(floor, img.lum(x, y));
1436 let total = 0, moment = 0;
1437 for (let y = 0; y < img.height; y++) {
1438 for (let x = 0; x < img.width; x++) {
1439 const w = Math.max(0, img.lum(x, y) - floor - 8);
1440 total += w;
1441 moment += w * (y + 0.5);
1442 }
1443 }
1444 if (!total) return null;
1445 return clip.y + moment / total / scale;
1446}
1447
1448// ── the scenarios ────────────────────────────────────────────────────
1449
1450const run = {
1451 async guestdirect() {
1452 const member = await newContext('settings owner', { cookie: cookieA, javaScriptEnabled: false });
1453 const settings = await member.newPage();
1454 await settings.goto(origin + '/settings');
1455 check(!(await settings.getByLabel('Require sign-in to join').isChecked()), 'new meeting defaults permit guests');
1456 check(!(await settings.getByLabel('Enable waiting room').isChecked()), 'waiting room defaults off');
1457 await settings.screenshot({ path: '/tmp/calls-settings.png', fullPage: true });
1458 await member.close();
1459 const guest = await newContext('anonymous guest');
1460 const page = await guest.newPage();
1461 await page.goto(origin + '/j/' + token);
1462 check((await page.getByRole('link', { name: 'Join in the app' }).getAttribute('href')).startsWith('oficina-calls://join?'), 'invitation offers the native app');
1463 await page.getByLabel('Your name').fill('Visitor');
1464 await Promise.all([
1465 page.waitForURL('**/m/' + sqid + '/join'),
1466 page.getByRole('button', { name: 'Continue in browser' }).click(),
1467 ]);
1468 check(await page.getByRole('link', { name: 'Join in the app' }).count() === 1, 'holding screen offers the native app');
1469 await page.locator('#join').click();
1470 check(await until(async () => (await state(page)).admitted, 20000), 'anonymous guest joins directly without sign-in or admission');
1471 await guest.close();
1472 },
1473 // The holding screen reaches real hardware: it enumerates devices,
1474 // previews one, and lets the person in.
1475 async devices() {
1476 const ctx = await newContext('member A', { cookie: cookieA });
1477 const page = await ctx.newPage();
1478 await page.goto(origin + '/m/' + sqid + '/join', { waitUntil: 'domcontentloaded' });
1479
1480 const revealed = await until(async () => !(await page.locator('#devices').isHidden()), 10000);
1481 check(revealed, 'the holding screen reveals its devices block');
1482
1483 const options = await until(async () => {
1484 const n = await page.locator('#pick-camera option').count();
1485 return n > 0 ? n : false;
1486 }, 10000);
1487 check(options, 'the camera picker lists at least one camera (got ' + options + ')');
1488
1489 const enabled = await until(async () => await page.locator('#join').isEnabled(), 10000);
1490 check(enabled, 'the Join button is enabled within ten seconds — status: ' + (await page.locator('#status').textContent()));
1491
1492 // A truthy srcObject was all this used to ask (Codex S2-9), and a
1493 // preview that never decoded a pixel satisfies that. Real dimensions
1494 // and a clock that moves are what "previewing" means.
1495 const preview = await until(async () => {
1496 const p = await previewState(page);
1497 return p && p.hasStream && p.width > 0 ? p : false;
1498 }, 15000);
1499 check(preview, 'the preview is decoding real frames (' + JSON.stringify(preview || (await previewState(page))) + ')');
1500 check(await page.locator('#preview').evaluate(video => new DOMMatrix(getComputedStyle(video).transform).a === -1), 'the holding camera preview is mirrored');
1501 if (preview) {
1502 const rising = await until(async () => {
1503 const p = await previewState(page);
1504 return p && p.time > preview.time ? p : false;
1505 }, 10000);
1506 check(rising, 'the preview is playing rather than holding one frame (' +
1507 JSON.stringify(rising || (await previewState(page))) + ')');
1508 }
1509
1510 check(errorsFor('member A') === '', 'no console errors on the holding screen (' + errorsFor('member A') + ')');
1511 await ctx.close();
1512 },
1513
1514 // Spec §15.20: the holding screen's audio test. The fake microphone
1515 // beeps, so the microphone test says it works; the test sound plays;
1516 // and the playback test records, plays back and throws the recording
1517 // away — with no request leaving the page while it does, which is the
1518 // promise the section makes in words.
1519 async audiotest() {
1520 const ctx = await newContext('member A', { cookie: cookieA });
1521 const page = await ctx.newPage();
1522 const requests = [];
1523 page.on('request', (r) => {
1524 const body = r.postDataBuffer();
1525 requests.push({ method: r.method(), path: where({ url: () => r.url() }), body: body ? body.length : 0 });
1526 });
1527 await page.goto(origin + '/m/' + sqid + '/join', { waitUntil: 'domcontentloaded' });
1528 const ready = await until(async () => await page.locator('#join').isEnabled(), 15000);
1529 check(ready, 'the holding screen is ready — status: ' + (await page.locator('#status').textContent()));
1530 check(await page.locator('#audio-test').isVisible(), 'the audio test is offered');
1531 await page.click('#audio-test summary');
1532 const test = () => page.evaluate(() => window.meetDevices.audioTest());
1533 const sent = requests.length;
1534
1535 await page.click('#test-mic');
1536 const working = await until(async () => {
1537 const t = await test();
1538 return t.mic.verdict === 'working' ? t : false;
1539 }, 10000);
1540 check(working, 'the microphone test hears the microphone (' + JSON.stringify(working || (await test())) + ')');
1541 const micText = ((await page.locator('#mic-result').textContent()) || '').trim();
1542 check(micText === 'Your microphone is working.', 'and says so in words (' + micText + ')');
1543 const micMeter = await until(async () => {
1544 const v = await page.locator('#mic-level').evaluate((m) => m.value);
1545 return v > 0 ? v : false;
1546 }, 5000);
1547 check(micMeter, 'its meter moves (' + micMeter + ')');
1548
1549 await page.click('#test-speaker');
1550 const played = await until(async () => {
1551 const t = await test();
1552 return t.speaker.played && t.speaker.peak > 0 ? t : false;
1553 }, 10000);
1554 check(played, 'the test sound plays, and its meter moves (' + JSON.stringify(played || (await test())) + ')');
1555 const speakerText = await until(async () => {
1556 const t = ((await page.locator('#speaker-result').textContent()) || '').trim();
1557 return t.startsWith('Played a test sound through') ? t : false;
1558 }, 5000);
1559 check(speakerText, 'and says where it played (' + speakerText + ')');
1560 check(await test().then((t) => t.running === '' && t.mic.verdict === 'working'),
1561 'starting one test stopped the other');
1562
1563 await page.click('#test-loopback');
1564 const recording = await until(async () => ((await test()).loopback.phase === 'recording' ? true : false), 5000);
1565 check(recording, 'the playback test records');
1566 const back = await until(async () => {
1567 const t = await test();
1568 return t.loopback.phase === 'played' || t.loopback.phase === 'failed' ? t : false;
1569 }, 20000);
1570 check(back && back.loopback.phase === 'played' && back.loopback.bytes > 0 && back.loopback.peak > 0,
1571 'records, and plays it back (' + JSON.stringify(back || (await test())) + ')');
1572 const loopText = ((await page.locator('#loopback-result').textContent()) || '').trim();
1573 check(loopText === 'That is how you sound to the others. The recording has been thrown away.',
1574 'and says so (' + loopText + ')');
1575 const released = await page.evaluate(() => {
1576 const a = document.getElementById('test-audio');
1577 return !a.srcObject;
1578 });
1579 check(released, 'the player lets go of the recording');
1580
1581 const during = requests.slice(sent);
1582 const carried = during.filter((r) => r.method !== 'GET' || r.body > 0);
1583 check(carried.length === 0, 'nothing left the page while the tests ran (' + JSON.stringify(carried) + ')');
1584
1585 // And Join still works afterwards.
1586 await page.click('#join');
1587 const joined = await until(async () => page.url().endsWith('/m/' + sqid), 10000);
1588 check(joined, 'Join still goes to the room (' + where(page) + ')');
1589 await page.waitForFunction(() => !!window.meet, null, { timeout: 20000 }).catch(() => {});
1590 await leaveRoom(page);
1591
1592 check(errorsFor('member A') === '', 'no console errors (' + errorsFor('member A') + ')');
1593 await ctx.close();
1594 },
1595
1596 // The whole media path in one assertion each way: getUserMedia, join,
1597 // roster, perfect negotiation, trickle ICE, a decoded frame.
1598 async call() {
1599 const ctxA = await newContext('member A', { cookie: cookieA });
1600 const ctxB = await newContext('member B', { cookie: cookieB });
1601 const pageA = await openRoom(ctxA);
1602 const pageB = await openRoom(ctxB);
1603
1604 await assertPair(pageA, pageB, 'member A', 'member B');
1605
1606 check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
1607 check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
1608
1609 await leaveRoom(pageA);
1610 await leaveRoom(pageB);
1611 await ctxA.close();
1612 await ctxB.close();
1613 },
1614
1615 // Spec §15.19: one peer mutes and turns the camera off, and the other
1616 // sees both on that peer's tile — in words, not only an icon — and
1617 // hears about it through the polite live region. Then the watcher
1618 // leaves and comes back, and the state is on the tile from the roster
1619 // alone, with no event to carry it: that is the late-joiner and resume
1620 // path, which is the reason the state lives in the roster at all.
1621 async mute() {
1622 const ctxA = await newContext('member A', { cookie: cookieA });
1623 const ctxB = await newContext('member B', { cookie: cookieB });
1624 const pageA = await openRoom(ctxA);
1625 let pageB = await openRoom(ctxB);
1626
1627 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
1628 if (!pair) return;
1629 const a = pair.a.peer;
1630 const nameA = await selfName(pageA);
1631
1632 // Both report on admission, so each knows the other is on — known,
1633 // not merely unknown — before anybody touches anything.
1634 const known = await until(async () => {
1635 const s = await pageB.evaluate(() => window.meet.state().remoteMedia);
1636 return s[a] && s[a].muted === false && s[a].cameraOff === false ? s : false;
1637 }, 10000);
1638 check(known, 'B has A’s first report: on, not unknown (' + JSON.stringify(known) + ')');
1639 const before = await tileMarks(pageB, a);
1640 check(before && !before.muted && !before.cameraOff, 'A’s tile starts with neither mark (' + JSON.stringify(before) + ')');
1641
1642 await pageA.click('#mute');
1643 const muted = await until(async () => {
1644 const m = await tileMarks(pageB, a);
1645 return m && m.muted ? m : false;
1646 }, 10000);
1647 check(muted, 'B sees A’s muted badge after A mutes (' + JSON.stringify(muted || (await tileMarks(pageB, a))) + ')');
1648 check(muted && /muted/i.test(muted.mutedText), 'the badge says Muted in words (' + (muted && muted.mutedText) + ')');
1649 check(muted && !muted.cameraOff, 'muting alone does not cover the video');
1650 const said = await until(async () => {
1651 const t = await mediaLive(pageB);
1652 return t.includes('muted their microphone') ? t : false;
1653 }, 5000);
1654 check(said && said.startsWith(nameA), 'B’s live region says so, politely, by name (' + (said || (await mediaLive(pageB))) + ')');
1655 const selfMark = await tileMarks(pageA, 'self');
1656 check(selfMark && selfMark.muted, 'A’s own tile carries the badge too (' + JSON.stringify(selfMark) + ')');
1657
1658 await pageA.click('#camera');
1659 const off = await until(async () => {
1660 const m = await tileMarks(pageB, a);
1661 return m && m.cameraOff ? m : false;
1662 }, 10000);
1663 check(off, 'B sees A’s camera-off cover (' + JSON.stringify(off || (await tileMarks(pageB, a))) + ')');
1664 check(off && off.offText === 'Camera off', 'the cover says Camera off in words (' + (off && off.offText) + ')');
1665 check(off && off.muted, 'and A is still muted');
1666 // For a person to look at, as guestdirect keeps the settings page.
1667 await pageB.screenshot({ path: '/tmp/calls-mute.png', fullPage: true });
1668
1669 // The watcher comes back: the roster alone must carry both.
1670 await leaveRoom(pageB);
1671 await pageB.close();
1672 pageB = await openRoom(ctxB);
1673 const again = await until(async () => {
1674 const s = await state(pageB);
1675 if (!s.admitted || !s.peers.includes(a)) return false;
1676 const m = await tileMarks(pageB, a);
1677 return m && m.muted && m.cameraOff ? m : false;
1678 }, 25000);
1679 check(again, 'B, rejoining, reads both marks from the roster (' + JSON.stringify(again || (await tileMarks(pageB, a))) + ')');
1680 check((await mediaLive(pageB)) === '', 'a state learned from the roster is not announced as news');
1681
1682 // And back on: both marks go.
1683 await pageA.click('#mute');
1684 await pageA.click('#camera');
1685 const clear = await until(async () => {
1686 const m = await tileMarks(pageB, a);
1687 return m && !m.muted && !m.cameraOff ? m : false;
1688 }, 10000);
1689 check(clear, 'unmuting and turning the camera on clears both marks (' + JSON.stringify(clear || (await tileMarks(pageB, a))) + ')');
1690 const fb = await waitFrames(pageB, a);
1691 check(fb, 'B is still decoding A’s video afterwards');
1692
1693 check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
1694 check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
1695 await leaveRoom(pageA);
1696 await leaveRoom(pageB);
1697 await ctxA.close();
1698 await ctxB.close();
1699 },
1700
1701 // Spec §15.20: the meters and the five-second rule, in a real call.
1702 // Chromium's fake microphone beeps about once a second, so a working
1703 // call is one where every meter moves and nobody is ever called
1704 // silent. Then the far side mutes, which is the Muted badge and not a
1705 // silence; and then it goes silent *without* muting — its microphone
1706 // track disabled under it, the shape of a microphone that hears
1707 // nothing — which is "No sound from …" here and "… isn't getting any
1708 // sound from you" there, over the report.
1709 async meters() {
1710 const ctxA = await newContext('member A', { cookie: cookieA });
1711 const ctxB = await newContext('member B', { cookie: cookieB });
1712 const pageA = await openRoom(ctxA);
1713 const pageB = await openRoom(ctxB);
1714
1715 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
1716 if (!pair) return;
1717 const a = pair.a.peer;
1718 const b = pair.b.peer;
1719 const nameA = await selfName(pageA);
1720 const nameB = await selfName(pageB);
1721 const hearingOf = async (page, key) => ((await state(page)).hearing || {})[key] || null;
1722
1723 const heard = await until(async () => {
1724 const h = await hearingOf(pageB, a);
1725 return h && h.peak > 0 && h.shown === 'meter' ? h : false;
1726 }, 15000);
1727 check(heard, 'B reads a level above zero for A (' + JSON.stringify(heard || (await hearingOf(pageB, a))) + ')');
1728 // The glow replaced the ring and the meter (0.3.7): neither is drawn.
1729 const plain = await glowOf(pageB, a);
1730 check(plain && !plain.ring && !plain.meter,
1731 'no ring round A’s tile and no meter beside the name on B (' + JSON.stringify(plain) + ')');
1732 const self = await until(async () => {
1733 const h = await hearingOf(pageA, 'self');
1734 return h && h.peak > 0 ? h : false;
1735 }, 15000);
1736 check(self, 'A’s own tile reads its microphone (' + JSON.stringify(self || (await hearingOf(pageA, 'self'))) + ')');
1737 // A speaks — a steady tone rather than the fake microphone's beep —
1738 // and A's name glows on B, and on A's own tile.
1739 const toned = await speakInto(pageA);
1740 check(toned > 0, 'A’s microphone is replaced by a tone (' + toned + ' senders)');
1741 const lit = await until(async () => {
1742 const g = await glowOf(pageB, a);
1743 const h = await hearingOf(pageB, a);
1744 return g && g.lit && g.glow > 0 && g.on === 1 && g.alpha > 0.1 && h && h.speaking && h.glow > 0 ? { g, h } : false;
1745 }, 10000);
1746 check(lit, 'A’s name glows on B while A speaks (' + JSON.stringify(lit || (await glowOf(pageB, a))) + ')');
1747 const selfLit = await until(async () => {
1748 const g = await glowOf(pageA, 'self');
1749 return g && g.lit && g.glow > 0 ? g : false;
1750 }, 10000);
1751 check(selfLit, 'and on A’s own tile (' + JSON.stringify(selfLit || (await glowOf(pageA, 'self'))) + ')');
1752 // For a look at it: CALLS_GLOW_SNAPSHOTS=<dir> keeps B's room, glowing.
1753 if (process.env.CALLS_GLOW_SNAPSHOTS) {
1754 await pageB.screenshot({ path: process.env.CALLS_GLOW_SNAPSHOTS + '/web-room-glowing.png', fullPage: true });
1755 }
1756 const ringless = await glowOf(pageB, a);
1757 check(ringless && !ringless.ring, 'speaking draws no ring round the tile (' + JSON.stringify(ringless) + ')');
1758 // Silence — the track disabled, so packets carry nothing — puts it
1759 // out, over the release rather than at once: --on waits for --lvl to
1760 // fall, then every layer of the rim is transparent. (The fake
1761 // microphone's beep, back, would light it again: Chromium's audioLevel
1762 // lingers.)
1763 await pageA.evaluate(() => {
1764 for (const [sn] of window.__toneRestore || []) if (sn.track) sn.track.enabled = false;
1765 });
1766 const out = await until(async () => {
1767 const g = await glowOf(pageB, a);
1768 return g && !g.lit && g.glow === 0 && g.on === 0 && g.shadow !== 'none' && g.alpha === 0 ? g : false;
1769 }, 10000);
1770 await stopSpeaking(pageA);
1771 check(out, 'and it goes out when A stops (' + JSON.stringify(out || (await glowOf(pageB, a))) + ')');
1772
1773 // A beeping microphone is not a silent one, on either side, for
1774 // longer than the rule's five seconds.
1775 const falseAlarm = await stayed(pageB, 7000, (s) =>
1776 s.hearing[a] && s.hearing[a].shown === 'meter' && !s.noSoundFrom.length && !s.hearing.self.text);
1777 check(!falseAlarm, 'a working microphone is never called silent (' +
1778 JSON.stringify(falseAlarm && { hearing: falseAlarm.hearing, noSoundFrom: falseAlarm.noSoundFrom }) + ')');
1779
1780 // Muted is the badge, not a meter and not a silence.
1781 await pageA.click('#mute');
1782 const muted = await until(async () => {
1783 const h = await hearingOf(pageB, a);
1784 const m = await tileMarks(pageB, a);
1785 return h && h.shown === 'muted' && m && m.muted ? { h, m } : false;
1786 }, 10000);
1787 check(muted, 'B shows A as Muted once A mutes (' + JSON.stringify(muted || (await hearingOf(pageB, a))) + ')');
1788 const glowGone = await until(async () => {
1789 const g = await glowOf(pageB, a);
1790 return g && !g.lit && g.glow === 0 ? g : false;
1791 }, 3000);
1792 check(glowGone, 'muted, nothing glows (' + JSON.stringify(glowGone || (await glowOf(pageB, a))) + ')');
1793 const mutedQuiet = await stayed(pageB, 6500, (s) =>
1794 s.hearing[a] && s.hearing[a].shown === 'muted' && s.hearing[a].text === '' && !s.noSoundFrom.length);
1795 check(!mutedQuiet, 'a muted peer is never "No sound from" (' + JSON.stringify(mutedQuiet && mutedQuiet.hearing[a]) + ')');
1796 const selfMuted = await hearingOf(pageA, 'self');
1797 check(selfMuted && selfMuted.shown === 'muted' && selfMuted.text === '',
1798 'and muted, A’s own tile warns of nothing (' + JSON.stringify(selfMuted) + ')');
1799 await pageA.click('#mute');
1800 const back = await until(async () => {
1801 const h = await hearingOf(pageB, a);
1802 return h && h.shown === 'meter' ? h : false;
1803 }, 10000);
1804 check(back, 'unmuting brings live sound back (' + JSON.stringify(back || (await hearingOf(pageB, a))) + ')');
1805
1806 // Silence without muting: the microphone track is disabled under the
1807 // call, so packets keep flowing and carry nothing, and A's report
1808 // still says unmuted — which is exactly a microphone that hears
1809 // nothing, as far as anybody listening can tell.
1810 await pageA.evaluate(() => {
1811 for (const pc of window.__pcs || []) {
1812 for (const sn of pc.getSenders()) if (sn.track && sn.track.kind === 'audio') sn.track.enabled = false;
1813 }
1814 });
1815 const silent = await until(async () => {
1816 const s = await state(pageB);
1817 const h = s.hearing[a];
1818 return h && h.shown === 'silent' && s.noSoundFrom.includes(a) ? { h, noSoundFrom: s.noSoundFrom } : false;
1819 }, 15000);
1820 check(silent, 'B calls A silent after five flat seconds (' + JSON.stringify(silent || (await hearingOf(pageB, a))) + ')');
1821 check(silent && silent.h.text === 'No sound from ' + nameA,
1822 'in words, by name (' + (silent && silent.h.text) + ')');
1823 const told = await until(async () => {
1824 const h = await hearingOf(pageA, b);
1825 return h && /isn.t getting any sound from you\./.test(h.notice) ? h : false;
1826 }, 10000);
1827 check(told, 'A’s tile for B says B is getting no sound from A (' + JSON.stringify(told || (await hearingOf(pageA, b))) + ')');
1828 check(told && told.notice === nameB + ' isn\u2019t getting any sound from you.',
1829 'naming B (' + (told && told.notice) + ')');
1830 const saidA = await mediaLive(pageA);
1831 check(/isn.t getting any sound from you/.test(saidA), 'and A’s live region says so once (' + saidA + ')');
1832
1833 // Sound again ends it on both sides.
1834 await pageA.evaluate(() => {
1835 for (const pc of window.__pcs || []) {
1836 for (const sn of pc.getSenders()) if (sn.track && sn.track.kind === 'audio') sn.track.enabled = true;
1837 }
1838 });
1839 const cleared = await until(async () => {
1840 const s = await state(pageB);
1841 return s.hearing[a] && s.hearing[a].shown === 'meter' && !s.noSoundFrom.length ? s.hearing[a] : false;
1842 }, 10000);
1843 check(cleared, 'sound again clears "No sound" on B (' + JSON.stringify(cleared || (await hearingOf(pageB, a))) + ')');
1844 const untold = await until(async () => {
1845 const h = await hearingOf(pageA, b);
1846 return h && h.notice === '' ? h : false;
1847 }, 10000);
1848 check(untold, 'and the notice on A (' + JSON.stringify(untold || (await hearingOf(pageA, b))) + ')');
1849
1850 check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
1851 check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
1852 await leaveRoom(pageA);
1853 await leaveRoom(pageB);
1854 await ctxA.close();
1855 await ctxB.close();
1856 },
1857
1858 // Space toggles mute (the keyboard path to the same toggle as the
1859 // button, so the peer's badge follows over the same `media` report).
1860 // Held Space is one toggle, not a flicker; Space on the focused Mute
1861 // button is the button's own press, once; and Space in the chat
1862 // composer is a space in the message, with the microphone left alone.
1863 async spacemute() {
1864 const ctxA = await newContext('member A', { cookie: cookieA });
1865 const ctxB = await newContext('member B', { cookie: cookieB });
1866 const pageA = await openRoom(ctxA);
1867 const pageB = await openRoom(ctxB);
1868
1869 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
1870 if (!pair) return;
1871 const a = pair.a.peer;
1872 const pressed = () => pageA.evaluate(() => document.getElementById('mute').getAttribute('aria-pressed'));
1873 const known = await until(async () => {
1874 const s = await pageB.evaluate(() => window.meet.state().remoteMedia);
1875 return s[a] && s[a].muted === false ? s : false;
1876 }, 10000);
1877 check(known, 'B has A’s first report, unmuted (' + JSON.stringify(known) + ')');
1878 // Whether the page kept the key: read after room.js's own listener.
1879 await pageA.evaluate(() => {
1880 window.__spacePrevented = [];
1881 window.addEventListener('keydown', (ev) => {
1882 if (ev.key === ' ') window.__spacePrevented.push(ev.defaultPrevented);
1883 });
1884 });
1885
1886 await pageA.evaluate(() => document.activeElement && document.activeElement.blur());
1887 await pageA.keyboard.press('Space');
1888 const muted = await until(async () => {
1889 const m = await tileMarks(pageB, a);
1890 return m && m.muted ? m : false;
1891 }, 10000);
1892 check(muted, 'Space mutes A, and B sees the badge (' + JSON.stringify(muted || (await tileMarks(pageB, a))) + ')');
1893 check((await pressed()) === 'true', 'A’s Mute button says pressed');
1894 const said = await until(async () => {
1895 const t = await mediaLive(pageA);
1896 return t === 'Microphone muted.' ? t : false;
1897 }, 5000);
1898 check(said, 'A’s live region says so (' + (await mediaLive(pageA)) + ')');
1899 check((await pageA.evaluate(() => window.__spacePrevented.slice(-1)[0])) === true,
1900 'a handled Space is prevented, so the page does not scroll');
1901
1902 // Held: the first press toggles, the repeat is swallowed.
1903 await pageA.keyboard.down('Space');
1904 await pageA.keyboard.down('Space');
1905 await pageA.keyboard.up('Space');
1906 check((await pressed()) === 'false', 'a held Space unmutes once and does not flicker back');
1907 const unmuted = await until(async () => {
1908 const m = await tileMarks(pageB, a);
1909 return m && !m.muted ? m : false;
1910 }, 10000);
1911 check(unmuted, 'B sees A unmuted (' + JSON.stringify(unmuted || (await tileMarks(pageB, a))) + ')');
1912
1913 // Space on the focused button: the button's own press, exactly once.
1914 await pageA.focus('#mute');
1915 await pageA.keyboard.press('Space');
1916 check(await until(async () => (await pressed()) === 'true', 3000),
1917 'Space on the focused Mute button presses it once — twice would leave it unmuted');
1918
1919 // Space in the chat composer is typing.
1920 check(await until(() => chatMounted(pageA), 10000), 'A’s chat panel is up');
1921 await openChat(pageA);
1922 await pageA.click('#chat-text');
1923 await pageA.keyboard.type('two words');
1924 const typed = await pageA.inputValue('#chat-text');
1925 check(typed === 'two words', 'Space in the chat box types a space (' + JSON.stringify(typed) + ')');
1926 check((await pressed()) === 'true', 'and leaves the microphone as it was');
1927 check((await pageA.evaluate(() => window.__spacePrevented.slice(-1)[0])) === false,
1928 'a Space that is typing is not prevented');
1929 const stillMuted = await tileMarks(pageB, a);
1930 check(stillMuted && stillMuted.muted, 'B still sees A muted');
1931
1932 check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
1933 check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
1934 await leaveRoom(pageA);
1935 await leaveRoom(pageB);
1936 await ctxA.close();
1937 await ctxB.close();
1938 },
1939
1940 // Changing a device without leaving the call. The panel opens, the
1941 // microphone and then the camera are chosen again from the list, and
1942 // the far side's media is watched across both.
1943 //
1944 // Picking the same device again is the point rather than a weakness
1945 // of the test. What is under test is the swap — getUserMedia, a
1946 // replaceTrack on every connection's sender, the local stream rebuilt
1947 // under the self tile — and none of that cares whether the id it is
1948 // handed differs from the one already in use. Chromium's fake capture
1949 // offers one camera and one microphone, so a "different" device here
1950 // would be a second fake and not a second assertion.
1951 //
1952 // The assertion that matters is the negative one: a swap must not
1953 // cost the call anything. replaceTrack does not renegotiate, so a
1954 // regression that reached for addTrack, or that stopped the old track
1955 // before the new one was on the senders, shows up here as the far
1956 // side's frames stopping.
1957 async switching() {
1958 const ctxA = await newContext('member A', { cookie: cookieA });
1959 const ctxB = await newContext('member B', { cookie: cookieB });
1960 const pageA = await openRoom(ctxA);
1961 const pageB = await openRoom(ctxB);
1962
1963 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
1964 if (!pair) {
1965 await ctxA.close();
1966 await ctxB.close();
1967 return;
1968 }
1969
1970 // room.js reveals the button only where there is an enumeration
1971 // behind it, so its being visible is itself the feature detection
1972 // working.
1973 const shown = await until(async () => await pageA.locator('#devices-toggle').isVisible(), 10000);
1974 check(shown, 'the Devices button is revealed in the room');
1975 await pageA.click('#devices-toggle');
1976 check(await pageA.locator('#room-devices').isVisible(), 'the device panel opens');
1977 check(await pageA.locator('#devices-toggle').getAttribute('aria-expanded') === 'true',
1978 'the Devices button reports itself expanded');
1979
1980 const mics = await until(async () => {
1981 const n = await pageA.locator('#pick-mic option').count();
1982 return n > 0 ? n : false;
1983 }, 10000);
1984 check(mics, 'the room lists at least one microphone (got ' + mics + ')');
1985 const cameras = await pageA.locator('#pick-camera option').count();
1986 check(cameras > 0, 'the room lists at least one camera (got ' + cameras + ')');
1987
1988 const sides = [
1989 { page: pageB, label: 'member B', keys: [pair.a.peer] },
1990 { page: pageA, label: 'member A', keys: [pair.b.peer] },
1991 ];
1992 for (const [selector, what] of [['#pick-mic', 'microphone'], ['#pick-camera', 'camera']]) {
1993 const value = await pageA.locator(selector + ' option').first().getAttribute('value');
1994 await pageA.selectOption(selector, value);
1995 // The swap is asynchronous behind the change event: one
1996 // getUserMedia and a replaceTrack per connection. Nothing in the
1997 // page announces it finishing, so what is waited for is the thing
1998 // that must be true afterwards — a live local track of that kind
1999 // — and then the media is watched over a duration.
2000 const live = await until(async () => await pageA.evaluate((kind) => {
2001 const tracks = (window.meet && window.meet.local && window.meet.local()) || [];
2002 const of = tracks.filter((t) => t.kind === kind);
2003 return of.length === 1 && of[0].state === 'live' && of[0].chosen;
2004 }, what === 'microphone' ? 'audio' : 'video'), 15000);
2005 check(live, 'member A holds exactly one live ' + what + ' track after the switch');
2006
2007 check((await pageA.locator('#device-note').textContent() || '').trim() === '',
2008 'switching the ' + what + ' reported no trouble (' +
2009 (await pageA.locator('#device-note').textContent()) + ')');
2010
2011 await watchMedia(sides, 'after the ' + what + ' was switched: ');
2012 }
2013
2014 // Three changes with nothing awaited between them, which is one
2015 // person clicking through a list rather than choosing from it. Each
2016 // is a getUserMedia and a replaceTrack across the mesh, and run at
2017 // once they would be two loops writing to the same senders — what
2018 // every peer then receives decided by whichever device opened
2019 // fastest. Serialised, the last click wins and the media never
2020 // stops; the failure this catches is the middle run stopping a
2021 // track the senders were still holding, which reads at the far end
2022 // as the call simply ending.
2023 const camera = await pageA.locator('#pick-camera option').first().getAttribute('value');
2024 await Promise.all([
2025 pageA.selectOption('#pick-camera', camera),
2026 pageA.selectOption('#pick-camera', camera),
2027 pageA.selectOption('#pick-camera', camera),
2028 ]);
2029 const settled = await until(async () => await pageA.evaluate(() => {
2030 const tracks = (window.meet && window.meet.local && window.meet.local()) || [];
2031 const video = tracks.filter((t) => t.kind === 'video');
2032 return video.length === 1 && video[0].state === 'live';
2033 }), 20000);
2034 check(settled, 'member A holds exactly one live camera after three switches at once — ' +
2035 JSON.stringify(await pageA.evaluate(() => window.meet.local())));
2036 await watchMedia(sides, 'after three switches at once: ');
2037
2038 check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
2039 check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
2040
2041 await leaveRoom(pageA);
2042 await leaveRoom(pageB);
2043 await ctxA.close();
2044 await ctxB.close();
2045 },
2046
2047 // §5.4 and §13's slice-4 sentence: "the higher-id context shares its
2048 // screen and the lower receives a second video track, then stops, the
2049 // received track ends, the share tile is removed and the camera track
2050 // keeps flowing."
2051 //
2052 // Every clause of that is an assertion here, and the camera is watched
2053 // across both halves rather than only at the end: a share that
2054 // renegotiated the call into silence and then gave it back would pass
2055 // a check made once, at rest.
2056 async share() {
2057 const ctxA = await newContext('member A', { cookie: cookieA });
2058 const ctxB = await newContext('member B', { cookie: cookieB });
2059 const pageA = await openRoom(ctxA);
2060 const pageB = await openRoom(ctxB);
2061 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
2062 if (!pair) {
2063 await ctxA.close();
2064 await ctxB.close();
2065 return;
2066 }
2067
2068 // §5.1's own tie-break, read the same way room.js reads it. §13
2069 // names the higher id as the one that shares, and it is the
2070 // interesting half: the higher id is the impolite peer, so the
2071 // offer its new transceiver causes is the one that does not roll
2072 // back on a collision.
2073 const aHigher = String(pair.a.peer) > String(pair.b.peer);
2074 const sharer = aHigher
2075 ? { page: pageA, label: 'member A', peer: pair.a.peer }
2076 : { page: pageB, label: 'member B', peer: pair.b.peer };
2077 const viewer = aHigher
2078 ? { page: pageB, label: 'member B', peer: pair.b.peer }
2079 : { page: pageA, label: 'member A', peer: pair.a.peer };
2080 line('the higher-id context is ' + sharer.label);
2081
2082 // The caption is built from the roster's name and the typographic
2083 // apostrophe room.js writes, not from a name typed here: the whole
2084 // point of the `share` message is that no label crosses the wire.
2085 const name = await selfName(sharer.page);
2086 check(name !== '', 'the sharer has a name for its screen to be captioned with (' + name + ')');
2087 const caption = name + '’s screen';
2088 const key = sharer.peer + '#share';
2089
2090 const resting = await shareControl(sharer.page);
2091 check(resting && resting.pressed === 'false' && resting.state === 'Off' && !resting.disabled,
2092 'the Share button rests off, in a word and in aria-pressed (' + JSON.stringify(resting) + ')');
2093 check((await shareTiles(viewer.page)).length === 0,
2094 'nobody is showing a screen before anybody shares one');
2095
2096 await sharer.page.click('#share');
2097
2098 // The message first: `shares` follows the tile, and the tile follows
2099 // the `share {on:true}` — which is what lets a name appear before
2100 // the media and is why these are three assertions and not one.
2101 const announced = await until(async () => {
2102 const s = await state(viewer.page);
2103 return s.shares.includes(sharer.peer) ? s : false;
2104 }, 5000);
2105 check(announced, 'the other context is told whose screen is coming within five seconds — ' +
2106 (await diagnose(viewer.page, viewer.label)));
2107
2108 const captioned = await until(async () => {
2109 const tiles = await shareTiles(viewer.page);
2110 return tiles.length === 1 && tiles[0].name === caption ? tiles : false;
2111 }, 5000);
2112 check(captioned, 'a second tile says whose screen it is (' +
2113 JSON.stringify(await shareTiles(viewer.page)) + ', want ' + JSON.stringify(caption) + ')');
2114
2115 const pixels = await frameGrowth(viewer.page, key, 10000);
2116 check(pixels.ok, 'the screen is decoding real frames and not only labelled (' +
2117 growth(pixels) + ') — ' + (await diagnose(viewer.page, viewer.label)));
2118
2119 // From a baseline taken now that the transition has settled, not
2120 // from one taken before the gesture: frames delivered between a
2121 // pre-gesture snapshot and a rollback stall would satisfy a
2122 // pre-gesture baseline while the camera was already dead (Codex
2123 // S4-6).
2124 const cameraOn = await frameGrowth(viewer.page, sharer.peer, 10000);
2125 check(cameraOn.ok, 'the camera kept flowing across the renegotiation (' +
2126 growth(cameraOn) + ')');
2127
2128 // And still flowing through a window rather than at an instant, in
2129 // both directions: the failure this slice is about is a tile that
2130 // runs for a moment after a renegotiation and then stops, which a
2131 // check that returns on the first new frame cannot tell from
2132 // health (Codex's S4-6 gap). Both sides, because the sharer's own
2133 // view of the viewer's camera is renegotiated just as hard and was
2134 // never looked at.
2135 await watchMedia([
2136 { page: viewer.page, label: viewer.label, keys: [sharer.peer, key] },
2137 { page: sharer.page, label: sharer.label, keys: [viewer.peer] },
2138 ], 'after the share started: ');
2139
2140 const pressed = await shareControl(sharer.page);
2141 check(pressed && pressed.pressed === 'true' && pressed.state === 'On',
2142 'the Share button says it is on, in both cues (' + JSON.stringify(pressed) + ')');
2143 check((await state(sharer.page)).sharing === true, 'the sharing tab says it is sharing');
2144 check((await shareTiles(sharer.page)).length === 0,
2145 'the sharer is not shown its own screen back (' + JSON.stringify(await shareTiles(sharer.page)) + ')');
2146
2147 // The received track's own ending, armed before anything stops.
2148 // `ended` fires once and never again, so a listener installed after
2149 // the click would be an assertion about a moment already gone — and
2150 // the track object is kept on the page too, because the tile that
2151 // was holding it is about to be removed and the failure message
2152 // wants its readyState.
2153 const armed = await viewer.page.evaluate(() => {
2154 const v = document.querySelector('#tiles figure.calls-tile-share video');
2155 const track = v && v.srcObject ? v.srcObject.getVideoTracks()[0] : null;
2156 if (!track) return null;
2157 window.__shareEnded = [];
2158 window.__shareTrack = track;
2159 track.addEventListener('ended', () => window.__shareEnded.push(track.id));
2160 return { id: track.id, readyState: track.readyState };
2161 });
2162 check(armed && armed.readyState === 'live',
2163 'the received screen track is live, and watched for its ending (' + JSON.stringify(armed) + ')');
2164
2165 // Paul, 2026-09-28: the sharing notice carries its own Stop
2166 // sharing button, named for a screen reader, reachable from the
2167 // keyboard — and this stop goes through it, so the button is
2168 // proved to do exactly what the bar's toggle does.
2169 const notice = await sharer.page.evaluate(() => {
2170 const n = document.getElementById('share-notice');
2171 const b = document.getElementById('share-stop');
2172 return n && b ? {
2173 shown: !n.hidden && n.getClientRects().length > 0,
2174 text: (n.querySelector('.calls-share-notice-row span') || {}).textContent || '',
2175 label: b.getAttribute('aria-label'),
2176 words: (b.textContent || '').trim(),
2177 focusable: b.tabIndex >= 0 && !b.disabled,
2178 } : null;
2179 });
2180 check(notice && notice.shown && notice.text === 'You are sharing your screen' &&
2181 notice.label === 'Stop sharing your screen' && notice.words === 'Stop sharing' && notice.focusable,
2182 'the sharing notice shows, with its own Stop sharing button (' + JSON.stringify(notice) + ')');
2183 await sharer.page.focus('#share-stop');
2184 await sharer.page.keyboard.press('Enter');
2185
2186 const removed = await until(async () => {
2187 const s = await state(viewer.page);
2188 if (s.shares.length !== 0) return false;
2189 return (await shareTiles(viewer.page)).length === 0 ? s : false;
2190 }, 5000);
2191 check(removed, 'the screen tile and the shared state are gone within five seconds — ' +
2192 (await diagnose(viewer.page, viewer.label)));
2193
2194 const ended = await until(() =>
2195 viewer.page.evaluate(() => ((window.__shareEnded || []).length > 0)), 5000);
2196 check(ended, 'the received track ended rather than merely stopping being drawn (readyState ' +
2197 (await viewer.page.evaluate(() => (window.__shareTrack || {}).readyState || '(no track)')) + ')');
2198
2199 // Again from a fresh baseline, taken now that the stop has settled,
2200 // and again across a window rather than at an instant.
2201 // The screen is gone by now and is not expected; the two cameras
2202 // are, and they are the half a stopped share can take with it.
2203 await watchMedia([
2204 { page: viewer.page, label: viewer.label, keys: [sharer.peer] },
2205 { page: sharer.page, label: sharer.label, keys: [viewer.peer] },
2206 ], 'after the share stopped: ');
2207
2208 const off = await until(async () => {
2209 const b = await shareControl(sharer.page);
2210 return b && b.pressed === 'false' && b.state === 'Off' ? b : false;
2211 }, 5000);
2212 check(off, 'the Share button is back off, in both cues (' +
2213 JSON.stringify(await shareControl(sharer.page)) + ')');
2214 check((await state(sharer.page)).sharing === false, 'the sharing tab says it has stopped');
2215 check(await sharer.page.evaluate(() => document.getElementById('share-notice').hidden),
2216 'the sharing notice goes with the share');
2217
2218 check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
2219 check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
2220
2221 await leaveRoom(pageA);
2222 await leaveRoom(pageB);
2223 await ctxA.close();
2224 await ctxB.close();
2225 },
2226
2227 // Both of them at once, three times over. §5.4 gives each connection
2228 // one sendonly transceiver for a screen, and a simultaneous share is
2229 // where that goes wrong: two peers renegotiating the same connection
2230 // in the same moment, the polite one rolling its own offer back — and
2231 // taking its share m-line with it. Either each screen ends up on its
2232 // own m-line, or one of them silently takes the other's and the
2233 // second screen arrives on a transceiver nothing is sending down.
2234 //
2235 // Nothing is staggered on purpose, because the stagger is the bug's
2236 // hiding place. Both clicks go out together, both directions are
2237 // asserted separately, and the frames half of each is what catches an
2238 // inactive m-line: `shares` and the caption move on the `share`
2239 // message alone, so a screen that was announced and never arrived
2240 // looks exactly like one that works until the pixels are counted.
2241 //
2242 // Three rounds, because a rollback that is recovered from once may
2243 // not be recovered from twice: the transceiver a round leaves behind
2244 // is the one the next round has to reuse or replace.
2245 async mutualshare() {
2246 const ctxA = await newContext('member A', { cookie: cookieA });
2247 const ctxB = await newContext('member B', { cookie: cookieB });
2248 const pageA = await openRoom(ctxA);
2249 const pageB = await openRoom(ctxB);
2250 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
2251 if (!pair) {
2252 await ctxA.close();
2253 await ctxB.close();
2254 return;
2255 }
2256
2257 const nameA = await selfName(pageA);
2258 const nameB = await selfName(pageB);
2259 check(nameA !== '' && nameB !== '' && nameA !== nameB,
2260 'the two contexts have different names, so a caption can name the wrong one (' +
2261 JSON.stringify([nameA, nameB]) + ')');
2262
2263 // `other` is the peer id on the far side of this page's one
2264 // connection, so every assertion below reads as "this page, about
2265 // the other one" and the two directions never get crossed.
2266 const sides = [
2267 { page: pageA, label: 'member A', other: pair.b.peer, otherName: nameB },
2268 { page: pageB, label: 'member B', other: pair.a.peer, otherName: nameA },
2269 ];
2270
2271 // Both buttons hovered first, so Playwright's actionability checks
2272 // are already satisfied and the two clicks are two CDP messages and
2273 // nothing else. Trusted input rather than a scripted
2274 // element.click(): getDisplayMedia needs transient activation and a
2275 // synthesised click does not grant it. This is as near the same tick
2276 // as two browser contexts get.
2277 const bothClick = async () => {
2278 await Promise.all([pageA.hover('#share'), pageB.hover('#share')]);
2279 await Promise.all([pageA.click('#share'), pageB.click('#share')]);
2280 };
2281
2282 for (let round = 1; round <= 3; round++) {
2283 const at = 'round ' + round + ': ';
2284 await bothClick();
2285
2286 for (const side of sides) {
2287 const key = side.other + '#share';
2288
2289 const announced = await until(async () => {
2290 const s = await state(side.page);
2291 return s.shares.includes(side.other) ? s : false;
2292 }, 10000);
2293 check(announced, at + side.label + " was told about the other's screen — " +
2294 (await diagnose(side.page, side.label)));
2295
2296 const tiles = await shareTiles(side.page);
2297 check(tiles.length === 1 && tiles[0].name === side.otherName + '’s screen',
2298 at + side.label + ' shows one screen, captioned with the other name (' +
2299 JSON.stringify(tiles) + ', want ' + JSON.stringify(side.otherName + '’s screen') + ')');
2300
2301 // The half a message cannot fake. A share m-line the far side
2302 // took for its own, or one left inactive by a rollback, is
2303 // announced exactly like a working one and carries nothing.
2304 //
2305 // From a baseline taken now, in every round: `frames[key] > 0`
2306 // reads a counter `dropShare` deliberately leaves behind, so
2307 // rounds two and three used to pass on round one's frames even
2308 // if the new screen delivered none at all (Codex S4-6).
2309 const pixels = await frameGrowth(side.page, key, 15000);
2310 check(pixels.ok, at + side.label + ' is decoding the other screen, not just showing its name (' +
2311 growth(pixels) + ') — ' + (await diagnose(side.page, side.label)));
2312
2313 // The camera, from a baseline taken after this round's
2314 // transition settled rather than before the gesture that caused
2315 // it: frames delivered between a pre-gesture snapshot and a
2316 // rollback stall satisfy a pre-gesture baseline while the camera
2317 // is already dead, which is the exact failure this scenario was
2318 // written to catch.
2319 const camera = await frameGrowth(side.page, side.other, 15000);
2320 check(camera.ok, at + side.label + " is still receiving the other's camera (" +
2321 growth(camera) + ') — ' + (await diagnose(side.page, side.label)));
2322 }
2323
2324 check((await state(pageA)).sharing === true && (await state(pageB)).sharing === true,
2325 at + 'both tabs say they are sharing');
2326
2327 // Five seconds of not wobbling. Glare's worst outcome is not a
2328 // screen that never arrives but a connection left mid-offer: it
2329 // goes on carrying the media it already had, so every count above
2330 // keeps rising while nothing further can ever be negotiated.
2331 const wobbled = await bothStable(sides, 5000);
2332 check(wobbled === null, at + 'negotiation settled and stayed settled — ' + JSON.stringify(wobbled));
2333
2334 // And the media through that same window. The five seconds above
2335 // watch signalling, which a connection whose camera has stopped
2336 // encoding passes perfectly: `connected`, `stable`, sendrecv, a
2337 // live track, and nothing coming out (Codex S4-6). Two successive
2338 // intervals per stream, both screens and both cameras, so a
2339 // stream that delivers a frame and stops cannot answer for one
2340 // that is running.
2341 // Both cameras and both screens, named rather than read back off
2342 // the page, and all four watched through the same windows.
2343 await watchMedia(sides.map((side) => ({
2344 page: side.page,
2345 label: side.label,
2346 keys: [side.other, side.other + '#share'],
2347 })), at + 'after the wait: ');
2348
2349 if (round === 1) {
2350 for (const side of sides) {
2351 const surface = await capturedSurface(side.page);
2352 check(surface !== null,
2353 side.label + ' is sending a display capture and not a second camera (displaySurface ' +
2354 JSON.stringify(surface) + ')');
2355 }
2356 }
2357
2358 if (round === 3) break;
2359
2360 await bothClick();
2361 for (const side of sides) {
2362 const gone = await until(async () => {
2363 const s = await state(side.page);
2364 if (s.shares.length !== 0) return false;
2365 return (await shareTiles(side.page)).length === 0 ? s : false;
2366 }, 10000);
2367 check(gone, at + side.label + ' lost the screen tile before the next round — ' +
2368 (await diagnose(side.page, side.label)));
2369 }
2370 check((await state(pageA)).sharing === false && (await state(pageB)).sharing === false,
2371 at + 'both tabs say they have stopped');
2372
2373 // The stop is a renegotiation too, and the round that follows
2374 // would happily start on a camera this one had already killed.
2375 await watchMedia(sides.map((side) => ({
2376 page: side.page,
2377 label: side.label,
2378 keys: [side.other],
2379 })), at + 'after both stopped: ');
2380 }
2381
2382 // And the asymmetric ending, which is the one a symmetrical
2383 // scenario would never reach: one of them stops and the other does
2384 // not. The m-line that goes has to be the one that was stopped.
2385 const [stopper, keeper] = sides;
2386 const keptKey = stopper.other + '#share';
2387
2388 await stopper.page.click('#share');
2389
2390 const dropped = await until(async () => {
2391 const s = await state(keeper.page);
2392 if (s.shares.length !== 0) return false;
2393 return (await shareTiles(keeper.page)).length === 0 ? s : false;
2394 }, 10000);
2395 check(dropped, keeper.label + ' lost only the screen that stopped — ' +
2396 (await diagnose(keeper.page, keeper.label)));
2397
2398 // Fresh baselines again, taken after the one-sided stop settled:
2399 // the screen that was kept, and both cameras.
2400 check((await state(stopper.page)).shares.includes(stopper.other),
2401 stopper.label + ' still holds the other screen through its own stop');
2402 const kept = await frameGrowth(stopper.page, keptKey, 10000);
2403 check(kept.ok, stopper.label + ' still has the other screen, and it is still arriving (' +
2404 growth(kept) + ') — ' + (await diagnose(stopper.page, stopper.label)));
2405
2406 const stillCamera = await frameGrowth(keeper.page, keeper.other, 10000);
2407 check(stillCamera.ok, keeper.label + ' still has the camera through the one-sided stop (' +
2408 growth(stillCamera) + ') — ' + (await diagnose(keeper.page, keeper.label)));
2409 const stopperCamera = await frameGrowth(stopper.page, stopper.other, 10000);
2410 check(stopperCamera.ok, stopper.label + ' still has the camera through its own stop (' +
2411 growth(stopperCamera) + ') — ' + (await diagnose(stopper.page, stopper.label)));
2412
2413 check((await state(stopper.page)).sharing === false,
2414 stopper.label + ' says it has stopped');
2415 check((await state(keeper.page)).sharing === true,
2416 keeper.label + ' says it is still sharing');
2417
2418 const settledAfter = await bothStable(sides, 5000);
2419 check(settledAfter === null,
2420 'the one-sided stop left both connections settled — ' + JSON.stringify(settledAfter));
2421
2422 // Last of all, and after that wait rather than before it: the media
2423 // checks above all ran before those five seconds, so a camera that
2424 // froze during them left every one of them green (Codex's S4-6
2425 // gap). Both sides, and the screen that is still being shared.
2426 // The stopper still holds the keeper's screen; the keeper holds
2427 // none. Both cameras, either way.
2428 await watchMedia([
2429 { page: stopper.page, label: stopper.label, keys: [stopper.other, keptKey] },
2430 { page: keeper.page, label: keeper.label, keys: [keeper.other] },
2431 ], 'after the one-sided stop settled: ');
2432
2433 check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
2434 check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
2435
2436 await keeper.page.click('#share');
2437 await leaveRoom(pageA);
2438 await leaveRoom(pageB);
2439 await ctxA.close();
2440 await ctxB.close();
2441 },
2442
2443 // The residual spec §15.16 names, forced rather than waited for.
2444 //
2445 // POLITE_YIELD is 300 ms, and a pair whose signalling round trip
2446 // exceeds it still collides: the polite side gives way, offers
2447 // anyway, and the remote offer lands afterwards — so it rolls back,
2448 // and Chromium rebuilds every send stream on the connection. The
2449 // camera's comes back having encoded nothing and never encodes again,
2450 // on an m-line that goes on reading sendrecv with a live track. A
2451 // slow network is only one way to get there: a 429 retry and a slow
2452 // offer arrive just as late.
2453 //
2454 // The lateness here is real rather than simulated. One context's own
2455 // `offer` POSTs are held for four times the yield before they reach
2456 // the hub, so the other side's offer really does arrive after the
2457 // yield has ended, on a connection that has already offered.
2458 //
2459 // What this asserts is the recovery, not the failure: a Chromium that
2460 // no longer rebuilds the streams passes it exactly as a repaired one
2461 // does, which is the right way round for a gate — the assertion is
2462 // "the camera is encoding again", never "the camera broke first".
2463 // Whether it stalled on the way is recorded as a line rather than a
2464 // check, because that half is the browser's behaviour and not this
2465 // app's promise.
2466 async laterolls() {
2467 const HOLD = 1200; // four times POLITE_YIELD
2468 const ctxA = await newContext('member A', { cookie: cookieA });
2469 const ctxB = await newContext('member B', { cookie: cookieB });
2470 const pageA = await openRoom(ctxA);
2471 const pageB = await openRoom(ctxB);
2472 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
2473 if (!pair) {
2474 await ctxA.close();
2475 await ctxB.close();
2476 return;
2477 }
2478
2479 // §5.1's tie-break, read the way room.js reads it: the lower id is
2480 // the polite one, and the polite one is the side that rolls back.
2481 const aPolite = String(pair.a.peer) < String(pair.b.peer);
2482 const polite = aPolite
2483 ? { page: pageA, ctx: ctxA, label: 'member A', peer: pair.a.peer, other: pair.b.peer }
2484 : { page: pageB, ctx: ctxB, label: 'member B', peer: pair.b.peer, other: pair.a.peer };
2485 const impolite = aPolite
2486 ? { page: pageB, ctx: ctxB, label: 'member B', peer: pair.b.peer, other: pair.a.peer }
2487 : { page: pageA, ctx: ctxA, label: 'member A', peer: pair.a.peer, other: pair.b.peer };
2488 line('the polite side is ' + polite.label + ', and its offers are the ones that roll back');
2489
2490 // Only the impolite side's offers, and only the offers: everything
2491 // else — candidates, the share announcement, the leave — goes at
2492 // its own speed, so what is delayed is exactly the thing that makes
2493 // the collision late.
2494 const held = [];
2495 await impolite.ctx.route('**/signal', async (route) => {
2496 const body = route.request().postData() || '';
2497 if (body.includes('"type":"offer"')) {
2498 held.push(Date.now());
2499 await sleep(HOLD);
2500 }
2501 await route.continue();
2502 });
2503
2504 await Promise.all([polite.page.hover('#share'), impolite.page.hover('#share')]);
2505 await Promise.all([polite.page.click('#share'), impolite.page.click('#share')]);
2506
2507 check(await until(() => held.length > 0, 15000) !== null,
2508 'the impolite side had an offer to hold back (' + held.length + ' held)');
2509
2510 for (const side of [polite, impolite]) {
2511 const announced = await until(async () => {
2512 const s = await state(side.page);
2513 return s.shares.includes(side.other) ? s : false;
2514 }, 20000);
2515 check(announced, side.label + " was told about the other's screen through the delay — " +
2516 (await diagnose(side.page, side.label)));
2517 }
2518
2519 // The collision, asserted rather than assumed. Holding an offer
2520 // back is a delay; what this scenario is about is what the delay
2521 // causes, and that is a remote offer arriving over this side's own
2522 // outstanding one — which is precisely when perfect negotiation
2523 // rolls the polite side back. The trap counts exactly that.
2524 const rolled = await until(async () => (await rollbacks(polite.page)) > 0, 15000);
2525 check(rolled, 'the delay produced a rollback on the polite side (' +
2526 (await rollbacks(polite.page)) + ' seen; without one this scenario proves nothing)');
2527
2528 // The stall itself, watched but not required, and watched as a
2529 // series rather than as two numbers five seconds apart: a total
2530 // that grew across the window cannot tell a steady stream from one
2531 // that ran for a moment and stopped.
2532 const series = [];
2533 for (let i = 0; i < 6; i++) {
2534 series.push((await state(impolite.page)).frames[polite.peer] || 0);
2535 await sleep(500);
2536 }
2537 const moving = series[series.length - 1] > series[series.length - 2];
2538 line('the polite side\'s camera, rendered frames every 500 ms: ' + JSON.stringify(series) +
2539 (moving ? ' — still moving' : ' — stopped'));
2540
2541 // And what the tiles are actually bound to, on both sides, whatever
2542 // happens next: three answers that ought to be one object.
2543 for (const side of [polite, impolite]) {
2544 line(side.label + ' identity: ' + JSON.stringify(await mediaIdentity(side.page)));
2545 }
2546
2547 // The recovery, which is the promise: whatever the rollback did,
2548 // both cameras and both screens are delivering frames across two
2549 // successive intervals inside the repair budget — two polls to
2550 // notice, a replaceTrack cycle, two more polls, and a reset behind
2551 // that.
2552 // Recovery has to *arrive* before it can be required to last, and
2553 // arriving takes real time: three polls for the watch to notice,
2554 // two replays, and behind those a reset that rebuilds the pair. So
2555 // a deadline for the arrival — growth from a baseline taken now,
2556 // never "the counter is above zero", which a counter left over from
2557 // before the collision answers on its own — and then, separately,
2558 // an observation for the lasting.
2559 for (const side of [polite, impolite]) {
2560 for (const key of [side.other, side.other + '#share']) {
2561 const back = await frameGrowth(side.page, key, 40000);
2562 if (!check(back.ok, side.label + ' has ' + key + ' arriving again after the collision (' +
2563 growth(back) + ')')) {
2564 line(' ' + (await diagnose(side.page, side.label)));
2565 line(' identity: ' + JSON.stringify(await mediaIdentity(side.page)));
2566 }
2567 }
2568 }
2569
2570 // Settled *after* the recovery, because a reset is one of the ways
2571 // this scenario is allowed to recover and a pair mid-rebuild is not
2572 // a pair that failed to settle.
2573 const settled = await bothStable(
2574 [{ page: polite.page, label: polite.label, other: polite.other },
2575 { page: impolite.page, label: impolite.label, other: impolite.other }], 15000);
2576 check(settled === null, 'the late collision left both connections settled — ' + JSON.stringify(settled));
2577
2578 // And then the windows: every stream, both pages, growth in each
2579 // one. A recovery that lasts as long as a check and stops
2580 // afterwards is the shape this scenario exists to refuse.
2581 await watchMedia([
2582 { page: polite.page, label: polite.label, keys: [polite.other, polite.other + '#share'] },
2583 { page: impolite.page, label: impolite.label, keys: [impolite.other, impolite.other + '#share'] },
2584 ], 'and kept arriving once it had settled: ');
2585
2586 check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
2587 check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
2588
2589 await impolite.ctx.unroute('**/signal');
2590 await polite.page.click('#share');
2591 await impolite.page.click('#share');
2592 await leaveRoom(pageA);
2593 await leaveRoom(pageB);
2594 await ctxA.close();
2595 await ctxB.close();
2596 },
2597
2598 // A stream lives five minutes and is then renewed by a resume. Six
2599 // seconds here, through a header only development honours, so the
2600 // renewal happens several times while the call is watched — and six
2601 // rather than two because a stream shorter than the client's own
2602 // "this one was healthy" mark never clears its backoff, so the
2603 // renewals would slow down and this scenario would be measuring the
2604 // backoff ladder rather than the renewal. The client's mark stays
2605 // where it is; the header moves.
2606 async renewal() {
2607 const ctxA = await newContext('member A', { cookie: cookieA });
2608 const ctxB = await newContext('member B', { cookie: cookieB });
2609 // Counting the attaching GETs is what stops this scenario passing
2610 // vacuously: a header the server ignored would leave the call up
2611 // for the whole watch on one five-minute stream, and every other
2612 // assertion here would still be true.
2613 let attaches = 0;
2614 for (const ctx of [ctxA, ctxB]) {
2615 await ctx.route('**/stream*', async (route) => {
2616 const url = route.request().url();
2617 if (!url.includes('probe=1')) attaches += 1;
2618 await route.continue({ headers: { ...route.request().headers(), 'x-calls-test-ttl': '6s' } });
2619 });
2620 }
2621 const pageA = await openRoom(ctxA);
2622 const pageB = await openRoom(ctxB);
2623
2624 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
2625 if (!pair) {
2626 await ctxA.close();
2627 await ctxB.close();
2628 return;
2629 }
2630
2631 const before = { a: await state(pageA), b: await state(pageB) };
2632 // Long enough for at least one renewal each at a six-second life,
2633 // and in practice two. Watched four times a second the whole way:
2634 // a renewal is a stream ended on purpose and resumed, and §15.23 says
2635 // nobody sees it — not "Reconnecting…" over the call, not on a tile,
2636 // not in the title (Paul, 2026-09-30: "I saw 'Reconnecting' appear
2637 // occasionally over the video, but only for a second").
2638 const said = [];
2639 let streamDowns = 0;
2640 for (const until = Date.now() + 16000; Date.now() < until;) {
2641 for (const [page, who] of [[pageA, 'A'], [pageB, 'B']]) {
2642 const r = await page.evaluate(() => ({
2643 reconnect: window.meet.state().reconnect,
2644 status: (document.getElementById('status') || {}).textContent || '',
2645 title: (document.getElementById('call-top') || {}).textContent || '',
2646 tiles: Array.from(document.querySelectorAll('.calls-tile-notice, [data-hear-notice]')).map((n) => n.textContent),
2647 }));
2648 if (r.reconnect.streamDown) streamDowns += 1;
2649 const text = [r.status, r.title, ...r.tiles].join(' | ');
2650 if (r.reconnect.call || r.reconnect.peers.length || /Reconnecting/.test(text)) said.push(who + ': ' + text);
2651 }
2652 await sleep(250);
2653 }
2654 const after = { a: await state(pageA), b: await state(pageB) };
2655 check(said.length === 0, 'no renewal was said as "Reconnecting" (' + said.slice(0, 3).join('; ') + ')');
2656 console.log('renewal: a stream was seen down (between its end and its resume) at ' + streamDowns + ' of the looks');
2657
2658 check(after.a.peer === before.a.peer,
2659 'A kept its participation across the renewals (' + before.a.peer + ' -> ' + after.a.peer + ')');
2660 check(after.b.peer === before.b.peer,
2661 'B kept its participation across the renewals (' + before.b.peer + ' -> ' + after.b.peer + ')');
2662 check(after.a.admitted && after.a.peers.includes(before.b.peer),
2663 'A is still in the call after the renewals — ' + (await diagnose(pageA, 'member A')));
2664 check(after.b.admitted && after.b.peers.includes(before.a.peer),
2665 'B is still in the call after the renewals — ' + (await diagnose(pageB, 'member B')));
2666 check((after.a.frames[before.b.peer] || 0) > (before.a.frames[before.b.peer] || 0),
2667 'A is still receiving video after the renewals (' +
2668 (before.a.frames[before.b.peer] || 0) + ' -> ' + (after.a.frames[before.b.peer] || 0) + ')');
2669 // Two browsers, a six-second life, sixteen seconds of watching: the
2670 // first two attaches are the two fresh streams, and everything past
2671 // that is a renewal that actually happened. The floor is four — one
2672 // renewal each — rather than the number a stopwatch would predict,
2673 // because what this counts is that the header was honoured at all: a
2674 // server that ignored it would leave both calls up on one
2675 // five-minute stream and stop at two.
2676 check(attaches >= 4, 'the streams really were renewed (' + attaches + ' attaching GETs, want at least 4)');
2677
2678 await leaveRoom(pageA);
2679 await leaveRoom(pageB);
2680 await ctxA.close();
2681 await ctxB.close();
2682 },
2683
2684 // §5.2 in a real browser: the app has a relay, both members fetch the
2685 // list before they build anything, and the pair connects anyway — the
2686 // contract names a host that does not exist, so a call that connects
2687 // at all connected on host candidates. The credential in that list is
2688 // the one thing that must never turn up anywhere else.
2689 async ice() {
2690 const ctxA = await newContext('member A', { cookie: cookieA });
2691 const ctxB = await newContext('member B', { cookie: cookieB });
2692 const iceA = await iceRoute(ctxA);
2693 const iceB = await iceRoute(ctxB);
2694 const pageA = await openRoom(ctxA);
2695 const pageB = await openRoom(ctxB);
2696
2697 // assertPair is the assertion that matters most here: signalling
2698 // settled, the transport up, and a decoded frame each way, with two
2699 // unreachable servers in every connection's configuration.
2700 await assertPair(pageA, pageB, 'member A', 'member B');
2701
2702 for (const [page, label, seen] of [[pageA, 'member A', iceA], [pageB, 'member B', iceB]]) {
2703 const s = await state(page);
2704 check(s.ice.servers === 2,
2705 label + ' holds the two servers /ice minted (servers=' + s.ice.servers + ')');
2706 // And the connections were actually built with them. The line
2707 // above is the client's own cached count and would read the same
2708 // if every RTCPeerConnection were constructed with an empty list;
2709 // this is what the constructor received (Codex S3-5).
2710 const configs = await iceConfigs(page);
2711 const live = await livePcs(page);
2712 const now = installedPerConnection(configs, live);
2713 // Per connection, and the *last* configuration each one took: a
2714 // connection built with the list and one that adopted it at its
2715 // first quiet moment are both §5.2, and neither may be left on
2716 // the empty one. Counted globally, one connection's second update
2717 // could stand in for another's first (Codex S3-10).
2718 check(now.length > 0 && now.every((c) => c.servers === 2 && c.credentialed === 1),
2719 label + ' has every live connection configured with the two-entry list, one credentialed (' +
2720 JSON.stringify(now) + ' of ' + JSON.stringify(configs) + ')');
2721 check(s.ice.fetches === 1,
2722 label + ' fetched the list once for the call, not once per connection (fetches=' + s.ice.fetches + ')');
2723 check(s.ice.expiresAt > Date.now() && s.ice.renewAt > Date.now() && s.ice.renewAt < s.ice.expiresAt,
2724 label + ' was given a renewal time before an expiry, both ahead of now (' + JSON.stringify(s.ice) + ')');
2725 check(s.ice.warning === false, label + ' shows no relay warning (' + JSON.stringify(s.ice) + ')');
2726
2727 const w = await warningRegion(page);
2728 check(w && w.role === 'status' && w.hidden && w.text === '',
2729 label + "'s warning region is in the page, empty and hidden (" + JSON.stringify(w) + ')');
2730 check((await tileNotes(page)).length === 0,
2731 label + ' has no sentence under any tile (' + JSON.stringify(await tileNotes(page)) + ')');
2732
2733 check(seen.served >= 1 && seen.servers.every((n) => n === 2),
2734 label + ' was served a two-entry list every time (' + JSON.stringify(seen.servers) + ')');
2735 check(seen.turn.length === seen.served,
2736 label + ' got exactly one credentialed entry per fetch (' + seen.turn.length + ' of ' + seen.served + ')');
2737 // The five fields are the platform's own username shape: the
2738 // expiry the credential is minted against, then the account, the
2739 // app, the incarnation and the key id the suffix names.
2740 const fields = seen.turn.length ? seen.turn[0].split(':') : [];
2741 check(fields.length === 5,
2742 label + "'s turn username has five colon-separated fields (" + fields.length + ')');
2743 check(fields[1] === 'abc' && fields[2] === 'meet' && fields[3] === '0123456789abcdef' && fields[4] === 'k1',
2744 label + "'s turn username carries the contract's suffix (" + fields.slice(1).join(':') + ')');
2745 check(Number(fields[0]) > Math.floor(Date.now() / 1000),
2746 label + "'s turn username is minted against an expiry in the future (" + fields[0] + ')');
2747 }
2748
2749 // §5.2's promise, measured: room.js prints an error object and never
2750 // a response, so no credential is in any console line — of either
2751 // browser, since one page's list must not reach the other's log
2752 // either.
2753 const creds = [...iceA.credentials, ...iceB.credentials];
2754 check(creds.length === 2, 'both members were minted a credential (' + creds.length + ')');
2755 const allLines = linesFor('member A') + '\n' + linesFor('member B');
2756 const leaked = leakedCredential(creds, allLines);
2757 check(leaked === -1,
2758 'no credential reaches the console (credential ' + leaked + ' of ' + creds.length +
2759 ' was found in ' + allLines.split('\n').filter(Boolean).length + ' console line(s))');
2760
2761 check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
2762 check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
2763
2764 await leaveRoom(pageA);
2765 await leaveRoom(pageB);
2766 await ctxA.close();
2767 await ctxB.close();
2768 },
2769
2770 // §5.3 steps 2 and 4: the credential's life shortened to eighteen
2771 // seconds, so the schedule the server named comes round while the call
2772 // is being watched. A is renewed; B is the control — no header, so its
2773 // credential lasts the production three hours and it never renews. One
2774 // browser's ICE restart is what step 2 is about, and two of them at
2775 // once would be a glare test wearing a renewal's clothes.
2776 //
2777 // Eighteen rather than twelve: the server renews at five sixths of the
2778 // life and expires at the whole of it, so twelve leaves two seconds
2779 // between the renewal starting and the deadline that prints the
2780 // expired sentence — and this scenario asserts that sentence stays
2781 // away. Eighteen leaves three, and still renews inside the window.
2782 async icerenewal() {
2783 const ctxA = await newContext('member A', { cookie: cookieA });
2784 const ctxB = await newContext('member B', { cookie: cookieB });
2785 await ctxA.addInitScript(pcTap);
2786 const iceA = await iceRoute(ctxA, { ttl: '18s' });
2787 const iceB = await iceRoute(ctxB);
2788 const pageA = await openRoom(ctxA);
2789 const pageB = await openRoom(ctxB);
2790
2791 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
2792 if (!pair) {
2793 await ctxA.close();
2794 await ctxB.close();
2795 return;
2796 }
2797 const peerA = pair.a.peer;
2798 const peerB = pair.b.peer;
2799
2800 const first = await state(pageA);
2801 check(first.ice.fetches === 1, 'A is on its first list before the renewal (' + first.ice.fetches + ')');
2802 check(first.ice.expiresAt - first.ice.renewAt === 3000,
2803 'the shortened life left the three-second margin the schedule is measured from (' +
2804 (first.ice.expiresAt - first.ice.renewAt) + ' ms)');
2805
2806 // The generation the call is on now, read the same way room.js's own
2807 // proof reads it. Without this the "changed" half of the assertion
2808 // below would have nothing to change from.
2809 const before = await selectedUfrags(pageA);
2810 check(before.length === 1 && before[0],
2811 'A is on a named ICE generation before the renewal (' + JSON.stringify(before) + ')');
2812
2813 const second = await until(async () => {
2814 const s = await state(pageA);
2815 return s.ice.fetches >= 2 ? s : false;
2816 }, 25000);
2817 check(second, 'A fetched a second list on the schedule the server named — ' + (await diagnose(pageA, 'member A')));
2818 if (!second) {
2819 await ctxA.close();
2820 await ctxB.close();
2821 return;
2822 }
2823 check(second.ice.lastFetchAt > first.ice.lastFetchAt,
2824 'the second list is a fetch and not a re-read (' + (second.ice.lastFetchAt - first.ice.lastFetchAt) + ' ms later)');
2825 check(second.ice.renewAt > Date.now(),
2826 'the second fetch put the next renewal ahead of now (' + (second.ice.renewAt - Date.now()) + ' ms)');
2827 check(iceA.served >= 2, 'the route saw both of A\'s fetches (' + iceA.served + ')');
2828
2829 // §5.3 step 2's proof: the exchange completed, the connection is
2830 // stable, and the selected pair is on a *new* generation.
2831 const record = await until(async () => {
2832 const s = await state(pageA);
2833 const r = s.renewals[peerB];
2834 return r && r.renewed ? r : false;
2835 }, 25000);
2836 check(record, 'A proved the ICE restart landed on the new list — ' + (await diagnose(pageA, 'member A')));
2837 if (record) {
2838 check(record.attempts === 1, 'one restart was enough (' + record.attempts + ' of 3)');
2839 check(record.ufrag && record.ufrag !== before[0],
2840 'the proof is a new ICE generation (' + before[0] + ' -> ' + record.ufrag + ')');
2841 const after = await selectedUfrags(pageA);
2842 check(after.length === 1 && after[0] && after[0] !== before[0],
2843 "the gate's own walk of the selected pair agrees (" + JSON.stringify(before) + ' -> ' + JSON.stringify(after) + ')');
2844
2845 // And the renewal installed the *replacement* credential, rather
2846 // than restarting on the one it already had: a fresh generation
2847 // and flowing media would look exactly the same if renewal's
2848 // setConfiguration call were deleted, because host candidates
2849 // carry this call either way (Codex S3-5). Digests, never
2850 // credentials.
2851 const configs = await iceConfigs(pageA);
2852 const live = await livePcs(pageA);
2853 const mine = configs.filter((c) => live.includes(c.pc));
2854 const credentialed = mine.filter((c) => c.servers === 2 && c.credentialed === 1);
2855 const digests = Array.from(new Set(credentialed.map((c) => c.digest)));
2856 // Two credentialed configurations with *different* digests: the
2857 // first list this connection was given, and the replacement the
2858 // renewal installed. Which of them arrived through the
2859 // constructor and which through setConfiguration is not the
2860 // claim — a connection built during the first fetch takes its
2861 // first list through adopt — and the digests never leave the
2862 // page as anything but digests.
2863 check(credentialed.length >= 2 && digests.length >= 2,
2864 'the renewal installed a credential different from the one this connection had (' +
2865 JSON.stringify(mine.map((c) => c.at + ':' + c.pc + ':' + c.servers + ':' + c.digest)) + ')');
2866 const last = installedPerConnection(mine, live);
2867 check(last.length > 0 && last.every((c) => c.digest === digests[digests.length - 1]),
2868 'and it is the newer credential each live connection is left on (' +
2869 JSON.stringify(last) + ')');
2870 }
2871
2872 // The call is what matters, not the restart: frames rising on both
2873 // sides two seconds after the renewal was proven.
2874 const framesA = (await state(pageA)).frames[peerB] || 0;
2875 const framesB = (await state(pageB)).frames[peerA] || 0;
2876 await sleep(2000);
2877 const risingA = await until(async () => {
2878 const s = await state(pageA);
2879 return (s.frames[peerB] || 0) > framesA ? s.frames[peerB] : false;
2880 }, 15000);
2881 check(risingA, 'A is still receiving video after the renewal (' + framesA + ' -> ' + risingA + ') — ' +
2882 (await diagnose(pageA, 'member A')));
2883 const risingB = await until(async () => {
2884 const s = await state(pageB);
2885 return (s.frames[peerA] || 0) > framesB ? s.frames[peerA] : false;
2886 }, 15000);
2887 check(risingB, 'B is still receiving video after A renewed (' + framesB + ' -> ' + risingB + ') — ' +
2888 (await diagnose(pageB, 'member B')));
2889
2890 // Nothing expired: a renewal that lands inside the old credential's
2891 // life is the whole point of the schedule, and §5.3 step 3's
2892 // sentence belongs to the case where it does not.
2893 const end = await state(pageA);
2894 check(end.renewals[peerB] && end.renewals[peerB].expired === false,
2895 'no connection was marked expired (' + JSON.stringify(end.renewals) + ')');
2896 check(end.ice.warning === false, 'no relay warning was shown (' + JSON.stringify(end.ice) + ')');
2897 for (const [page, label] of [[pageA, 'member A'], [pageB, 'member B']]) {
2898 const notes = await tileNotes(page);
2899 check(!notes.includes(ICE_EXPIRED),
2900 label + ' shows no tile the credential expired on (' + JSON.stringify(notes) + ')');
2901 const w = await warningRegion(page);
2902 check(w && w.hidden && w.text === '', label + "'s warning region stayed empty (" + JSON.stringify(w) + ')');
2903 }
2904 check((await state(pageB)).ice.fetches === 1 && iceB.served === 1,
2905 'B, on the production schedule, never renewed (' + (await state(pageB)).ice.fetches + ' fetch(es))');
2906
2907 await leaveRoom(pageA);
2908 await leaveRoom(pageB);
2909 await ctxA.close();
2910 await ctxB.close();
2911 },
2912
2913 // §5.3 steps 1 and 3, the unhappy half: the list is fetched once and
2914 // every fetch after that is refused. A is told, in a sentence, that
2915 // Calls cannot reach the relay; when the old credential's own deadline
2916 // passes with nothing renewed, the connection that was built on it
2917 // says so too — and through all of it the call stays up, because on
2918 // this network the relay was never load-bearing.
2919 //
2920 // Twelve seconds here rather than the renewal scenario's eighteen:
2921 // this one wants the deadline to arrive, and it arrives two seconds
2922 // after the warning.
2923 //
2924 // What is NOT asserted: the recovery. The client's retry is a fixed
2925 // minute (§5.3 step 1) and shortening it from the gate would be
2926 // testing a knob rather than the client, so proving the warning clears
2927 // would cost this scenario seventy seconds of waiting. The plan's own
2928 // ruling says the same; the recovery timing stays unproved here.
2929 // §13's other renewal ending, and the one no scenario reached: the
2930 // fetch works, the restart does not. room.js must spend all three
2931 // attempts and then say the connection is stuck — never that the
2932 // credential expired, which it plainly has not, and which is what the
2933 // client used to say after one attempt when the old deadline happened
2934 // to fall inside the window (Codex S3-4, S3-5).
2935 //
2936 // The instrument is a page-side stub: A's restartIce does nothing at
2937 // all. Everything else is real — the fetch is served normally, the
2938 // list is installed, the exchange is untouched — so what is being
2939 // tested is the state machine's own verdict on a restart that will
2940 // not land.
2941 async icestuck() {
2942 const ctxA = await newContext('member A', { cookie: cookieA });
2943 const ctxB = await newContext('member B', { cookie: cookieB });
2944 await ctxA.addInitScript(() => {
2945 const proto = window.RTCPeerConnection && window.RTCPeerConnection.prototype;
2946 if (proto) proto.restartIce = function noRestart() {};
2947 });
2948 const iceA = await iceRoute(ctxA, { ttl: '12s' });
2949 const pageA = await openRoom(ctxA);
2950 const pageB = await openRoom(ctxB);
2951
2952 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
2953 if (!pair) {
2954 await ctxA.close();
2955 await ctxB.close();
2956 return;
2957 }
2958 const peerB = pair.b.peer;
2959 const framesBefore = (await state(pageA)).frames[peerB] || 0;
2960
2961 // The renewal itself works: a second list arrives.
2962 const renewing = await until(async () => {
2963 const s = await state(pageA);
2964 return s.ice.fetches >= 2 ? s : false;
2965 }, 25000);
2966 check(renewing, 'A fetched a replacement list (' + iceA.served + ' served) — ' +
2967 (await diagnose(pageA, 'member A')));
2968 check(!(await state(pageA)).ice.warning,
2969 'and nothing claims the relay is unreachable: it answered (' +
2970 JSON.stringify((await state(pageA)).ice) + ')');
2971
2972 // Three restarts, thirty seconds each, and none of them lands.
2973 // That is 90 seconds before the verdict, so the wait is generous.
2974 const verdict = await until(async () => {
2975 const r = (await state(pageA)).renewals[peerB];
2976 return r && r.trouble ? r : false;
2977 }, 120000);
2978 check(verdict, 'A reached a verdict on the connection it could not renew — ' +
2979 (await diagnose(pageA, 'member A')));
2980 if (verdict) {
2981 check(verdict.trouble === 'stuck',
2982 'the verdict is that the connection is stuck, not that the credential expired (' +
2983 JSON.stringify(verdict) + ')');
2984 check(verdict.expired === false,
2985 'and it is not marked expired: a replacement credential was in hand throughout (' +
2986 JSON.stringify(verdict) + ')');
2987 // The run that reached this verdict, not the connection's whole
2988 // life: `attempts` keeps counting as later runs take later lists,
2989 // so it cannot say how many restarts *this* verdict followed
2990 // (Codex S3-14). `lastRun` is captured with the verdict itself.
2991 check(verdict.lastRun && verdict.lastRun.attempts === 3 &&
2992 verdict.lastRun.verdict === 'stuck',
2993 'the run that gave up spent the whole three-restart budget (' +
2994 JSON.stringify(verdict.lastRun) + ')');
2995 check(verdict.attempts >= 3,
2996 'and the lifetime count is at least that (' + verdict.attempts + ')');
2997 check(verdict.renewed === false, 'and nothing was proven (' + JSON.stringify(verdict) + ')');
2998 const notes = await tileNotes(pageA);
2999 check(notes.includes(ICE_STUCK) && !notes.includes(ICE_EXPIRED),
3000 "A's tile says it could not renew this connection, and does not say expired (" +
3001 JSON.stringify(notes) + ')');
3002 }
3003
3004 // And the call itself is untouched: a restart that never happened
3005 // costs nothing on a network where the media was already flowing.
3006 const rising = await until(async () => {
3007 const s = await state(pageA);
3008 return (s.frames[peerB] || 0) > framesBefore && s.connection[peerB] === 'connected' ? s : false;
3009 }, 15000);
3010 check(rising, 'the call is still connected and still receiving video (' + framesBefore + ' -> ' +
3011 (rising ? rising.frames[peerB] : 'stuck') + ') — ' + (await diagnose(pageA, 'member A')));
3012
3013 await leaveRoom(pageA);
3014 await leaveRoom(pageB);
3015 await ctxA.close();
3016 await ctxB.close();
3017 },
3018
3019 async icefail() {
3020 const ctxA = await newContext('member A', { cookie: cookieA });
3021 const ctxB = await newContext('member B', { cookie: cookieB });
3022 const iceA = await iceRoute(ctxA, { ttl: '12s', failFrom: 2 });
3023 const pageA = await openRoom(ctxA);
3024 const pageB = await openRoom(ctxB);
3025
3026 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
3027 if (!pair) {
3028 await ctxA.close();
3029 await ctxB.close();
3030 return;
3031 }
3032 const peerA = pair.a.peer;
3033 const peerB = pair.b.peer;
3034
3035 const first = await state(pageA);
3036 check(first.ice.fetches === 1 && first.ice.warning === false,
3037 'A is on a good list with no warning before the relay goes (' + JSON.stringify(first.ice) + ')');
3038 check(first.ice.expiresAt - first.ice.renewAt === 2000,
3039 'the shortened life left the two-second margin (' + (first.ice.expiresAt - first.ice.renewAt) + ' ms)');
3040 const framesBefore = (await state(pageA)).frames[peerB] || 0;
3041
3042 const warned = await until(async () => {
3043 const s = await state(pageA);
3044 return s.ice.warning ? s : false;
3045 }, 25000);
3046 check(warned, 'A says it cannot reach the relay — ' + (await diagnose(pageA, 'member A')));
3047 const warnedAt = Date.now();
3048 check(warnedAt - first.ice.renewAt < 15000,
3049 'the sentence arrives within fifteen seconds of the renewal time the server named (' +
3050 (warnedAt - first.ice.renewAt) + ' ms)');
3051 check(iceA.refused >= 1, 'the relay really was refused (' + iceA.refused + ' refusal(s))');
3052 check((await state(pageA)).ice.fetches === 1,
3053 'a refused fetch is not counted as a list (' + (await state(pageA)).ice.fetches + ')');
3054
3055 const w = await warningRegion(pageA);
3056 check(w && w.text === ICE_WARNING && !w.hidden && w.role === 'status',
3057 'the warning is the sentence itself, in a live region a reader is told about (' + JSON.stringify(w) + ')');
3058
3059 // §5.3 step 3: the old credential's expiry is the must-succeed-by,
3060 // and the connection built on it is marked when it passes.
3061 const expired = await until(async () => {
3062 const s = await state(pageA);
3063 const r = s.renewals[peerB];
3064 return r && r.expired ? s : false;
3065 }, 25000);
3066 check(expired, 'the connection that credential was about is marked expired — ' + (await diagnose(pageA, 'member A')));
3067 if (expired) {
3068 check(Date.now() >= first.ice.expiresAt,
3069 'nothing was called expired before the credential it belonged to ran out (' +
3070 (Date.now() - first.ice.expiresAt) + ' ms after)');
3071 check(Date.now() >= warnedAt,
3072 'the warning came first and the sentence second (' + (Date.now() - warnedAt) + ' ms apart)');
3073 const notes = await tileNotes(pageA);
3074 check(notes.includes(ICE_EXPIRED), "A's tile says the credential expired (" + JSON.stringify(notes) + ')');
3075 }
3076
3077 // And through all of it: a call that never needed the relay.
3078 const rising = await until(async () => {
3079 const s = await state(pageA);
3080 return (s.frames[peerB] || 0) > framesBefore &&
3081 s.signaling[peerB] === 'stable' && s.connection[peerB] === 'connected' ? s : false;
3082 }, 15000);
3083 check(rising, 'the call is still connected and still receiving video (' + framesBefore + ' -> ' +
3084 (rising ? rising.frames[peerB] : 'stuck') + ') — ' + (await diagnose(pageA, 'member A')));
3085
3086 // B is the control: nothing was taken away from it, so neither
3087 // sentence belongs on its screen.
3088 const b = await state(pageB);
3089 check(b.ice.warning === false && b.ice.fetches === 1,
3090 'B, whose relay was never taken away, shows no warning (' + JSON.stringify(b.ice) + ')');
3091 check(!(await tileNotes(pageB)).includes(ICE_EXPIRED),
3092 'B shows no expired sentence (' + JSON.stringify(await tileNotes(pageB)) + ')');
3093 check((b.frames[peerA] || 0) > 0, 'B is still receiving A (' + (b.frames[peerA] || 0) + ' frames)');
3094
3095 line('note: the one-minute retry recovering is not asserted here — see the comment above icefail');
3096
3097 await leaveRoom(pageA);
3098 await leaveRoom(pageB);
3099 await ctxA.close();
3100 await ctxB.close();
3101 },
3102
3103 // §4.3's admission queue: a guest knocks, sees nothing of the room,
3104 // and is let in by a member.
3105 //
3106 // Two guests knock, and only one is admitted. That is what makes the
3107 // isolation half of this scenario load-bearing (W-1): with a single
3108 // guest, the queue is empty at the moment it is admitted and no
3109 // admission ever happens while anyone is waiting, so every one of
3110 // §4.3's audience filters can be deleted and the scenario still
3111 // passes. It was, and it did.
3112 async guest() {
3113 const ctxA = await newContext('member A', { cookie: cookieA });
3114 const ctxW = await newContext('waiting guest');
3115 const ctxG = await newContext('guest');
3116 const pageA = await openRoom(ctxA);
3117 check(await until(async () => (await state(pageA)).admitted, 20000),
3118 'the member is in the room — ' + (await diagnose(pageA, 'member A')));
3119
3120 // The guest that stays in the queue for the whole scenario.
3121 await signInAsGuest(ctxW, WAITER);
3122 const pageW = await openRoom(ctxW);
3123 check(await until(async () => ((await state(pageW)).peer ? true : false), 20000),
3124 'the first guest knocked — ' + (await diagnose(pageW, 'waiting guest')));
3125 check(await until(async () => (await pageA.locator('#knocks li').count()) === 1, 20000),
3126 "the member's queue shows the first knock — " + (await diagnose(pageA, 'member A')));
3127
3128 await signInAsGuest(ctxG, GUEST);
3129 const pageG = await openRoom(ctxG);
3130
3131 const knocked = await until(async () => ((await state(pageG)).peer ? true : false), 20000);
3132 check(knocked, 'the guest got a peer id and knocked — ' + (await diagnose(pageG, 'guest')));
3133 check(await waitStatus(pageG, 'Waiting for someone to let you in.', 10000),
3134 'the guest is told it is waiting — status: ' + (await statusText(pageG)));
3135
3136 const waitingState = await state(pageG);
3137 check(waitingState.admitted === false, 'the guest is not admitted before anyone admits it');
3138 check(waitingState.peers.length === 0,
3139 'the guest has no peer connections before admission (' + JSON.stringify(waitingState.peers) + ')');
3140 check((await queueNames(pageG)).length === 0,
3141 'a guest that has only knocked is told nothing about the queue (' + JSON.stringify(await queueNames(pageG)) + ')');
3142
3143 const queued = await until(async () => (await pageA.locator('#knocks li').count()) === 2, 20000);
3144 check(queued, "the member's queue shows both knocks — " + (await diagnose(pageA, 'member A')));
3145 if (queued) {
3146 check(await admitKnock(pageA, 'guest'),
3147 'the queue names the guest to admit (' + JSON.stringify(await queueNames(pageA)) + ')');
3148 }
3149
3150 await assertPair(pageA, pageG, 'member A', 'guest');
3151
3152 // W-1, as a browser can see it. The guest was admitted while
3153 // somebody else was knocking, so roster.waiting had a name in it to
3154 // leak, and an admission happened while the other guest waited.
3155 check((await queueNames(pageG)).length === 0,
3156 'the admitted guest was never handed the waiting queue (' + JSON.stringify(await queueNames(pageG)) + ')');
3157 check(await pageG.locator('#queue').isHidden(),
3158 "the admitted guest's queue stays hidden");
3159 // `terminal === null` and a peer id are load-bearing, not decoration:
3160 // a `peer` event sent to a waiting guest makes it open a connection
3161 // and start signalling, which is refused, which sends it round the
3162 // fresh-stream ladder into `replaced` — and a terminal page has an
3163 // empty peer list too. Without these two the assertion would pass on
3164 // the very leak it is written to catch.
3165 const stillWaiting = await stayed(pageW, 3000,
3166 (s) => s.terminal === null && !!s.peer && s.admitted === false && s.peers.length === 0);
3167 check(stillWaiting === null,
3168 'the guest still waiting is still just waiting, told nothing of the admission — ' +
3169 JSON.stringify(stillWaiting));
3170 check((await queueNames(pageW)).length === 0,
3171 'the guest still waiting knows nothing of the queue it is in (' + JSON.stringify(await queueNames(pageW)) + ')');
3172
3173 // The member does see the queue, so the three checks above are an
3174 // audience and not an empty room.
3175 check(JSON.stringify(await queueNames(pageA)) === JSON.stringify(['waiter']),
3176 "the member's queue still holds the guest that was not admitted (" + JSON.stringify(await queueNames(pageA)) + ')');
3177
3178 // And a knock that arrives *while* the guest is admitted: the same
3179 // audience rule, and the one slice 4's chat fan-out extends. The
3180 // waiter leaves and comes back so the knock happens now rather than
3181 // before the admission.
3182 await leaveRoom(pageW);
3183 const pageW2 = await openRoom(ctxW);
3184 check(await until(async () => ((await state(pageW2)).peer ? true : false), 20000),
3185 'the guest knocked again — ' + (await diagnose(pageW2, 'waiting guest')));
3186 check(await until(async () => (await pageA.locator('#knocks li').count()) === 1, 20000),
3187 "the member is told about the second knock — " + (await diagnose(pageA, 'member A')));
3188 check((await queueNames(pageG)).length === 0,
3189 'the admitted guest is not told who is knocking (' + JSON.stringify(await queueNames(pageG)) + ')');
3190
3191 await leaveRoom(pageG);
3192 await leaveRoom(pageW2);
3193 await leaveRoom(pageA);
3194 await ctxA.close();
3195 await ctxW.close();
3196 await ctxG.close();
3197 },
3198
3199 // §6 and §13's other slice-4 sentence: "chat arrives at the admitted
3200 // context and not at a waiting one."
3201 //
3202 // The guest is what makes that a claim rather than a description. It
3203 // is signed in, it has a participation, it is knocking — everything
3204 // except admitted — so a fan-out that reached everybody with a stream
3205 // would reach it. The window it is watched for is opened only after
3206 // the *member* has the line, so "silence" here means a message that
3207 // demonstrably arrived somewhere and demonstrably did not arrive here.
3208 // Then it is let in, and the very next line has to reach it.
3209 async chat() {
3210 const ctxA = await newContext('member A', { cookie: cookieA });
3211 const ctxB = await newContext('member B', { cookie: cookieB });
3212 const ctxG = await newContext('guest');
3213
3214 // Every chat POST A makes, kept whole. §4.3's wire shape is a
3215 // negative claim — no `to`, no `seq`, no `gen` — and the body A
3216 // actually sent is the only place a browser can prove it. The count
3217 // matters as much as the shape: one line typed is one POST however
3218 // many people hear it, which is the whole difference between a
3219 // broadcast and a loop over the roster.
3220 const chatPosts = [];
3221 await ctxA.route('**/signal', async (route) => {
3222 try {
3223 const msg = JSON.parse(route.request().postData() || '');
3224 if (msg && msg.type === 'chat') chatPosts.push(msg);
3225 } catch {
3226 // No body, or not JSON: not a chat.
3227 }
3228 await route.continue();
3229 });
3230
3231 const pageA = await openRoom(ctxA);
3232 const pageB = await openRoom(ctxB);
3233 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
3234 if (!pair) {
3235 await ctxA.close();
3236 await ctxB.close();
3237 await ctxG.close();
3238 return;
3239 }
3240
3241 check(await until(() => chatMounted(pageA), 10000),
3242 "an admitted member's chat panel is on the page — " + (await diagnose(pageA, 'member A')));
3243 check(await pageA.locator('#chat-overlay').isHidden(), 'and closed until asked for, as on the Mac');
3244 await openChat(pageA);
3245 await openChat(pageB);
3246 check(await pageA.locator('#chat-text').isEditable(), "A's composer is live");
3247 check(await pageB.locator('#chat-send').isEnabled(), "B's Send button is live");
3248
3249 // The third context: signed in through the emailed link, knocking,
3250 // not admitted.
3251 await signInAsGuest(ctxG);
3252 const pageG = await openRoom(ctxG);
3253 check(await until(async () => ((await state(pageG)).peer ? true : false), 20000),
3254 'the guest got a peer id and knocked — ' + (await diagnose(pageG, 'guest')));
3255 check(await waitStatus(pageG, 'Waiting for someone to let you in.', 10000),
3256 'the guest is told it is waiting — status: ' + (await statusText(pageG)));
3257 check((await state(pageG)).admitted === false, 'the guest is waiting, not admitted');
3258 check(!(await chatMounted(pageG)) && await pageG.locator('#chat').isHidden(),
3259 'a guest that has only knocked is shown no chat panel');
3260
3261 const nameA = await selfName(pageA);
3262 check(nameA !== '', "A's own tile names it (" + nameA + ')');
3263
3264 const HELLO = 'hello from A';
3265 await pageA.fill('#chat-text', HELLO);
3266 await pageA.press('#chat-text', 'Enter');
3267
3268 const heard = await until(async () => {
3269 const log = await chatLog(pageB);
3270 return log.find((l) => l.text === HELLO) || false;
3271 }, 2000);
3272 check(heard, "B's log gained the line within two seconds (" +
3273 JSON.stringify(await chatLog(pageB)) + ')');
3274 if (heard) {
3275 // Not "the envelope's name": what a browser can see is that the
3276 // line B was given is attributed to A and not to B, and with two
3277 // distinct local parts on the two addresses (browser_test.go says
3278 // why) that is a claim this check can fail.
3279 check(heard.name === nameA,
3280 "the line B sees is attributed to A, not to B (" + JSON.stringify(heard.name) +
3281 ' vs ' + JSON.stringify(nameA) + ')');
3282 check(heard.self === false, "the line B sees is not drawn as B's own");
3283 }
3284
3285 // A's echo is appended when A's own POST is answered, and B's line
3286 // when the hub's broadcast reaches B's stream: two different events
3287 // with no order between them. Reading A's log the instant B had the
3288 // line raced that, and lost about one run in six on main f5a1254 as
3289 // on this branch (#24). The claim is unchanged — A's log shows what A
3290 // said — and it gets the same two seconds B's does.
3291 const echo = await until(async () => (await chatLog(pageA)).find((l) => l.text === HELLO) || false, 2000);
3292 check(echo, "A's own log shows what A said (" + JSON.stringify(await chatLog(pageA)) + ')');
3293 if (echo) check(echo.self === true, "A's echo is drawn as A's own line");
3294
3295 // §4.3: the hub never sends a chat back to the tab that sent it, and
3296 // the line in A's log is the panel's own echo. Those two facts are
3297 // one line each and they are what stop a double-drawn message
3298 // passing every check above.
3299 check((await state(pageA)).chat.received === 0,
3300 'A was not sent its own line back (' + JSON.stringify((await state(pageA)).chat) + ')');
3301 check((await chatLog(pageA)).filter((l) => l.text === HELLO).length === 1,
3302 "A's log holds the line once, not twice (" + JSON.stringify(await chatLog(pageA)) + ')');
3303 check((await chatLog(pageB)).filter((l) => l.text === HELLO).length === 1,
3304 "B's log holds the line once, not twice (" + JSON.stringify(await chatLog(pageB)) + ')');
3305
3306 // The member side is polled first, deliberately: the guest's
3307 // two-second window has to be a window in which the message
3308 // demonstrably existed, or it is a test of nothing having happened
3309 // yet. Every guest check below is guarded on it for the same reason
3310 // — an unsent message is silent everywhere, and this scenario would
3311 // be reporting that as a pass.
3312 const counted = await until(async () => {
3313 const s = await state(pageB);
3314 return s.chat.received >= 1 ? s : false;
3315 }, 5000);
3316 check(counted, 'B counted the line it put in its log (' +
3317 JSON.stringify((await state(pageB)).chat) + ')');
3318 check(counted && counted.chat.received === 1,
3319 'and counted it once (' + JSON.stringify(counted && counted.chat) + ')');
3320 check((await state(pageA)).chat.sent === 1,
3321 'A counted exactly the one line the hub accepted (' +
3322 JSON.stringify((await state(pageA)).chat) + ')');
3323
3324 if (counted) {
3325 const silent = await stayed(pageG, 2000, (s) => s.chat.received === 0 && s.admitted === false);
3326 check(silent === null,
3327 'the waiting guest was sent no chat at all — ' + JSON.stringify(silent));
3328 check(!(await chatMounted(pageG)) && await pageG.locator('#chat').isHidden(),
3329 "the waiting guest's chat panel is still not there to put anything in");
3330 check((await chatLog(pageG)).length === 0,
3331 "the waiting guest's log is empty (" + JSON.stringify(await chatLog(pageG)) + ')');
3332 } else {
3333 check(false, 'the guest was not watched for silence: nothing was proven to have been said');
3334 }
3335
3336 check(chatPosts.length === 1,
3337 'one line typed is one POST, however many people hear it (' + chatPosts.length + ')');
3338 const sent = chatPosts[0] || {};
3339 check(sent.type === 'chat' && sent.peer === pair.a.peer &&
3340 sent.payload && sent.payload.text === HELLO,
3341 'the POST carries this tab\'s own peer and the text (' + JSON.stringify(Object.keys(sent)) + ')');
3342 check(!('to' in sent) && !('seq' in sent) && !('gen' in sent),
3343 'a broadcast names no target and no ordering (' + JSON.stringify(Object.keys(sent)) + ')');
3344
3345 // And now the other half of the sentence.
3346 const queued = await until(async () => (await pageA.locator('#knocks li').count()) === 1, 20000);
3347 check(queued, "the member's queue shows the knock — " + (await diagnose(pageA, 'member A')));
3348 if (queued) {
3349 check(await admitKnock(pageA, 'guest'),
3350 'the queue names the guest to admit (' + JSON.stringify(await queueNames(pageA)) + ')');
3351 }
3352
3353 const admitted = await until(async () => {
3354 const s = await state(pageG);
3355 return s.admitted && s.rosters >= 1 ? s : false;
3356 }, 25000);
3357 check(admitted, 'the guest was admitted and has its roster — ' + (await diagnose(pageG, 'guest')));
3358 check(await until(() => chatMounted(pageG), 10000),
3359 "the admitted guest's chat panel is on the page — " + (await diagnose(pageG, 'guest')));
3360
3361 const AGAIN = 'and now the guest is here';
3362 await pageA.fill('#chat-text', AGAIN);
3363 await pageA.press('#chat-text', 'Enter');
3364
3365 const guestHeard = await until(async () => {
3366 const log = await chatLog(pageG);
3367 return log.find((l) => l.text === AGAIN) || false;
3368 }, 5000);
3369 check(guestHeard, 'the admitted guest receives the next line (' +
3370 JSON.stringify(await chatLog(pageG)) + ')');
3371 if (guestHeard) {
3372 check(guestHeard.name === nameA,
3373 "the guest's line is attributed to A too (" + JSON.stringify(guestHeard.name) + ')');
3374 }
3375 // §6 has no replay and no history: what was said before this guest
3376 // was let in is not caught up with, and nothing offers to.
3377 check((await chatLog(pageG)).every((l) => l.text !== HELLO),
3378 'and is handed nothing that was said before it was let in (' +
3379 JSON.stringify(await chatLog(pageG)) + ')');
3380 check(chatPosts.length === 2,
3381 'the second line is one POST as well (' + chatPosts.length + ')');
3382 // Exactly two, not at least two: §6 has no replay and no gap, so a
3383 // count that climbed past what was said would mean something was
3384 // delivered twice, and a count short of it would mean a line went
3385 // missing. Both are the same assertion.
3386 const both = await until(async () => {
3387 const s = await state(pageB);
3388 return s.chat.received >= 2 ? s : false;
3389 }, 5000);
3390 check(both && both.chat.received === 2,
3391 'B heard both lines, each once (' + JSON.stringify((await state(pageB)).chat) + ')');
3392 check((await chatLog(pageB)).filter((l) => l.text === AGAIN).length === 1,
3393 "and drew the second one once (" + JSON.stringify(await chatLog(pageB)) + ')');
3394 check((await state(pageA)).chat.received === 0,
3395 'A was sent neither of its own lines back (' +
3396 JSON.stringify((await state(pageA)).chat) + ')');
3397
3398 // §6 and §5.2's promise together: what people say is not logged.
3399 for (const label of ['member A', 'member B', 'guest']) {
3400 const lines = linesFor(label);
3401 check(!lines.includes(HELLO) && !lines.includes(AGAIN),
3402 'nothing anybody typed reached the console on ' + label);
3403 }
3404 check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
3405 check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
3406
3407 await leaveRoom(pageG);
3408 await leaveRoom(pageA);
3409 await leaveRoom(pageB);
3410 await ctxA.close();
3411 await ctxB.close();
3412 await ctxG.close();
3413 },
3414
3415 // §3.5: removal is a member's own confirm page, in its own tab, and
3416 // the person removed is told so in a sentence.
3417 async removed() {
3418 const ctxA = await newContext('member A', { cookie: cookieA });
3419 const ctxG = await newContext('guest');
3420 const pageA = await openRoom(ctxA);
3421 await until(async () => (await state(pageA)).admitted, 20000);
3422
3423 await signInAsGuest(ctxG);
3424 const pageG = await openRoom(ctxG);
3425
3426 const queued = await until(async () => (await pageA.locator('#knocks li').count()) === 1, 20000);
3427 check(queued, 'the guest reached the queue — ' + (await diagnose(pageA, 'member A')));
3428 if (queued) await pageA.click('#knocks li button');
3429
3430 const pair = await assertPair(pageA, pageG, 'member A', 'guest');
3431 if (!pair) {
3432 await ctxA.close();
3433 await ctxG.close();
3434 return;
3435 }
3436
3437 check(await pageA.locator('#tiles figcaption .calls-tile-remove, #tiles figcaption a').count() === 0,
3438 'the name label carries no Remove: it is in the tile menu');
3439 await openTileMenu(pageA, pair.b.peer);
3440 const href = await pageA.locator('.calls-tile-remove').first().getAttribute('href');
3441 check(href === '/m/' + sqid + '/remove/' + pair.b.peer,
3442 "the Remove control names the guest's peer (" + href + ')');
3443
3444 // target="_blank": the call is holding this tab, so the confirm page
3445 // arrives as a second page in the same context.
3446 const [confirm] = await Promise.all([
3447 ctxA.waitForEvent('page', { timeout: 15000 }),
3448 pageA.click('.calls-tile-remove'),
3449 ]);
3450 await confirm.waitForLoadState('domcontentloaded');
3451 const heading = await confirm.locator('h1').first().textContent();
3452 check(/^Remove .* from the call\?$/.test((heading || '').trim()),
3453 'the confirm page asks about this person (' + heading + ')');
3454
3455 await Promise.all([
3456 confirm.waitForURL('**/m/' + sqid + '/join', { timeout: 15000 }),
3457 confirm.click('form[rst-form-actions] button[type="submit"]'),
3458 ]);
3459 check(confirm.url() === origin + '/m/' + sqid + '/join',
3460 'the confirmed removal lands on the holding screen, which opens no stream (at ' + where(confirm) + ')');
3461
3462 check(await waitStatus(pageG, 'Someone removed you from this meeting.', 20000),
3463 'the guest is told it was removed — status: ' + (await statusText(pageG)));
3464 const ended = await until(async () => {
3465 const s = await state(pageG);
3466 return s.terminal === 'removed' && s.admitted === false && s.peers.length === 0 ? s : false;
3467 }, 10000);
3468 check(ended, 'the removed guest is terminal on `removed`, not admitted, and holds no peer connections — ' +
3469 (await diagnose(pageG, 'guest')));
3470
3471 await leaveRoom(pageA);
3472 await ctxA.close();
3473 await ctxG.close();
3474 },
3475
3476 // §2.4 and §13: a member dropped from the team mid-call is dropped on
3477 // the next tick, and the other context sees them go. Only Go can move
3478 // the allowlist and run the tick, so the browser asks for both.
3479 async allowlist() {
3480 const ctxA = await newContext('member A', { cookie: cookieA });
3481 const ctxB = await newContext('member B', { cookie: cookieB });
3482 const pageA = await openRoom(ctxA);
3483 const pageB = await openRoom(ctxB);
3484 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
3485 if (!pair) {
3486 await ctxA.close();
3487 await ctxB.close();
3488 return;
3489 }
3490
3491 const answer = await control('drop-member-b');
3492 check(answer === 'ok', 'B was taken off the team and the tick was run (' + answer + ')');
3493
3494 const dropped = await until(async () => {
3495 const s = await state(pageB);
3496 return s.terminal === 'standing' && s.admitted === false && s.peers.length === 0 ? s : false;
3497 }, 25000);
3498 check(dropped, 'the dropped member is terminal on `standing`, not admitted, and holds no connections — ' +
3499 (await diagnose(pageB, 'member B')));
3500 check(await waitStatus(pageB, 'You no longer have access to this meeting.', 10000),
3501 'the dropped member is told why in plain words — status: ' + (await statusText(pageB)));
3502
3503 const gone = await until(async () => {
3504 const s = await state(pageA);
3505 return !s.peers.includes(pair.b.peer) ? s : false;
3506 }, 25000);
3507 check(gone, 'the other context lost the dropped member from its peers — ' + (await diagnose(pageA, 'member A')));
3508 const tiles = await until(async () => (await pageA.locator('#tiles figure').count()) === 1, 15000);
3509 check(tiles, "the dropped member's tile is gone — " + (await diagnose(pageA, 'member A')));
3510 check(!(await state(pageA)).terminal, 'the member still on the team is still in the call');
3511
3512 await leaveRoom(pageA);
3513 await ctxA.close();
3514 await ctxB.close();
3515 },
3516
3517 // §4.5 and §13: a context whose stream is cut while a peer leaves
3518 // resumes, receives the roster, and has closed the connection to the
3519 // peer that left. The `gone` event never reached it; the roster is
3520 // what tells it.
3521 async departure() {
3522 const ctxA = await newContext('member A', { cookie: cookieA });
3523 const ctxB = await newContext('member B', { cookie: cookieB });
3524 const cut = await cuttableStream(ctxA);
3525 const pageA = await openRoom(ctxA);
3526 const pageB = await openRoom(ctxB);
3527 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
3528 if (!pair) {
3529 await ctxA.close();
3530 await ctxB.close();
3531 return;
3532 }
3533
3534 cut.on = true;
3535 check(await biteCut(cut), "A's attaching stream is cut (" + cut.aborted + ' aborted)');
3536
3537 await leaveRoom(pageB);
3538 cut.on = false;
3539
3540 const back = await until(async () => {
3541 const s = await state(pageA);
3542 return s.peer === pair.a.peer && !s.peers.includes(pair.b.peer) && !s.terminal ? s : false;
3543 }, 45000);
3544 check(back, 'A resumed its own participation and closed the connection to the peer that left — ' +
3545 (await diagnose(pageA, 'member A')));
3546 const tiles = await until(async () => (await pageA.locator('#tiles figure').count()) === 1, 15000);
3547 check(tiles, 'A is left with its own tile only — ' + (await diagnose(pageA, 'member A')));
3548
3549 await leaveRoom(pageA);
3550 await ctxA.close();
3551 await ctxB.close();
3552 },
3553
3554 // §4.5 and §13: signalling whose SSE delivery is cut after the POST's
3555 // 2xx is replayed on resume, and the pair connects. B forces an ICE
3556 // restart while A cannot hear it; the offer sits in A's ring; A's
3557 // resume replays it. B's own signalingState is the proof — it stays at
3558 // have-local-offer until A answers, so B returning to `stable` means
3559 // the replayed offer arrived and was answered.
3560 async replay() {
3561 const ctxA = await newContext('member A', { cookie: cookieA });
3562 const ctxB = await newContext('member B', { cookie: cookieB });
3563 const cut = await cuttableStream(ctxA);
3564 const pageA = await openRoom(ctxA);
3565 const pageB = await openRoom(ctxB);
3566 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
3567 if (!pair) {
3568 await ctxA.close();
3569 await ctxB.close();
3570 return;
3571 }
3572
3573 cut.on = true;
3574 check(await biteCut(cut), "A's attaching stream is cut (" + cut.aborted + ' aborted)');
3575
3576 // The Retry control on B's tile for A is restartIce. The page only
3577 // reveals it on a failed connection, so it is dispatched rather than
3578 // clicked: this scenario is about the signalling, not the control.
3579 await pageB.locator('#tiles figure:not(.calls-tile-self) figcaption button').first().dispatchEvent('click');
3580
3581 // Asserted while A is still cut, and this is the assertion that
3582 // makes the scenario mean anything: B has an offer out that A cannot
3583 // answer, so its signalingState leaves `stable`. Without this, the
3584 // ending state below is the starting state — B stable and connected,
3585 // A's frames still rising because the cut is on the SSE stream and
3586 // not on the media — and a dispatch that never reached restartIce
3587 // would pass.
3588 const offered = await until(async () => {
3589 const b = await state(pageB);
3590 return b.signaling[pair.a.peer] === 'have-local-offer' ? b : false;
3591 }, 5000);
3592 check(offered, 'B has signalling out that A cannot answer while it is cut — ' +
3593 (await diagnose(pageB, 'member B')));
3594
3595 await sleep(5000);
3596 cut.on = false;
3597
3598 const settled = await until(async () => {
3599 const b = await state(pageB);
3600 const a = await state(pageA);
3601 return b.signaling[pair.a.peer] === 'stable' && b.connection[pair.a.peer] === 'connected' &&
3602 a.peer === pair.a.peer && a.connection[pair.b.peer] === 'connected' ? { a, b } : false;
3603 }, 45000);
3604 check(settled, 'the offer B sent while A was cut was replayed on A’s resume and answered — ' +
3605 (await diagnose(pageB, 'member B')) + ' ' + (await diagnose(pageA, 'member A')));
3606
3607 const before = (await state(pageA)).frames[pair.b.peer] || 0;
3608 const rising = await until(async () => ((await state(pageA)).frames[pair.b.peer] || 0) > before, 25000);
3609 check(rising, 'A is decoding video again after the replay (' + before + ' -> ' +
3610 ((await state(pageA)).frames[pair.b.peer] || 0) + ')');
3611
3612 await leaveRoom(pageA);
3613 await leaveRoom(pageB);
3614 await ctxA.close();
3615 await ctxB.close();
3616 },
3617
3618 // §4.5, §4.6 and §13: more targeted messages than the ring holds
3619 // arrive while A is cut, so the replay cannot fill the hole. The hub
3620 // answers `gap`, A asks for a reset, the hub bumps the generation and
3621 // both sides rebuild.
3622 async gap() {
3623 const ctxA = await newContext('member A', { cookie: cookieA });
3624 const ctxB = await newContext('member B', { cookie: cookieB });
3625 const cut = await cuttableStream(ctxA);
3626 // A's own POSTs are counted, not touched: a `reset` from A is the
3627 // observable proof that it received `gap` — nothing else in the room
3628 // makes A ask for one.
3629 let resets = 0;
3630 await ctxA.route('**/signal', async (route) => {
3631 const body = route.request().postData() || '';
3632 if (body.includes('"type":"reset"')) resets += 1;
3633 await route.continue();
3634 });
3635 const pageA = await openRoom(ctxA);
3636 const pageB = await openRoom(ctxB);
3637 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
3638 if (!pair) {
3639 await ctxA.close();
3640 await ctxB.close();
3641 return;
3642 }
3643 check(resets === 0, 'nothing had reset this pair before the gap (' + resets + ')');
3644
3645 cut.on = true;
3646 check(await biteCut(cut), "A's attaching stream is cut (" + cut.aborted + ' aborted)');
3647
3648 // Forty targeted messages, from B's own page so they carry B's
3649 // session and peer, aimed at A. The ring holds thirty-two, and `seq`
3650 // starts far above anything room.js has sent, so every one of them
3651 // is above the cursor A resumes with and the eviction is a real
3652 // hole rather than an already-delivered prefix. `gen` is 1 because
3653 // nothing has reset this pair — the check above is what says so.
3654 const posted = await pageB.evaluate(async ({ room, to, n }) => {
3655 const me = window.meet.state().peer;
3656 let ok = 0;
3657 for (let i = 0; i < n; i++) {
3658 const res = await fetch('/m/' + room + '/signal', {
3659 method: 'POST',
3660 credentials: 'same-origin',
3661 headers: { 'Content-Type': 'application/json' },
3662 body: JSON.stringify({ peer: me, type: 'candidate', to, seq: 100000 + i, gen: 1, payload: {} }),
3663 });
3664 if (res.status === 200) ok += 1;
3665 }
3666 return ok;
3667 }, { room: sqid, to: pair.a.peer, n: 40 });
3668 check(posted === 40, 'B pushed forty targeted messages past A while it was cut (' + posted + ' accepted)');
3669
3670 cut.on = false;
3671
3672 const asked = await until(() => (resets > 0 ? resets : false), 45000);
3673 check(asked, 'A received `gap` and asked for a reset (' + resets + ' resets posted)');
3674
3675 const rebuilt = await until(async () => {
3676 const a = await state(pageA);
3677 const b = await state(pageB);
3678 return a.peer === pair.a.peer && !a.terminal &&
3679 a.peers.includes(pair.b.peer) && b.peers.includes(pair.a.peer) &&
3680 a.signaling[pair.b.peer] === 'stable' && a.connection[pair.b.peer] === 'connected' &&
3681 b.signaling[pair.a.peer] === 'stable' && b.connection[pair.a.peer] === 'connected' ? { a, b } : false;
3682 }, 45000);
3683 check(rebuilt, 'both sides rebuilt the pair at the new generation and connected — ' +
3684 (await diagnose(pageA, 'member A')) + ' ' + (await diagnose(pageB, 'member B')));
3685
3686 await leaveRoom(pageA);
3687 await leaveRoom(pageB);
3688 await ctxA.close();
3689 await ctxB.close();
3690 },
3691
3692 // The rest of §13's gap clause: "including when one side's `pair` was
3693 // cut and it learns the generation from the roster on its own resume".
3694 //
3695 // The reset comes from B, and A is cut across the whole of it, so A's
3696 // `pair` is never delivered. It is not replayed either: a `pair` is
3697 // only replayed when it carries a generation *above* the roster's, and
3698 // the roster carries the current one — so the roster is the only place
3699 // A can learn it (hub/relay.go's own note on that branch). A's
3700 // reconcile path is what has to notice: `ensurePC` with a generation
3701 // above the local one rebuilds.
3702 //
3703 // That A never asked is the assertion that separates this from the gap
3704 // scenario: its own `reset` count stays at zero throughout.
3705 async rosterpair() {
3706 const ctxA = await newContext('member A', { cookie: cookieA });
3707 const ctxB = await newContext('member B', { cookie: cookieB });
3708 const cut = await cuttableStream(ctxA);
3709 let resets = 0;
3710 await ctxA.route('**/signal', async (route) => {
3711 if ((route.request().postData() || '').includes('"type":"reset"')) resets += 1;
3712 await route.continue();
3713 });
3714 const pageA = await openRoom(ctxA);
3715 const pageB = await openRoom(ctxB);
3716 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
3717 if (!pair) {
3718 await ctxA.close();
3719 await ctxB.close();
3720 return;
3721 }
3722
3723 cut.on = true;
3724 check(await biteCut(cut), "A's attaching stream is cut (" + cut.aborted + ' aborted)');
3725
3726 // B resets the pair from its own page, so the request carries B's
3727 // session and peer and nothing on A's side is involved.
3728 const reset = await pageB.evaluate(async ({ room, to }) => {
3729 const res = await fetch('/m/' + room + '/signal', {
3730 method: 'POST',
3731 credentials: 'same-origin',
3732 headers: { 'Content-Type': 'application/json' },
3733 body: JSON.stringify({ peer: window.meet.state().peer, type: 'reset', to }),
3734 });
3735 return res.status;
3736 }, { room: sqid, to: pair.a.peer });
3737 check(reset === 200, 'B reset the pair while A was cut (' + reset + ')');
3738
3739 // B rebuilt on its own `pair`, which it did receive: its connection
3740 // to A leaves `connected` while A knows nothing about it.
3741 const bRebuilt = await until(async () => {
3742 const b = await state(pageB);
3743 return b.connection[pair.a.peer] !== 'connected' || b.signaling[pair.a.peer] !== 'stable' ? b : false;
3744 }, 15000);
3745 check(bRebuilt, 'B rebuilt its side at the new generation — ' + (await diagnose(pageB, 'member B')));
3746
3747 cut.on = false;
3748
3749 const rebuilt = await until(async () => {
3750 const a = await state(pageA);
3751 const b = await state(pageB);
3752 return a.peer === pair.a.peer && !a.terminal &&
3753 a.peers.includes(pair.b.peer) && b.peers.includes(pair.a.peer) &&
3754 a.signaling[pair.b.peer] === 'stable' && a.connection[pair.b.peer] === 'connected' &&
3755 b.signaling[pair.a.peer] === 'stable' && b.connection[pair.a.peer] === 'connected' ? { a, b } : false;
3756 }, 45000);
3757 // Connecting at all is the proof that A converged on B's generation:
3758 // the hub refuses signalling at any other one with 409, so a side
3759 // still at the old generation never gets an offer through.
3760 check(rebuilt, 'A learned the new generation from the roster alone and the pair connected again — ' +
3761 (await diagnose(pageA, 'member A')) + ' ' + (await diagnose(pageB, 'member B')));
3762 check(resets === 0, 'A never asked for a reset of its own (' + resets + ')');
3763
3764 const before = (await state(pageA)).frames[pair.b.peer] || 0;
3765 const rising = await until(async () => ((await state(pageA)).frames[pair.b.peer] || 0) > before, 25000);
3766 check(rising, 'A is decoding video over the rebuilt connection (' + before + ' -> ' +
3767 ((await state(pageA)).frames[pair.b.peer] || 0) + ')');
3768
3769 await leaveRoom(pageA);
3770 await leaveRoom(pageB);
3771 await ctxA.close();
3772 await ctxB.close();
3773 },
3774
3775 // A signal 404 that is about the *target*, not about us (Codex S2-3).
3776 // A's stream is cut, B leaves, and A — which cannot have heard `gone`
3777 // — sends B an offer. The hub answers 404 because B is not there any
3778 // more. That says nothing about A's own participation, and A must
3779 // stay in the call: the old client closed everything, opened a fresh
3780 // stream, met a 409 from its own still-live participation and put up
3781 // the takeover screen for a tab that does not exist.
3782 async staletarget() {
3783 const ctxA = await newContext('member A', { cookie: cookieA });
3784 const ctxB = await newContext('member B', { cookie: cookieB });
3785 const cut = await cuttableStream(ctxA);
3786 // A's own 404s are counted, to prove the scenario actually produced
3787 // the answer it is about rather than passing on a quiet call — and
3788 // so is everything A posts once the roster is known to be stale,
3789 // because "A is still here" was true of the twelve-round reset cycle
3790 // Codex S2-11 found as well.
3791 let notFound = 0;
3792 let posts = 0;
3793 let counting = false;
3794 await ctxA.route('**/signal', async (route) => {
3795 if (counting) posts += 1;
3796 const res = await route.fetch();
3797 if (res.status() === 404) notFound += 1;
3798 await route.fulfill({ response: res });
3799 });
3800 const pageA = await openRoom(ctxA);
3801 const pageB = await openRoom(ctxB);
3802 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
3803 if (!pair) {
3804 await ctxA.close();
3805 await ctxB.close();
3806 return;
3807 }
3808
3809 cut.on = true;
3810 check(await biteCut(cut), "A's attaching stream is cut (" + cut.aborted + ' aborted)');
3811
3812 await leaveRoom(pageB);
3813 counting = true;
3814 // The recovery's own probe is refused 503 to begin with. That says
3815 // nothing about anyone's participation, and acting on it recreated
3816 // the very failure this scenario is about (Codex S2-12).
3817 cut.probeFail = true;
3818
3819 // The Retry control on A's tile for B is restartIce, which fires
3820 // negotiationneeded and puts an offer on the wire for a peer that
3821 // is no longer in the room. Dispatched rather than clicked: the
3822 // scenario is about the answer, not about the control.
3823 await pageA.locator('#tiles figure:not(.calls-tile-self) figcaption button').first().dispatchEvent('click');
3824 const answered = await until(() => (notFound > 0 ? notFound : false), 20000);
3825 check(answered, 'A was answered 404 for a peer that had left (' + notFound + ')');
3826 const refusedProbe = await until(() => (cut.probesFailed > 0 ? cut.probesFailed : false), 20000);
3827 check(refusedProbe, 'a probe was answered 503 while A was diagnosing (' + cut.probesFailed + ')');
3828
3829 // Nothing about either answer is A's own ending, and A is still cut,
3830 // so the whole window is asserted rather than read once.
3831 const broke = await stayed(pageA, 8000, (s) => s.peer === pair.a.peer && !s.terminal);
3832 check(broke === null, 'A stayed in the call with its own peer id after the 404 and the 503 — ' +
3833 (broke ? JSON.stringify(broke) : 'held for eight seconds') + ' — ' + (await diagnose(pageA, 'member A')));
3834 const status = await statusText(pageA);
3835 check(!status.includes('another tab'), 'A was never shown the takeover screen (status: ' + status + ')');
3836
3837 // Now the probe answers, A learns its own participation is fine, and
3838 // whatever it does about the departed peer it does once: the roster
3839 // is what settles a stale target, not a reset cycle with a fresh
3840 // budget each round.
3841 cut.probeFail = false;
3842 await sleep(5000);
3843 check(posts <= 3, 'A posted at most three times while its roster was stale (' + posts + ')');
3844
3845 // What "reconciled" has to mean: not that A's own `pcs` no longer
3846 // has B — `staleTarget` closes that connection locally, while the
3847 // stream is still cut, so that was already true — but that A's
3848 // stream came back and the server told it who is here (Codex
3849 // S2-16). Both are counted from here.
3850 const attachesBefore = cut.attaches;
3851 const rostersBefore = (await state(pageA)).rosters;
3852 cut.on = false;
3853
3854 // And the departure reaches A the way §4.5 says it does: from the
3855 // roster on its own resume, not from the 404.
3856 const reconciled = await until(async () => {
3857 const s = await state(pageA);
3858 return cut.attaches > attachesBefore && s.rosters > rostersBefore &&
3859 s.peer === pair.a.peer && !s.terminal && !s.peers.includes(pair.b.peer) ? s : false;
3860 }, 45000);
3861 check(reconciled, 'A resumed its stream, was sent a fresh roster, and has let the departed peer go — ' +
3862 'attaches ' + attachesBefore + '→' + cut.attaches + ', rosters ' + rostersBefore + '→' +
3863 ((await state(pageA)).rosters) + ' — ' + (await diagnose(pageA, 'member A')));
3864 // Counted across the whole recovery, not only the first five
3865 // seconds of it: a cycle that started late would still be a cycle.
3866 // The round-1 client posted four hundred and fifty times here.
3867 check(posts <= 6, 'the whole recovery cost a handful of requests, not a cycle (' + posts + ')');
3868
3869 await leaveRoom(pageA);
3870 await ctxA.close();
3871 await ctxB.close();
3872 },
3873
3874 // §4.6's thirty seconds again, this time over the *diagnosis* rather
3875 // than the message (Codex S2-15). A signal 404 sends the client to
3876 // probe its own participation; that probe never settles. Two things
3877 // have to hold: the probe is aborted rather than awaited for ever, and
3878 // the clock it is aborted on is the one the message started — the 404
3879 // here is deliberately answered twenty seconds late, so a recovery
3880 // that took a fresh thirty seconds would land at fifty and fail the
3881 // measurement rather than merely being slower.
3882 async probehang() {
3883 const ctxA = await newContext('member A', { cookie: cookieA });
3884 const ctxB = await newContext('member B', { cookie: cookieB });
3885 const pageA = await openRoom(ctxA);
3886 const pageB = await openRoom(ctxB);
3887 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
3888 if (!pair) {
3889 await ctxA.close();
3890 await ctxB.close();
3891 return;
3892 }
3893
3894 // §15.24: A reports who it hears once the pair is up; let that report
3895 // go before everything A posts is held, or it is counted below as a
3896 // second message the recovery sent.
3897 const settled = await until(async () => ((await state(pageA)).hearingList || []).includes(pair.b.peer), 10000);
3898 check(settled, 'A reports that it hears B before the cut');
3899 await sleep(500);
3900
3901 let firstAt = 0;
3902 let signals = 0;
3903 const posted = [];
3904 let hungProbes = 0;
3905 // Everything A posts from here is answered 404, twenty seconds
3906 // after it was sent: "that peer is not here", arriving with a third
3907 // of the message's budget left.
3908 await ctxA.route('**/signal', async (route) => {
3909 if (!firstAt) firstAt = Date.now();
3910 signals += 1;
3911 const m = (route.request().postData() || '').match(/"type":"([a-z-]+)"/);
3912 posted.push(m ? m[1] : '?');
3913 await sleep(20000);
3914 await route.fulfill({ status: 404, contentType: 'application/json', body: '{"error":"not-found"}' });
3915 });
3916 // And the probe that follows never answers at all. Only the probe
3917 // forms: A's own stream stays up, so nothing else is being tested.
3918 await ctxA.route('**/stream*', async (route) => {
3919 if (!route.request().url().includes('probe=1')) {
3920 await route.continue();
3921 return;
3922 }
3923 hungProbes += 1;
3924 await new Promise(() => {});
3925 });
3926
3927 await pageA.locator('#tiles figure:not(.calls-tile-self) figcaption button').first().dispatchEvent('click');
3928
3929 const dropped = await until(async () => {
3930 const s = await state(pageA);
3931 return !s.peers.includes(pair.b.peer) ? s : false;
3932 }, 70000);
3933 check(dropped, 'A let the target go rather than waiting on a probe that never answers (' + signals +
3934 ' signals, ' + hungProbes + ' hung probes) — ' + (await diagnose(pageA, 'member A')));
3935 check(hungProbes > 0, 'the scenario actually hung the probe it meant to (' + hungProbes + ')');
3936 const took = Date.now() - firstAt;
3937 check(took >= 25000 && took <= 35000,
3938 'and it did so on the message’s own clock, not a fresh one (' + took + ' ms; a restarted clock lands near 50000)');
3939
3940 // Dropping the target on time is not the claim. The claim is that
3941 // the recovery ended there — and a client that let the queue restart
3942 // would look identical at this instant while a reset ran on a fresh
3943 // thirty seconds behind it (Codex S2-17). So: the one message this
3944 // scenario sent is the only thing A ever posted, and it is still the
3945 // only thing ten seconds later.
3946 const atDrop = signals;
3947 check(atDrop === 1, 'the offer was the only thing A posted (' + atDrop + ': ' + posted.join(', ') + ')');
3948 check(dropped && dropped.stale && dropped.stale.includes(pair.b.peer),
3949 'and A holds the target marked stale, waiting for a roster rather than asking again (' +
3950 JSON.stringify(dropped && dropped.stale) + ')');
3951 await sleep(10000);
3952 check(signals === atDrop, 'nothing followed it, ten seconds on (' + atDrop + ' → ' + signals + ')');
3953
3954 const after = await state(pageA);
3955 check(after.peer === pair.a.peer && !after.terminal,
3956 'A is still in the call with its own peer id — ' + (await diagnose(pageA, 'member A')));
3957
3958 await ctxA.close();
3959 await ctxB.close();
3960 },
3961
3962 // §4.6 and Codex S2-5: a `reset` refused with 429 used to be dropped
3963 // on the floor, and after an outbound deadline that reset is the only
3964 // thing left that can rebuild the pair — the queue has been thrown
3965 // away and the connection closed, the hub's generation has not moved,
3966 // and no `pair` is coming. So this scenario walks A into exactly that
3967 // corner: everything it posts is refused until the deadline fires,
3968 // and then the reset the deadline asks for is refused once more.
3969 async resetretry() {
3970 const ctxA = await newContext('member A', { cookie: cookieA });
3971 const ctxB = await newContext('member B', { cookie: cookieB });
3972 let failing = false;
3973 let resets = 0;
3974 await ctxA.route('**/signal', async (route) => {
3975 const isReset = (route.request().postData() || '').includes('"type":"reset"');
3976 if (isReset) {
3977 resets += 1;
3978 // The deadline has done its work; the room is reachable again
3979 // and only the reset itself is in question.
3980 failing = false;
3981 if (resets === 1) {
3982 await route.fulfill({
3983 status: 429,
3984 headers: { 'retry-after': '1' },
3985 contentType: 'application/json',
3986 body: '{"error":"rate"}',
3987 });
3988 return;
3989 }
3990 await route.continue();
3991 return;
3992 }
3993 if (failing) {
3994 await route.fulfill({ status: 503, contentType: 'application/json', body: '{"error":"full"}' });
3995 return;
3996 }
3997 await route.continue();
3998 });
3999 const pageA = await openRoom(ctxA);
4000 const pageB = await openRoom(ctxB);
4001 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
4002 if (!pair) {
4003 await ctxA.close();
4004 await ctxB.close();
4005 return;
4006 }
4007 check(resets === 0, 'nothing had reset this pair before this (' + resets + ')');
4008
4009 failing = true;
4010 await pageA.locator('#tiles figure:not(.calls-tile-self) figcaption button').first().dispatchEvent('click');
4011
4012 const asked = await until(() => (resets > 0 ? resets : false), 60000);
4013 check(asked, 'A hit its outbound deadline and asked for a reset (' + resets + ')');
4014 const askedTwice = await until(() => (resets > 1 ? resets : false), 30000);
4015 check(askedTwice, 'A asked again after the 429 rather than losing the reset (' + resets + ' asks)');
4016
4017 const rebuilt = await until(async () => {
4018 const a = await state(pageA);
4019 const b = await state(pageB);
4020 return a.peer === pair.a.peer && !a.terminal &&
4021 a.signaling[pair.b.peer] === 'stable' && a.connection[pair.b.peer] === 'connected' &&
4022 b.signaling[pair.a.peer] === 'stable' && b.connection[pair.a.peer] === 'connected' ? { a, b } : false;
4023 }, 45000);
4024 check(rebuilt, 'the pair came back, on a reset that was refused the first time — ' +
4025 (await diagnose(pageA, 'member A')) + ' ' + (await diagnose(pageB, 'member B')));
4026
4027 const before = (await state(pageA)).frames[pair.b.peer] || 0;
4028 const rising = await until(async () => ((await state(pageA)).frames[pair.b.peer] || 0) > before, 25000);
4029 check(rising, 'A is decoding video over the rebuilt connection (' + before + ' -> ' +
4030 ((await state(pageA)).frames[pair.b.peer] || 0) + ')');
4031
4032 await leaveRoom(pageA);
4033 await leaveRoom(pageB);
4034 await ctxA.close();
4035 await ctxB.close();
4036 },
4037
4038 // §4.6's thirty seconds, under continuous 429s (Codex S2-6). Every
4039 // retry used to skip the deadline check, so a rate-limited target was
4040 // retried for ever and the connection to it was never rebuilt. What
4041 // is asserted is the verdict — A closes the connection to B — not how
4042 // long it took to reach it.
4043 async senddeadline() {
4044 const ctxA = await newContext('member A', { cookie: cookieA });
4045 const ctxB = await newContext('member B', { cookie: cookieB });
4046 const pageA = await openRoom(ctxA);
4047 const pageB = await openRoom(ctxB);
4048 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
4049 if (!pair) {
4050 await ctxA.close();
4051 await ctxB.close();
4052 return;
4053 }
4054
4055 // Only from here: the join and the whole negotiation above went
4056 // through untouched, so what is being timed is one message.
4057 let limited = 0;
4058 let firstAt = 0;
4059 await ctxA.route('**/signal', async (route) => {
4060 if (!firstAt) firstAt = Date.now();
4061 limited += 1;
4062 await route.fulfill({
4063 status: 429,
4064 headers: { 'retry-after': '1' },
4065 contentType: 'application/json',
4066 body: '{"error":"rate"}',
4067 });
4068 });
4069 await pageA.locator('#tiles figure:not(.calls-tile-self) figcaption button').first().dispatchEvent('click');
4070
4071 const gaveUp = await until(async () => {
4072 const s = await state(pageA);
4073 return !s.peers.includes(pair.b.peer) ? s : false;
4074 }, 60000);
4075 check(gaveUp, 'A gave up on an endlessly rate-limited target (' + limited +
4076 ' refusals) — ' + (await diagnose(pageA, 'member A')));
4077 // §4.6's number, not merely "eventually": measured from the first
4078 // message this route saw, which is the one whose deadline is
4079 // running. Five seconds of tolerance for the polling and the
4080 // scheduler at either end (Codex S2-13).
4081 const took = Date.now() - firstAt;
4082 check(took >= 25000 && took <= 35000,
4083 'and it gave up on §4.6’s thirty seconds, not on its own clock (' + took + ' ms)');
4084 check(limited > 1, 'and it did retry rather than giving up on the first refusal (' + limited + ')');
4085 check(!(await state(pageA)).terminal, 'giving up on one target is not the end of the call');
4086
4087 await ctxA.close();
4088 await ctxB.close();
4089 },
4090
4091 // The other half of the same deadline: a request that never settles at
4092 // all. Without an abort on the fetch the pump simply waits for ever,
4093 // and the deadline check is never even reached (Codex S2-6).
4094 async sendhang() {
4095 const ctxA = await newContext('member A', { cookie: cookieA });
4096 const ctxB = await newContext('member B', { cookie: cookieB });
4097 const pageA = await openRoom(ctxA);
4098 const pageB = await openRoom(ctxB);
4099 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
4100 if (!pair) {
4101 await ctxA.close();
4102 await ctxB.close();
4103 return;
4104 }
4105
4106 let hung = 0;
4107 let firstAt = 0;
4108 // The abort itself, watched from outside the page: a fetch the page
4109 // gives up on fails, and that is the only direct evidence that the
4110 // AbortController fired rather than the request having settled some
4111 // other way (Codex S2-13).
4112 let cancelled = 0;
4113 pageA.on('requestfailed', (req) => {
4114 if (req.url().includes('/signal')) cancelled += 1;
4115 });
4116 await ctxA.route('**/signal', async () => {
4117 if (!firstAt) firstAt = Date.now();
4118 hung += 1;
4119 // Never fulfilled, never continued, never aborted: the request
4120 // stays pending until the page itself gives up on it.
4121 await new Promise(() => {});
4122 });
4123 await pageA.locator('#tiles figure:not(.calls-tile-self) figcaption button').first().dispatchEvent('click');
4124
4125 const gaveUp = await until(async () => {
4126 const s = await state(pageA);
4127 return !s.peers.includes(pair.b.peer) ? s : false;
4128 }, 60000);
4129 check(gaveUp, 'A gave up on a target whose requests never settle (' + hung +
4130 ' hung requests) — ' + (await diagnose(pageA, 'member A')));
4131 check(hung > 0, 'the scenario actually hung the request it meant to (' + hung + ')');
4132 check(cancelled > 0, 'and the page aborted it rather than waiting for it (' + cancelled + ' cancelled)');
4133 const took = Date.now() - firstAt;
4134 check(took >= 25000 && took <= 35000,
4135 'the abort came at §4.6’s thirty seconds (' + took + ' ms)');
4136 check(!(await state(pageA)).terminal, 'a hanging endpoint is not the end of the call either');
4137
4138 await ctxA.close();
4139 await ctxB.close();
4140 },
4141
4142 // The process goes away and comes back. Both browsers see the stream
4143 // die, find a boot they do not know, and rejoin (§4.5).
4144 async restart() {
4145 const ctxA = await newContext('member A', { cookie: cookieA });
4146 const ctxB = await newContext('member B', { cookie: cookieB });
4147 const pageA = await openRoom(ctxA);
4148 const pageB = await openRoom(ctxB);
4149
4150 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
4151 if (!pair) {
4152 await ctxA.close();
4153 await ctxB.close();
4154 return;
4155 }
4156 const bootBefore = (await state(pageA)).boot;
4157 check(!!bootBefore, 'the browser knows which process it is talking to (boot ' + bootBefore + ')');
4158
4159 // §13 asks that both contexts have closed their old connections
4160 // *before* rejoining, and that is a moment rather than a state: the
4161 // gap between closeAllPCs and the roster of the new participation is
4162 // one round trip wide. A poll from out here would step over it, so
4163 // each page watches itself, five milliseconds at a time, from before
4164 // the restart until after the new peer ids arrive.
4165 for (const page of [pageA, pageB]) {
4166 await page.evaluate(() => {
4167 window.__sawEmpty = false;
4168 window.__samples = 0;
4169 window.__watch = setInterval(() => {
4170 window.__samples += 1;
4171 if (window.meet.state().peers.length === 0) window.__sawEmpty = true;
4172 }, 5);
4173 });
4174 }
4175
4176 const answer = await control('restart');
4177 check(answer === 'ok', 'the app restarted on the same port (' + answer + ')');
4178
4179 const back = await until(async () => {
4180 const a = await state(pageA);
4181 const b = await state(pageB);
4182 return a.peer && b.peer && a.peer !== pair.a.peer && b.peer !== pair.b.peer ? { a, b } : false;
4183 }, 60000);
4184 check(back, 'both browsers took a new participation after the restart — ' +
4185 (await diagnose(pageA, 'member A')) + ' ' + (await diagnose(pageB, 'member B')));
4186
4187 // §13: "a different-boot 404 after restart" (W-3). New peer ids are
4188 // not that: a hub whose boot came from something stable would issue
4189 // new ids too, and `recoverFromNotFound` would lose the one thing
4190 // that tells a restart from a lapsed resume window (§4.5).
4191 const bootAfter = back ? back.a.boot : (await state(pageA)).boot;
4192 check(!!bootAfter && bootAfter !== bootBefore,
4193 'the restarted process announced a different boot id (' + bootBefore + ' then ' + bootAfter + ')');
4194
4195 for (const [page, label] of [[pageA, 'member A'], [pageB, 'member B']]) {
4196 const watched = await page.evaluate(() => {
4197 clearInterval(window.__watch);
4198 return { sawEmpty: window.__sawEmpty, samples: window.__samples };
4199 });
4200 check(watched.sawEmpty, label + ' held no peer connections at all between the old participation and the new one (' +
4201 watched.samples + ' samples)');
4202 }
4203
4204 if (back) await assertPair(pageA, pageB, 'member A', 'member B', 40000);
4205
4206 await leaveRoom(pageA);
4207 await leaveRoom(pageB);
4208 await ctxA.close();
4209 await ctxB.close();
4210 },
4211
4212 // §4.2: one peer per session per room, and nothing displaces a tab
4213 // automatically. The second tab is refused and offers the confirm
4214 // page; confirming it is what ends the first.
4215 async takeover() {
4216 const ctxA = await newContext('member A', { cookie: cookieA });
4217 const first = await openRoom(ctxA);
4218 check(await until(async () => (await state(first)).peer, 20000),
4219 'the first tab is in the room — ' + (await diagnose(first, 'member A')));
4220
4221 const second = await openRoom(ctxA);
4222 check(await waitStatus(second, 'This meeting is open in another tab.', 20000),
4223 'the second tab is told the meeting is open elsewhere — status: ' + (await statusText(second)));
4224 const secondState = await state(second);
4225 check(secondState.peer === null,
4226 'the second tab took no participation (' + secondState.peer + ')');
4227
4228 // Not a single read: "was not displaced" is a claim about a window
4229 // of time, and the displacement it is denying would arrive over a
4230 // stream a moment later if it were going to arrive at all.
4231 await sleep(1000);
4232 const displaced = await stayed(first, 3000, (s) => s.peer !== null && !s.terminal);
4233 check(displaced === null,
4234 'the first tab was not displaced by the second opening (' + JSON.stringify(displaced) + ')');
4235
4236 // Located by the href, not by "the link in #status": a terminal
4237 // screen may carry more than one way out, and this scenario is about
4238 // the one that takes the call over.
4239 const takeover = second.locator('#status a[href$="/takeover"]');
4240 const takeoverHref = await takeover.getAttribute('href');
4241 check(takeoverHref === '/m/' + sqid + '/takeover',
4242 'the second tab offers the takeover page (' + takeoverHref + ')');
4243
4244 await Promise.all([
4245 second.waitForURL('**/m/' + sqid + '/takeover', { timeout: 15000 }),
4246 takeover.click(),
4247 ]);
4248 await Promise.all([
4249 second.waitForURL(origin + '/m/' + sqid, { timeout: 15000 }),
4250 second.click('form[rst-form-actions] button[type="submit"]'),
4251 ]);
4252 check(second.url() === origin + '/m/' + sqid,
4253 'the confirmed takeover lands the second tab in the room (at ' + where(second) + ')');
4254
4255 check(await waitStatus(first, 'This meeting is open in another tab.', 20000),
4256 'the first tab is told it lost the call — status: ' + (await statusText(first)));
4257 const lost = await state(first);
4258 check(lost.terminal === 'replaced' && lost.admitted === false,
4259 'the displaced tab is terminal on `replaced` and no longer admitted (' + JSON.stringify(lost) + ')');
4260
4261 // Terminal means terminal: nothing reconnects on its own.
4262 const reconnected = await stayed(first, 5000, (s) => s.peers.length === 0 && s.terminal === 'replaced');
4263 check(reconnected === null,
4264 'the displaced tab did not reconnect (' + JSON.stringify(reconnected) + ')');
4265
4266 await ctxA.close();
4267 },
4268
4269 // A GET from somewhere else on the web ends nothing: the takeover is
4270 // a POST behind its own confirm page (§4.2), and a top-level
4271 // navigation does not even reach the stream (ruling 15).
4272 async crosssite() {
4273 const ctxA = await newContext('member A', { cookie: cookieA });
4274 const page = await openRoom(ctxA);
4275 check(await until(async () => (await state(page)).peer, 20000),
4276 'the call is up before the cross-site visit — ' + (await diagnose(page, 'member A')));
4277 const before = await state(page);
4278
4279 await ctxA.route('http://elsewhere.test/**', (route) =>
4280 route.fulfill({
4281 status: 200,
4282 contentType: 'text/html',
4283 body: '<!doctype html><a id="go" href="' + origin + '/m/' + sqid + '/takeover">go</a>' +
4284 '<a id="stream" href="' + origin + '/m/' + sqid + '/stream">stream</a>',
4285 }));
4286
4287 const other = await ctxA.newPage();
4288 await other.goto('http://elsewhere.test/', { waitUntil: 'domcontentloaded' });
4289 check(new URL(other.url()).origin !== origin, 'the other page really is another origin (' + other.url() + ')');
4290
4291 await Promise.all([
4292 other.waitForURL('**/m/' + sqid + '/takeover', { timeout: 15000 }),
4293 other.click('#go'),
4294 ]);
4295 check(other.url() === origin + '/m/' + sqid + '/takeover',
4296 'the cross-site GET reached the takeover URL (at ' + where(other) + ')');
4297
4298 // And the navigation to the stream itself, which must not take this
4299 // session's peer slot either.
4300 await other.goto('http://elsewhere.test/', { waitUntil: 'domcontentloaded' });
4301 await Promise.all([
4302 other.waitForURL('**/m/' + sqid + '/stream', { timeout: 15000 }),
4303 other.click('#stream'),
4304 ]);
4305 check(other.url() === origin + '/m/' + sqid + '/stream',
4306 'the cross-site GET reached the stream URL (at ' + where(other) + ')');
4307 // Ruling 15, seen from a browser: a top-level navigation asks for
4308 // HTML, so it is refused before anything is looked up and cannot
4309 // occupy this session's one peer slot for the resume window. The
4310 // exact bytes, because the whole point of that refusal is that it
4311 // is identical for every meeting and every person.
4312 const refusal = (await other.locator('body').innerText()).trim();
4313 check(refusal === '{"error":"accept"}',
4314 'the navigation was refused with exactly the accept error (' + refusal + ')');
4315
4316 const disturbed = await stayed(page, 3000,
4317 (s) => s.peer === before.peer && !s.terminal);
4318 check(disturbed === null,
4319 'the call kept its participation through the cross-site GETs (' + JSON.stringify(disturbed) + ')');
4320 check(!(await statusText(page)).includes('This meeting is open in another tab.'),
4321 'the call was not displaced by a GET — status: ' + (await statusText(page)));
4322
4323 await leaveRoom(page);
4324 await ctxA.close();
4325 },
4326
4327 // A refusal the probe cannot name: the room is full, which the
4328 // peerless probe answers 204 to because this session holds no peer
4329 // here. Without a ceiling the client would retry that for ever behind
4330 // "Connecting to the call."; with one it stops and says something
4331 // true, with both ways out.
4332 async stall() {
4333 const ctx = await newContext('member A', { cookie: cookieA });
4334 await ctx.route('**/stream*', async (route) => {
4335 if (route.request().url().includes('probe=1')) {
4336 await route.continue();
4337 return;
4338 }
4339 await route.fulfill({ status: 503, contentType: 'application/json', body: '{"error":"full"}' });
4340 });
4341 const page = await openRoom(ctx);
4342
4343 // Deliberately generous: the ceiling is a count of rounds and a
4344 // stretch of time, and the backoff between rounds is the client's
4345 // own. What is asserted is where it ends up, never how long it took.
4346 const ended = await until(async () => ((await state(page)).terminal === 'unreachable' ? true : false), 130000);
4347 check(ended, 'a refusal the probe cannot name stops rather than retrying for ever — ' +
4348 (await diagnose(page, 'member A')));
4349 check(await waitStatus(page, 'Calls cannot reach the call.', 10000),
4350 'the stalled room says something true — status: ' + (await statusText(page)));
4351 const hrefs = await page.locator('#status a').evaluateAll((as) => as.map((a) => a.getAttribute('href')));
4352 check(hrefs.includes('/m/' + sqid) && hrefs.includes('/m/' + sqid + '/join'),
4353 'the stalled room offers both ways out (' + JSON.stringify(hrefs) + ')');
4354
4355 await ctx.close();
4356 },
4357
4358 // An expired session answers the signal endpoint 403. That is not
4359 // transient and never becomes true by waiting, so the room says so and
4360 // offers the one thing that fixes it.
4361 async signedout() {
4362 const ctx = await newContext('member A', { cookie: cookieA });
4363 await ctx.route('**/signal', (route) =>
4364 route.fulfill({ status: 403, contentType: 'text/plain', body: 'forbidden' }));
4365 const page = await openRoom(ctx);
4366
4367 const out = await until(async () => ((await state(page)).terminal === 'signedout' ? true : false), 30000);
4368 check(out, 'a 403 from the signal endpoint ends the call as signed out — ' + (await diagnose(page, 'member A')));
4369 check(await waitStatus(page, 'You have been signed out.', 10000),
4370 'the signed-out room says so — status: ' + (await statusText(page)));
4371 const href = await page.locator('#status a').first().getAttribute('href');
4372 // Back in, and back to this meeting's holding screen (spec §9.4).
4373 check(href === '/signin?return_to=' + encodeURIComponent('/m/' + sqid + '/join'), 'the signed-out room offers the way back in, to this meeting (' + href + ')');
4374
4375 await ctx.close();
4376
4377 // The other half of the same rule, and the one the client explicitly
4378 // implements the compatibility for: a session that has expired can
4379 // answer a POST with a redirect to sign-in instead of a 403. fetch
4380 // follows it, so what comes back is a 200 carrying the sign-in page
4381 // — which read as a status alone is success, and was being taken as
4382 // success (Codex S2-8). Member B, because A's participation from the
4383 // phase above is still inside its resume window.
4384 const ctxB = await newContext('member B', { cookie: cookieB });
4385 await ctxB.route('**/signal', (route) =>
4386 route.fulfill({ status: 303, headers: { location: origin + '/signin' } }));
4387 const pageB = await openRoom(ctxB);
4388
4389 const redirected = await until(async () => ((await state(pageB)).terminal === 'signedout' ? true : false), 30000);
4390 check(redirected, 'a POST redirected to sign-in ends the call as signed out, not as a success — ' +
4391 (await diagnose(pageB, 'member B')));
4392 check(await waitStatus(pageB, 'You have been signed out.', 10000),
4393 'the redirected room says the same thing — status: ' + (await statusText(pageB)));
4394
4395 await ctxB.close();
4396 },
4397
4398 // A browser with no WebRTC at all. The room needs another peer before
4399 // it tries to build a connection, so B is in the call first.
4400 async nowebrtc() {
4401 const ctxB = await newContext('member B', { cookie: cookieB });
4402 const pageB = await openRoom(ctxB);
4403 check(await until(async () => (await state(pageB)).admitted, 20000),
4404 'the other member is in the room — ' + (await diagnose(pageB, 'member B')));
4405
4406 const ctxA = await newContext('member A', { cookie: cookieA });
4407 await ctxA.addInitScript(() => {
4408 delete window.RTCPeerConnection;
4409 });
4410 const pageA = await openRoom(ctxA);
4411
4412 const ended = await until(async () => ((await state(pageA)).terminal === 'nowebrtc' ? true : false), 30000);
4413 check(ended, 'a browser that cannot make calls is told so rather than sitting on an empty grid — ' +
4414 (await diagnose(pageA, 'member A')));
4415 check(await waitStatus(pageA, 'This browser cannot make calls.', 10000),
4416 'the sentence says what is wrong — status: ' + (await statusText(pageA)));
4417 const href = await pageA.locator('#status a').first().getAttribute('href');
4418 check(href === '/m/' + sqid + '/join', 'even this is not a dead end (' + href + ')');
4419
4420 await leaveRoom(pageB);
4421 await ctxA.close();
4422 await ctxB.close();
4423 },
4424
4425 // An autoplay policy that refuses a remote video: the tile offers the
4426 // gesture rather than leaving a still frame nobody can explain. The
4427 // refusal is applied to unmuted elements only, which is every remote
4428 // tile and no local one, and it lasts one call so the control can be
4429 // seen to work.
4430 async playfallback() {
4431 const ctxA = await newContext('member A', { cookie: cookieA });
4432 await ctxA.addInitScript(refuseFirstPlay);
4433 const ctxB = await newContext('member B', { cookie: cookieB });
4434 const pageA = await openRoom(ctxA);
4435 const pageB = await openRoom(ctxB);
4436
4437 const a = await waitConnected(pageA);
4438 check(a, 'A is in the call with a peer — ' + (await diagnose(pageA, 'member A')));
4439 const b = await waitConnected(pageB);
4440 check(b, 'B is in the call with a peer — ' + (await diagnose(pageB, 'member B')));
4441
4442 // The two buttons in a remote tile's caption are Retry and Play, in
4443 // that order.
4444 const play = pageA.locator('#tiles figure:not(.calls-tile-self) figcaption button').nth(1);
4445 const offered = await until(async () => await play.isVisible(), 25000);
4446 check(offered, 'the refused remote video offers a Play control — ' + (await diagnose(pageA, 'member A')));
4447 if (offered) {
4448 // What the control has to actually do. The click handler hides the
4449 // button before it attempts playback, so "the button went" was
4450 // satisfied by a handler that never called play() at all (Codex
4451 // S2-9): the assertion is frames arriving and the element's own
4452 // clock moving, both of which stop while it is paused.
4453 const before = (await state(pageA)).frames[b.peer] || 0;
4454 const still = await remoteTile(pageA);
4455 check(still && still.paused, 'the refused video is paused before the gesture (' + JSON.stringify(still) + ')');
4456
4457 await play.click();
4458 const taken = await until(async () => !(await play.isVisible()), 20000);
4459 check(taken, 'the gesture starts the video and the control goes — ' + (await diagnose(pageA, 'member A')));
4460
4461 const advancing = await until(async () => {
4462 const s = await state(pageA);
4463 const t = await remoteTile(pageA);
4464 return (s.frames[b.peer] || 0) > before && t && !t.paused && t.time > (still ? still.time : 0)
4465 ? { frames: s.frames[b.peer], tile: t }
4466 : false;
4467 }, 25000);
4468 check(advancing, 'the gesture starts the frames as well as hiding the control (from ' + before +
4469 ' frames; ' + JSON.stringify(advancing || (await remoteTile(pageA))) + ') — ' +
4470 (await diagnose(pageA, 'member A')));
4471 }
4472
4473 await leaveRoom(pageA);
4474 await leaveRoom(pageB);
4475 await ctxA.close();
4476 await ctxB.close();
4477 },
4478
4479 // Spec §15.20, the autoplay half, in a Chromium launched under its
4480 // real desktop policy rather than the gate's permissive one, and never
4481 // given a gesture. First what the incident of 2026-09-25 settled:
4482 // Chromium plays a MediaStream without a gesture whatever its policy,
4483 // so the call just plays and no banner appears — if that ever changes,
4484 // this is where it shows. Then a refusal, forced the way playfallback
4485 // forces one: the banner, the report reaching the other side as a
4486 // notice on this tab's tile, and a click anywhere putting it all back.
4487 async autoplay() {
4488 const strict = await chromium.launch({
4489 args: LAUNCH_ARGS.filter((a) => !a.startsWith('--autoplay-policy=')).concat(
4490 '--autoplay-policy=document-user-activation-required'),
4491 });
4492 try {
4493 const ctxA = await newContext('member A', { cookie: cookieA, via: strict });
4494 const ctxB = await newContext('member B', { cookie: cookieB, via: strict });
4495 // No evaluate may touch A's page until its remote tile has played:
4496 // Playwright runs page.evaluate as a user gesture, so the gate's
4497 // own polling would be the activation this is proving is not
4498 // needed. So A's page reports for itself, through the console, the
4499 // moment an unmuted element starts playing and whether it had been
4500 // activated by then — and A is opened with a bare goto, not
4501 // openRoom, whose waitForFunction is an evaluate too.
4502 await ctxA.addInitScript(() => {
4503 document.addEventListener('playing', (e) => {
4504 const el = e.target;
4505 if (!el || el.muted || el.dataset.reported) return;
4506 el.dataset.reported = '1';
4507 const active = navigator.userActivation ? navigator.userActivation.hasBeenActive : 'unknown';
4508 console.log('calls-autoplay: playing active=' + active);
4509 }, true);
4510 });
4511 let pageA = await ctxA.newPage();
4512 const firstPlay = new Promise((resolve) => {
4513 pageA.on('console', (m) => {
4514 if (m.text().startsWith('calls-autoplay:')) resolve(m.text());
4515 });
4516 });
4517 await pageA.goto(origin + '/m/' + sqid, { waitUntil: 'domcontentloaded' });
4518 const pageB = await openRoom(ctxB);
4519 const played = await Promise.race([firstPlay, sleep(30000).then(() => null)]);
4520 check(played, 'with no gesture, A’s remote tile starts playing under the real policy (' + played + ')');
4521 check(played === 'calls-autoplay: playing active=false',
4522 'and A’s page had really had no gesture when it did (' + played + ')');
4523 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
4524 if (!pair) return;
4525 const nameA = await selfName(pageA);
4526
4527 const plays = await until(async () => {
4528 const t = await remoteTile(pageA);
4529 return t && t.hasStream && !t.paused && t.time > 0.5 ? t : false;
4530 }, 15000);
4531 check(plays, 'and it goes on playing (' + JSON.stringify(plays || (await remoteTile(pageA))) + ')');
4532 const calm = await stayed(pageA, 3000, (s) => !s.soundBanner && !s.audioBlocked);
4533 check(!calm, 'no banner and nothing blocked (' + JSON.stringify(calm && { banner: calm.soundBanner, blocked: calm.audioBlocked }) + ')');
4534 check(!(await pageA.locator('#sound-banner').isVisible()), 'the banner is not on the page');
4535
4536 // Now a browser that refuses. A comes back in a fresh page whose
4537 // first unmuted play() is refused.
4538 await leaveRoom(pageA);
4539 await pageA.close();
4540 await ctxA.addInitScript(refuseFirstPlay);
4541 pageA = await openRoom(ctxA);
4542 const again = await waitConnected(pageA);
4543 check(again, 'A is back in the call — ' + (await diagnose(pageA, 'member A')));
4544 if (!again) return;
4545 const a = again.peer;
4546 const b = pair.b.peer;
4547
4548 const banner = await until(async () => {
4549 const s = await state(pageA);
4550 return s.soundBanner && s.audioBlocked ? s : false;
4551 }, 20000);
4552 check(banner, 'the refusal puts the banner up and marks A blocked — ' + (await diagnose(pageA, 'member A')));
4553 const bannerText = ((await pageA.locator('#sound-banner').textContent()) || '').trim();
4554 check(await pageA.locator('#sound-banner').isVisible() && bannerText.startsWith('Click anywhere to turn on sound'),
4555 'the banner says what to do, in words (' + bannerText + ')');
4556 check(await pageA.locator('#sound-banner button').isVisible(), 'with a button a keyboard can reach');
4557 check(await pageA.evaluate(() => !!document.querySelector('.calls-window #notices > #sound-banner')),
4558 'in the call window\'s notices, which stay whatever the controls do');
4559 const winOut = process.env.CALLS_WINDOW_SNAPSHOTS;
4560 if (winOut) {
4561 mkdirSync(winOut, { recursive: true });
4562 await pageA.screenshot({ path: winOut + '/web-autoplay-banner.png' });
4563 }
4564 const refusedTile = await until(async () => {
4565 const s = await state(pageA);
4566 return s.hearing[b] && s.hearing[b].shown === 'blocked' ? s.hearing[b] : false;
4567 }, 10000);
4568 check(refusedTile && refusedTile.text === 'Sound blocked — click to hear',
4569 'B’s tile on A says the sound is blocked (' + JSON.stringify(refusedTile) + ')');
4570 const still = await remoteTile(pageA);
4571 check(still && still.paused, 'and the refused video is paused (' + JSON.stringify(still) + ')');
4572
4573 const notice = await until(async () => {
4574 const s = await state(pageB);
4575 const h = s.hearing[a];
4576 return h && /blocking sound/.test(h.notice) && s.remoteMedia[a] && s.remoteMedia[a].audioBlocked ? h : false;
4577 }, 15000);
4578 check(notice, 'B hears about it: A’s tile on B carries the notice (' +
4579 JSON.stringify(notice || ((await state(pageB)).hearing || {})[a]) + ')');
4580 check(notice && notice.notice === nameA + '’s browser is blocking sound — they need to click to hear you.',
4581 'naming A (' + (notice && notice.notice) + ')');
4582 // §15.24: A is new to B and cannot hear B yet, but its own report
4583 // is more specific than "connecting", and replaces it.
4584 const first = (await state(pageB)).connecting[a];
4585 check(notice && notice.noticeKind === 'report' && first && first.joining,
4586 'in place of "' + nameA + ' is connecting and can’t hear you yet" (' + JSON.stringify({ notice, first }) + ')');
4587
4588 // A click on nothing in particular: the page's own heading.
4589 const heading = await pageA.locator('h1').first().boundingBox();
4590 await pageA.mouse.click(heading.x + 4, heading.y + heading.height / 2);
4591 const resumed = await until(async () => {
4592 const s = await state(pageA);
4593 const t = await remoteTile(pageA);
4594 return !s.soundBanner && !s.audioBlocked && t && !t.paused ? { t, banner: s.soundBanner } : false;
4595 }, 15000);
4596 check(resumed, 'a click anywhere plays the tile and takes the banner down — ' + (await diagnose(pageA, 'member A')));
4597 if (resumed) {
4598 const moving = await until(async () => {
4599 const t = await remoteTile(pageA);
4600 return t && !t.paused && t.time > resumed.t.time ? t : false;
4601 }, 10000);
4602 check(moving, 'and the video is really playing (' + JSON.stringify(moving || (await remoteTile(pageA))) + ')');
4603 }
4604 const cleared = await until(async () => {
4605 const s = await state(pageB);
4606 return s.hearing[a] && s.hearing[a].notice === '' && !(s.remoteMedia[a] || {}).audioBlocked ? s.hearing[a] : false;
4607 }, 15000);
4608 check(cleared, 'and B’s notice clears (' + JSON.stringify(cleared || ((await state(pageB)).hearing || {})[a]) + ')');
4609
4610 check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
4611 check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
4612 await leaveRoom(pageA);
4613 await leaveRoom(pageB);
4614 await ctxA.close();
4615 await ctxB.close();
4616 } finally {
4617 await strict.close();
4618 }
4619 },
4620
4621 // Spec §15.24 (2026-10-01): "<name> is connecting and can't hear you
4622 // yet". A fast join says nothing; a join whose connection is held back
4623 // says it on the joiner's tile, with dots a screen reader never hears,
4624 // and clears with "… can hear you now" the moment the joiner's own
4625 // report lists us in `hearing`; and a joiner that does not send
4626 // `hearing` (a client from before it, played by stripping the key off
4627 // its reports) is let go on its first report once the connection is up.
4628 // CALLS_CONNECTING_SNAPSHOTS=<dir> keeps pictures of the notice.
4629 async connecting() {
4630 const out = process.env.CALLS_CONNECTING_SNAPSHOTS;
4631 if (out) mkdirSync(out, { recursive: true });
4632 const ctxA = await newContext('member A', { cookie: cookieA });
4633 const ctxB = await newContext('member B', { cookie: cookieB });
4634 const pageA = await openRoom(ctxA);
4635 const admitted = await until(async () => ((await state(pageA)).admitted ? true : false), 20000);
4636 check(admitted, 'A is in the call on its own — ' + (await diagnose(pageA, 'member A')));
4637 const a = (await state(pageA)).peer;
4638 // A watches itself, ten times a second, from inside the page: whether
4639 // the words ever showed for anyone, when each person appeared, and
4640 // when their first moments ended; and everything its live region said.
4641 const watchA = () => pageA.evaluate(() => {
4642 window.__joins = {};
4643 window.__said = [];
4644 const live = document.getElementById('media-live');
4645 if (live) new MutationObserver(() => window.__said.push(live.textContent)).observe(live, { childList: true, characterData: true, subtree: true });
4646 setInterval(() => {
4647 const s = window.meet.state();
4648 for (const [id, c] of Object.entries(s.connecting || {})) {
4649 const j = window.__joins[id] || (window.__joins[id] = { from: Date.now(), done: 0, shown: false });
4650 if (c.shown) j.shown = true;
4651 if (!c.joining && !j.done) j.done = Date.now();
4652 }
4653 }, 100);
4654 });
4655 await watchA();
4656 const joins = () => pageA.evaluate(() => window.__joins);
4657 const said = () => pageA.evaluate(() => window.__said);
4658
4659 // 1. A fast join says nothing.
4660 let pageB = await openRoom(ctxB);
4661 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
4662 if (!pair) return;
4663 let b = pair.b.peer;
4664 const nameB = await selfName(pageB);
4665 const heardA = await until(async () => ((await state(pageB)).hearingList || []).includes(a), 15000);
4666 check(heardA, 'B reports that it hears A (' + JSON.stringify((await state(pageB)).hearingList) + ')');
4667 const fast = await until(async () => {
4668 const j = (await joins())[b];
4669 return j && j.done ? j : false;
4670 }, 15000);
4671 check(fast && !fast.shown, 'a fast join never says "can’t hear you yet" (' + JSON.stringify(fast) + ', ' +
4672 (fast ? fast.done - fast.from : '?') + ' ms from appearing to being heard)');
4673 check(!(await said()).some((t) => /hear you/.test(t)), 'and nothing about it is announced (' + JSON.stringify(await said()) + ')');
4674 await leaveRoom(pageB);
4675 await pageB.close();
4676
4677 // 2. B again, with everything that would connect it held back for a
4678 // few seconds: its offers, answers and candidates.
4679 const HOLD = 6000;
4680 let releaseAt = 0;
4681 const holdSignals = async (route) => {
4682 const body = route.request().postData() || '';
4683 if (/"type":"(offer|answer|candidate)"/.test(body)) {
4684 if (!releaseAt) releaseAt = Date.now() + HOLD;
4685 await sleep(Math.max(0, releaseAt - Date.now()));
4686 }
4687 await route.continue();
4688 };
4689 await ctxB.route('**/signal', holdSignals);
4690 await pageA.evaluate(() => { window.__said = []; });
4691 pageB = await openRoom(ctxB);
4692 const joined = await until(async () => {
4693 const s = await state(pageB);
4694 return s.admitted && s.peer ? s.peer : false;
4695 }, 20000);
4696 check(joined, 'B is back, connection held — ' + (await diagnose(pageB, 'member B')));
4697 b = joined;
4698 const words = nameB + ' is connecting and can’t hear you yet';
4699 const showing = await until(async () => {
4700 const s = await state(pageA);
4701 const h = s.hearing[b];
4702 return h && h.noticeKind === 'connecting' ? h : false;
4703 }, 10000);
4704 check(showing && showing.notice === words, 'A says it on B’s tile (' + JSON.stringify(showing) + ')');
4705 const drawn = await pageA.evaluate((id) => {
4706 const n = document.querySelector('figure[data-tile="' + id + '"] .calls-tile-notice');
4707 const dots = n && n.querySelector('.calls-ellipsis');
4708 return n && { visible: !n.hidden && n.offsetWidth > 0, dots: !!dots, hidden: dots && dots.getAttribute('aria-hidden') };
4709 }, b);
4710 check(drawn && drawn.visible && drawn.dots && drawn.hidden === 'true',
4711 'on screen, with the dots hidden from screen readers (' + JSON.stringify(drawn) + ')');
4712 check((await said()).includes(words), 'and it is announced once, without the dots (' + JSON.stringify(await said()) + ')');
4713 if (out) await pageA.screenshot({ path: out + '/web-connecting.png' });
4714 await pageA.emulateMedia({ reducedMotion: 'reduce' });
4715 const still = await pageA.evaluate((id) => {
4716 const dot = document.querySelector('figure[data-tile="' + id + '"] .calls-ellipsis > span');
4717 return dot ? getComputedStyle(dot).animationName : null;
4718 }, b);
4719 check(still === 'none', 'under reduced motion the dots keep still (' + still + ')');
4720 if (out) await pageA.screenshot({ path: out + '/web-connecting-reduced-motion.png' });
4721 await pageA.emulateMedia({ reducedMotion: 'no-preference' });
4722
4723 const clear = await until(async () => {
4724 const s = await state(pageA);
4725 const h = s.hearing[b];
4726 return h && h.notice === '' && s.connecting[b] && !s.connecting[b].joining ? s : false;
4727 }, 30000);
4728 check(clear, 'once B hears A, the words go — ' + (await diagnose(pageA, 'member A')));
4729 check(clear && ((clear.remoteMedia[b] || {}).hearing || []).includes(a), 'because B’s own report says so (' +
4730 JSON.stringify(clear && clear.remoteMedia[b]) + ')');
4731 check(releaseAt > 0 && Date.now() >= releaseAt, 'and not before the hold let go');
4732 check((await said()).includes(nameB + ' can hear you now'), 'and A hears that B can hear it now (' + JSON.stringify(await said()) + ')');
4733 await ctxB.unroute('**/signal', holdSignals);
4734 await leaveRoom(pageB);
4735 await pageB.close();
4736
4737 // 3. B as a client that does not say who it hears: the key stripped
4738 // off every report, and the connection held back as before.
4739 releaseAt = 0;
4740 const oldClient = async (route) => {
4741 let body = route.request().postData() || '';
4742 if (body.includes('"type":"media"')) {
4743 const sig = JSON.parse(body);
4744 delete sig.payload.hearing;
4745 body = JSON.stringify(sig);
4746 return route.continue({ postData: body });
4747 }
4748 return holdSignals(route);
4749 };
4750 await ctxB.route('**/signal', oldClient);
4751 pageB = await openRoom(ctxB);
4752 const back = await until(async () => {
4753 const s = await state(pageB);
4754 return s.admitted && s.peer ? s.peer : false;
4755 }, 20000);
4756 check(back, 'B is back as an old client — ' + (await diagnose(pageB, 'member B')));
4757 b = back;
4758 const oldShowing = await until(async () => {
4759 const h = (await state(pageA)).hearing[b];
4760 return h && h.noticeKind === 'connecting' ? h : false;
4761 }, 10000);
4762 check(oldShowing, 'the words show for an old client too (' + JSON.stringify(oldShowing) + ')');
4763 const oldClear = await until(async () => {
4764 const s = await state(pageA);
4765 return s.connecting[b] && !s.connecting[b].joining && s.hearing[b] && s.hearing[b].notice === '' ? s : false;
4766 }, 30000);
4767 check(oldClear && oldClear.remoteMedia[b] && !('hearing' in oldClear.remoteMedia[b]) &&
4768 oldClear.connection[b] === 'connected',
4769 'and go once its connection is up, without any `hearing` from it (' + JSON.stringify(oldClear && oldClear.remoteMedia[b]) + ')');
4770 check(Date.now() - (releaseAt || Date.now()) < 20000, 'well before the twenty-second cap');
4771 await ctxB.unroute('**/signal', oldClient);
4772
4773 check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
4774 // A signal held back for seconds can land after the pair has moved to
4775 // a new generation, and the hub refuses it 409 (§15.9) — which is the
4776 // hold's doing, and what the client recovers from. Nothing else.
4777 const errorsB = (consoleErrors.get('member B') || []).filter((e) => !/status of 409/.test(e)).join('; ');
4778 check(errorsB === '', 'no console errors in B but the held signals’ 409s (' + errorsB + ')');
4779 await leaveRoom(pageA);
4780 await leaveRoom(pageB);
4781 await ctxA.close();
4782 await ctxB.close();
4783 },
4784
4785 // Speaker view and the viewer's order (0.4.0), in a call of four: two
4786 // members and two guests. Everyone's microphone is silenced but one,
4787 // which speaks a tone; the stage goes to that person only once they
4788 // have held the floor for the dominance delay, and stays put through a
4789 // short interjection. A pin holds the stage against the speaker; the
4790 // tile buttons and a mouse drag change the tiles' order in the DOM,
4791 // and the order survives a reload of the tab (sessionStorage).
4792 // CALLS_SPEAKER_SNAPSHOTS=<dir> keeps pictures of each state.
4793 async speaker() {
4794 const out = process.env.CALLS_SPEAKER_SNAPSHOTS;
4795 if (out) mkdirSync(out, { recursive: true });
4796 const shot = async (page, name) => { if (out) await page.screenshot({ path: out + '/web-' + name + '.png' }); };
4797 const ctxA = await newContext('member A', { cookie: cookieA });
4798 const ctxB = await newContext('member B', { cookie: cookieB });
4799 const ctxG = await newContext('guest');
4800 const ctxW = await newContext('waiter');
4801 const pageA = await openRoom(ctxA);
4802 await pageA.setViewportSize({ width: 1280, height: 900 });
4803 await pageA.evaluate(() => { localStorage.removeItem('calls.videoLayout'); });
4804 const pageB = await openRoom(ctxB);
4805 const pair = await assertPair(pageA, pageB, 'member A', 'member B');
4806 if (!pair) return;
4807 for (const [ctx, address, name] of [[ctxG, GUEST, 'guest'], [ctxW, WAITER, 'waiter']]) {
4808 await signInAsGuest(ctx, address);
4809 await openRoom(ctx);
4810 check(await until(async () => (await pageA.locator('#knocks li').count()) >= 1 && await admitKnock(pageA, name), 20000),
4811 'A lets ' + name + ' in (' + JSON.stringify(await queueNames(pageA)) + ')');
4812 }
4813 const pageG = ctxG.pages().find((p) => p.url().endsWith('/m/' + sqid));
4814 const pageW = ctxW.pages().find((p) => p.url().endsWith('/m/' + sqid));
4815 const four = await until(async () => {
4816 const s = await state(pageA);
4817 return s.peers.length === 3 && Object.values(s.frames).filter((n) => n > 0).length >= 3 ? s : false;
4818 }, 40000);
4819 check(four, 'A is in a call of four, with video from all three (' + JSON.stringify(await state(pageA).catch(() => null)) + ')');
4820 if (!four) return;
4821 const b = pair.b.peer;
4822 const g = (await state(pageG)).peer;
4823 const w = (await state(pageW)).peer;
4824 check(await pageA.locator('#layout-speaker').getAttribute('aria-pressed') === 'true', 'Speaker is the layout by default');
4825
4826 // Silence: every microphone's track disabled under the call, so the
4827 // packets carry nothing and nobody is speaking.
4828 const hush = (page) => page.evaluate(() => {
4829 for (const pc of window.__pcs || []) {
4830 for (const sn of pc.getSenders()) if (sn.track && sn.track.kind === 'audio') sn.track.enabled = false;
4831 }
4832 });
4833 for (const page of [pageA, pageB, pageG, pageW]) await hush(page);
4834 await sleep(2500);
4835 const layoutOf = (page) => page.evaluate(() => ({
4836 ...window.callsLayout.state(),
4837 stage: (document.querySelector('#tiles > [data-stage]') || {}).dataset?.tile || '',
4838 pinned: (document.querySelector('#tiles > [data-pinned]') || {}).dataset?.tile || '',
4839 tiles: Array.from(document.querySelectorAll('#tiles > figure')).map((f) => f.dataset.tile),
4840 strip: Array.from(document.querySelectorAll('#tiles > figure:not([data-stage])')).map((f) => f.dataset.tile),
4841 }));
4842 const quiet = await layoutOf(pageA);
4843 check(quiet.strip.length === 3 && quiet.strip.includes('self'),
4844 'the filmstrip holds the three not on the stage, A included (' + JSON.stringify(quiet) + ')');
4845
4846 // Before anyone speaks the stage is the first other person in A's
4847 // order. G, who is not, speaks: the stage goes to G, and not before
4848 // G has held the floor for the dominance delay.
4849 check(quiet.stage !== g, 'G is not on the stage before speaking (' + quiet.stage + ')');
4850 await speakInto(pageG);
4851 const started = Date.now();
4852 const toG = await until(async () => ((await layoutOf(pageA)).stage === g ? Date.now() : false), 15000);
4853 check(toG, 'the stage goes to G while G speaks (' + JSON.stringify(await layoutOf(pageA)) + ')');
4854 check(toG && toG - started >= 1200, 'and only after G has held the floor for the delay (' + (toG - started) + ' ms)');
4855 await shot(pageA, 'speaker-filmstrip');
4856
4857 // B interjects for about half a second while G carries on: the stage
4858 // stays with G throughout.
4859 await speakInto(pageB);
4860 await sleep(500);
4861 await stopSpeaking(pageB);
4862 await hush(pageB);
4863 const flapped = await stayed(pageA, 4000, (s) => s.speaker === g);
4864 check(!flapped, 'an interjection does not take the stage (' + JSON.stringify(flapped && flapped.speaker) + ')');
4865 // G stops; W speaks on. W takes it, and over the next seconds the
4866 // stage changes exactly once.
4867 await stopSpeaking(pageG);
4868 await hush(pageG);
4869 await speakInto(pageW);
4870 const seen = [];
4871 const deadline = Date.now() + 9000;
4872 while (Date.now() < deadline) {
4873 const s = await layoutOf(pageA);
4874 if (seen[seen.length - 1] !== s.stage) seen.push(s.stage);
4875 await sleep(150);
4876 }
4877 check(seen.join(',') === [g, w].join(','), 'the stage moves once, from G to W, and does not flap (' + seen.join(' -> ') + ')');
4878
4879 // A pin holds the stage against the speaker: B's tile, clicked in the
4880 // filmstrip.
4881 await pageA.locator('#tiles > figure[data-tile="' + b + '"]').click({ position: { x: 20, y: 40 } });
4882 const pinned = await until(async () => {
4883 const l = await layoutOf(pageA);
4884 return l.stage === b && l.pinned === b ? l : false;
4885 }, 3000);
4886 check(pinned, 'clicking B in the filmstrip pins B to the stage (' + JSON.stringify(await layoutOf(pageA)) + ')');
4887 check(/Pinned .* to the stage/.test(await pageA.locator('#layout-live').textContent()), 'and says so, politely');
4888 check(await pageA.locator('#tiles > figure[data-tile="' + b + '"] .calls-tool-pin').isVisible(),
4889 'a pinned stage shows its pin');
4890 await sleep(2500);
4891 check((await layoutOf(pageA)).stage === b, 'W speaking does not take a pinned stage');
4892 await shot(pageA, 'speaker-pinned');
4893 await pageA.locator('#tiles > figure[data-tile="' + b + '"] .calls-tool-pin').click();
4894 check(await until(async () => (await layoutOf(pageA)).stage === w, 3000), 'unpinning gives the stage back to the speaker');
4895 await stopSpeaking(pageW);
4896
4897 // The viewer's order: "Move to first" on the last person changes the
4898 // DOM order, and the keyboard stays on a move button.
4899 await chooseLayout(pageA, 'grid');
4900 const before = (await layoutOf(pageA)).tiles;
4901 const last = before[before.length - 1];
4902 await openTileMenu(pageA, last);
4903 const first = pageA.locator('#tile-menu [data-move="first"]');
4904 await first.focus();
4905 await pageA.keyboard.press('Enter');
4906 const moved = await until(async () => {
4907 const l = await layoutOf(pageA);
4908 return l.tiles[0] === last ? l : false;
4909 }, 3000);
4910 check(moved, 'Move to first puts ' + last + ' first in the DOM (' + JSON.stringify(await layoutOf(pageA)) + ')');
4911 check(/moved to position 1 of 4/.test(await pageA.locator('#layout-live').textContent()), 'and says where it went');
4912 check(await pageA.evaluate(() => !!document.activeElement.closest('#tile-menu')), 'the keyboard stays in the tile\'s menu');
4913 // Move later, by keyboard: one place on.
4914 const later = pageA.locator('#tile-menu [data-move="later"]');
4915 await later.focus();
4916 await pageA.keyboard.press('Enter');
4917 check(await until(async () => (await layoutOf(pageA)).tiles[1] === last, 3000), 'Move later moves it one place');
4918 await pageA.keyboard.press('Escape');
4919 check(await pageA.locator('#tile-menu').isHidden() &&
4920 await pageA.evaluate(() => document.activeElement && document.activeElement.classList.contains('calls-tool-more')),
4921 'Escape closes the menu and puts the keyboard back on its ⋯');
4922
4923 // A mouse drag: the first tile dropped on the third's place.
4924 const order1 = (await layoutOf(pageA)).tiles;
4925 const from = await pageA.locator('#tiles > figure[data-tile="' + order1[0] + '"]').boundingBox();
4926 const to = await pageA.locator('#tiles > figure[data-tile="' + order1[2] + '"]').boundingBox();
4927 await pageA.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
4928 await pageA.mouse.down();
4929 for (let i = 1; i <= 10; i++) {
4930 await pageA.mouse.move(from.x + from.width / 2 + (to.x - from.x) * i / 10, from.y + from.height / 2 + (to.y - from.y) * i / 10);
4931 await sleep(30);
4932 }
4933 check(await pageA.locator('.calls-tile-lifted').count() === 1, 'the dragged tile is lifted');
4934 await shot(pageA, 'grid-drag');
4935 await pageA.mouse.up();
4936 const dragged = await until(async () => {
4937 const l = await layoutOf(pageA);
4938 return l.tiles[2] === order1[0] ? l : false;
4939 }, 3000);
4940 check(dragged, 'dragging the first tile onto the third puts it third in the DOM (' +
4941 JSON.stringify({ before: order1, after: (await layoutOf(pageA)).tiles }) + ')');
4942 await shot(pageA, 'grid-reordered');
4943 const kept = (await layoutOf(pageA)).tiles;
4944 const stored = await pageA.evaluate((k) => sessionStorage.getItem(k), 'calls.order.' + sqid);
4945 check(stored && JSON.parse(stored).length === 4, 'the order is kept in sessionStorage for the meeting (' + stored + ')');
4946
4947 // A reload of the tab puts the order back (the peer ids are new).
4948 await pageA.reload({ waitUntil: 'domcontentloaded' });
4949 await pageA.waitForFunction(() => !!window.meet, null, { timeout: 20000 });
4950 const back = await until(async () => {
4951 const l = await pageA.evaluate(() => Array.from(document.querySelectorAll('#tiles > figure')).map((f) =>
4952 f.classList.contains('calls-tile-self') ? 'self' : f.dataset.name));
4953 return l.length === 4 ? l : false;
4954 }, 40000);
4955 check(back, 'A is back in the call of four after a reload');
4956 // Compared with what was kept before the reload, not after it: a
4957 // leave that wiped the order and a rejoin that rebuilt it in roster
4958 // order would agree with themselves.
4959 const want = JSON.parse(stored || '[]');
4960 check(back && back.map((n) => n === 'self' ? 'self' : 'name:' + n).join(',') === want.join(','),
4961 'and the tiles come back in the kept order (' + JSON.stringify({ back, want, kept,
4962 now: await pageA.evaluate(() => window.callsLayout && window.callsLayout.state()) }) + ')');
4963
4964 for (const label of ['member A', 'member B', 'guest', 'waiter']) {
4965 check(errorsFor(label) === '', 'no console errors in ' + label + ' (' + errorsFor(label) + ')');
4966 }
4967 for (const page of [pageA, pageB, pageG, pageW]) await leaveRoom(page);
4968 for (const ctx of [ctxA, ctxB, ctxG, ctxW]) await ctx.close();
4969 },
4970
4971 // The floor: both pages load with JavaScript switched off, say so in
4972 // plain words, and show nothing that pretends to be a call.
4973 async layout() {
4974 const ctxA = await newContext('layout A', {cookie: cookieA});
4975 const ctxB = await newContext('layout B', {cookie: cookieB});
4976 const pageA = await openRoom(ctxA);
4977 const pageB = await openRoom(ctxB);
4978 const pair = await assertPair(pageA, pageB, 'layout A', 'layout B');
4979 if (!pair) throw new Error('layout call did not connect');
4980 await openChat(pageA);
4981 await pageA.locator('#chat-text').fill('Draft kept while chat is closed');
4982 await pageA.locator('#toggle-chat').click();
4983 check(await until(() => pageA.locator('#chat').isHidden(), 2000), 'chat collapses');
4984 await openChat(pageB);
4985 await pageB.locator('#chat-text').fill('New message while hidden');
4986 await pageB.locator('#chat-send').click();
4987 check(await until(async () => (await pageA.locator('#chat-unread').innerText()).includes('1'), 5000),
4988 'hidden chat reports unread arrivals');
4989 // The badge above is aria-hidden and the toggle's aria-label does not
4990 // announce, and collapsing chat display:none's the aria-live log out
4991 // of the tree entirely — so without this region a screen reader is
4992 // told nothing at all. It has to be OUTSIDE #chat to survive the
4993 // collapse, which is what textContent being readable here proves:
4994 // Playwright reads the DOM, but the assertion below on the live
4995 // region's ancestry is what pins it out of the collapsed subtree.
4996 check(await until(async () => (await pageA.locator('#chat-unread-live')
4997 .textContent()) === '1 new chat message', 5000),
4998 'collapsed chat announces the unread count to a screen reader (got ' +
4999 JSON.stringify(await pageA.locator('#chat-unread-live').textContent()) + ')');
5000 check(await pageA.evaluate(() => {
5001 const live = document.getElementById('chat-unread-live');
5002 const chat = document.getElementById('chat');
5003 return !!live && !!chat && !chat.contains(live);
5004 }), 'the unread live region is outside the panel that gets collapsed');
5005 await pageA.locator('#toggle-chat').focus();
5006 await pageA.keyboard.press('Enter');
5007 await pageA.keyboard.press('Escape');
5008 check(await until(() => pageA.locator('#chat-overlay').isHidden(), 2000), 'Escape closes chat');
5009 check(await pageA.evaluate(() => document.activeElement && document.activeElement.id === 'toggle-chat'),
5010 'and the keyboard goes back to the Chat button');
5011 await pageA.keyboard.press('Enter');
5012 check(await pageA.locator('#chat-text').inputValue() === 'Draft kept while chat is closed',
5013 'keyboard reopening preserves the draft');
5014 check(await pageA.locator('#chat-log').innerText().then(x => x.includes('New message while hidden')),
5015 'reopening preserves messages');
5016 const out = process.env.CALLS_DESIGN_SCREENSHOTS;
5017 if (out) mkdirSync(out, {recursive: true});
5018 for (const width of [1200, 320]) {
5019 await pageA.setViewportSize({width, height: 900});
5020 for (const colorScheme of ['light', 'dark']) {
5021 await pageA.emulateMedia({colorScheme, reducedMotion: 'reduce'});
5022 for (const mode of ['speaker', 'grid']) {
5023 await chooseLayout(pageA, mode);
5024 check(await pageA.locator('#layout-' + mode).getAttribute('aria-pressed') === 'true', mode + ' selection is exposed');
5025 check(await pageA.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
5026 `${width}px ${colorScheme} ${mode} has no horizontal page overflow`);
5027 check(await pageA.locator('#controls button:visible').evaluateAll(buttons => buttons.every(button => {
5028 const r = button.getBoundingClientRect(); return r.width >= 44 && r.height >= 44;
5029 })), 'call controls retain 44px targets');
5030 if (out) await pageA.screenshot({path: `${out}/room-${width}-${colorScheme}-${mode}.png`, fullPage: true});
5031 }
5032 }
5033 }
5034 await chooseLayout(pageA, 'speaker');
5035 await pageA.locator('[data-stage] .calls-tile-note').evaluate(note => { note.textContent = 'Connection recovery'; note.hidden = false; });
5036 check(await until(async () => !await pageA.locator('#tiles').evaluate(el => el.hasAttribute('data-pip')), 2000),
5037 'recovery captions disable the self overlay');
5038 await pageA.locator('[data-stage] .calls-tile-note').evaluate(note => { note.textContent = ''; note.hidden = true; });
5039 await pageB.locator('#share').click();
5040 await pageA.locator('.calls-tile-share').waitFor({state: 'visible'});
5041 check(await until(async () => !await pageA.locator('#tiles').evaluate(el => el.hasAttribute('data-pip')), 2000),
5042 'screen share never gets a self-preview overlay');
5043 // With the controls hidden: while they show, the filmstrip rises
5044 // clear of the bar and over the stage's lower edge, as the Mac's does.
5045 await pageA.evaluate(() => window.callsChrome.pin(false));
5046 await sleep(400);
5047 const shareBounds = await pageA.locator('.calls-tile-share').boundingBox();
5048 const selfBounds = await pageA.locator('.calls-tile-self').boundingBox();
5049 check(selfBounds.y >= shareBounds.y + shareBounds.height - 1, 'self preview is outside shared screen content');
5050 await pageA.evaluate(() => window.callsChrome.pin(null));
5051 if (out) await pageA.screenshot({path: out + '/share-320.png', fullPage: true});
5052 await pageB.locator('#share').click();
5053 await assertPair(pageA, pageB, 'layout A after changes', 'layout B after changes');
5054 await leaveRoom(pageA);
5055 await leaveRoom(pageB);
5056 const holding = await ctxA.newPage();
5057 await holding.setViewportSize({width: 320, height: 900});
5058 await holding.goto(origin + '/m/' + sqid + '/join');
5059 await holding.locator('#join').waitFor({state: 'visible'});
5060 check(await holding.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'joining screen reflows at 320px');
5061 if (out) await holding.screenshot({path: out + '/join-320.png', fullPage: true});
5062 await ctxA.close(); await ctxB.close();
5063 const noMedia = await newContext('layout no media', {cookie: cookieA});
5064 await noMedia.addInitScript(() => Object.defineProperty(navigator, 'mediaDevices', {value: undefined}));
5065 const noPreview = await noMedia.newPage();
5066 await noPreview.goto(origin + '/m/' + sqid + '/join');
5067 await noPreview.locator('#devices').waitFor({state: 'visible'});
5068 check(await noPreview.locator('#preview').isHidden(), 'no-media preview stays hidden despite author styles');
5069 await noMedia.close();
5070 },
5071
5072 // The control bar's glyphs and the filmstrip's labels (0.4.1). Paul
5073 // measured the Mac bar's glyphs and found the share symbol low; the
5074 // web bar is held to the same rule: every glyph's ink centred on the
5075 // bar's centre line, within half a pixel. And a filmstrip tile's
5076 // caption stays inside its tile, its muted mark visible, with the
5077 // camera-off cover's words never under it, down to a 320 px screen.
5078 // CALLS_BAR_SNAPSHOTS=<dir> keeps pictures and the measurements.
5079 async glyphs() {
5080 const out = process.env.CALLS_BAR_SNAPSHOTS;
5081 if (out) mkdirSync(out, { recursive: true });
5082 const report = [];
5083 const ctxA = await newContext('glyphs A', { cookie: cookieA });
5084 const ctxB = await newContext('glyphs B', { cookie: cookieB });
5085 const pageA = await openRoom(ctxA);
5086 const pageB = await openRoom(ctxB);
5087 const pair = await assertPair(pageA, pageB, 'glyphs A', 'glyphs B');
5088 if (!pair) throw new Error('glyphs call did not connect');
5089 const b = pair.b.peer;
5090 // B mutes and turns the camera off, so B's tile carries both marks.
5091 await pageB.locator('#mute').click();
5092 await pageB.locator('#camera').click();
5093 const tileB = pageA.locator('#tiles > figure[data-tile="' + b + '"]');
5094 check(await until(async () => await tileB.locator('.calls-tile-muted').isVisible() &&
5095 await tileB.locator('.calls-tile-off').isVisible(), 10000), 'B shows muted and camera off on A');
5096 await pageA.emulateMedia({ reducedMotion: 'reduce' });
5097 await chooseLayout(pageA, 'speaker');
5098 // Pin yourself, so B is in the filmstrip.
5099 await openTileMenu(pageA, 'self');
5100 await pageA.locator('#tile-menu [data-act="pin"]').click();
5101 check(await until(async () => (await pageA.locator('#tiles > [data-stage]').getAttribute('data-tile')) === 'self', 3000),
5102 'A pinned to the stage, B in the filmstrip');
5103 await pageA.mouse.move(1, 1);
5104
5105 for (const width of [1280, 760]) {
5106 await pageA.setViewportSize({ width, height: 800 });
5107 await sleep(300);
5108 await pageA.locator('#controls').scrollIntoViewIfNeeded();
5109 const bar = await pageA.locator('#controls').boundingBox();
5110 // The centre line of the row a button is in: the bar's rows
5111 // stretch their buttons to one height, so it is the button's own
5112 // centre (and the bar's, while the bar is one row).
5113 // The glyph each button is showing: a toggle carries both of its
5114 // glyphs (the microphone and the slashed one) and shows one.
5115 const glyphs = await pageA.locator('#controls button:visible svg.icon').evaluateAll((svgs) => svgs
5116 .filter((svg) => svg.getBoundingClientRect().width > 0).map((svg) => {
5117 const r = svg.getBoundingClientRect();
5118 const row = svg.closest('button').getBoundingClientRect();
5119 return { id: svg.closest('button').id || 'share-link', x: r.x, y: r.y, width: r.width, height: r.height,
5120 centre: row.y + row.height / 2 };
5121 }));
5122 check(glyphs.length >= 5, width + ' px: the bar has its glyphs (' + glyphs.length + ')');
5123 for (const g of glyphs) {
5124 const ink = await inkCentroid(pageA, g);
5125 const centre = g.centre;
5126 const off = ink === null ? NaN : ink - centre;
5127 report.push({ width, glyph: g.id, inkCentroid: ink, rowCentre: centre, barCentre: bar.y + bar.height / 2, offset: off });
5128 check(Math.abs(off) <= 0.5, width + ' px: ' + g.id + "'s ink is on the bar's centre line (" +
5129 (off >= 0 ? '+' : '') + off.toFixed(2) + ' px)');
5130 }
5131 if (out) await pageA.screenshot({ path: out + '/web-bar-' + width + '.png', clip: bar });
5132 }
5133
5134 // A name as long as a real one can be, so a narrow tile has to
5135 // truncate it.
5136 await tileB.locator('figcaption > span').first().evaluate((n) => { n.textContent = 'Ana García Fernández'; });
5137 for (const width of [1280, 760, 390, 320]) {
5138 await pageA.setViewportSize({ width, height: width < 500 ? 844 : 800 });
5139 await sleep(300);
5140 const t = await tileB.evaluate((fig) => {
5141 const box = (el) => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left, right: r.right }; };
5142 const cap = fig.querySelector('figcaption');
5143 const name = cap.querySelector('span');
5144 const muted = fig.querySelector('.calls-tile-muted');
5145 const mark = muted.querySelector('svg');
5146 const off = fig.querySelector('.calls-tile-off');
5147 const ink = Array.from(off.children).filter((c) => getComputedStyle(c).display !== 'none' && c.getClientRects().length);
5148 return {
5149 tile: box(fig), caption: box(cap), mark: mark ? box(mark) : null,
5150 markShown: !!mark && getComputedStyle(mark).display !== 'none' && mark.getBoundingClientRect().width > 0,
5151 nameFits: name.scrollWidth <= name.clientWidth + 1 || getComputedStyle(name).textOverflow === 'ellipsis',
5152 cover: ink.map(box),
5153 label: fig.getAttribute('aria-label') || '',
5154 mutedText: muted.textContent.trim(),
5155 };
5156 });
5157 report.push({ width, filmstripTile: t });
5158 check(t.caption.right <= t.tile.right + 0.5 && t.caption.left >= t.tile.left - 0.5,
5159 width + ' px: the caption stays inside the filmstrip tile');
5160 check(t.markShown && t.mark.right <= t.tile.right, width + ' px: the muted mark is visible');
5161 check(t.nameFits, width + ' px: the name fits or ends in an ellipsis');
5162 check(t.cover.every((c) => c.bottom <= t.caption.top), width + " px: the camera-off cover is clear of the caption (" +
5163 JSON.stringify(t.cover.map((c) => Math.round(c.bottom))) + ' vs ' + Math.round(t.caption.top) + ')');
5164 check(/muted/i.test(t.mutedText), width + ' px: "Muted" is still in the tile for assistive technology');
5165 if (out) await pageA.screenshot({ path: out + '/web-filmstrip-' + width + '.png', fullPage: true });
5166 }
5167 if (out) writeFileSync(out + '/web-measurements.json', JSON.stringify(report, null, 2));
5168 await ctxA.close();
5169 await ctxB.close();
5170 },
5171
5172 // The call window (2026-09-28). Paul asked for the web call to be
5173 // more like the Mac's: the whole call in a rounded window inset from
5174 // the viewport. So: the window is inset with rounded corners on a
5175 // desktop and edge to edge on a phone; the page does not scroll; the
5176 // controls come with the pointer, go 3 s after it stops or 1.5 s
5177 // after it leaves, come back with movement and with Tab, and stay
5178 // while the keyboard, the pointer on the bar or an open panel holds
5179 // them; chat is a sidebar over the picture, not a column beside it;
5180 // the popovers sit over their buttons; the self view floats in a call
5181 // of two and can be moved to another corner; the sharing notice and
5182 // its Stop sharing stay with the controls hidden; the glass keeps
5183 // white at 4.5:1 or better over white, black and busy video, with and
5184 // without its blur; and with no script the room still reads sensibly
5185 // inside the window. CALLS_WINDOW_SNAPSHOTS=<dir> keeps pictures.
5186 // Shared chrome R2 (estilo spec/shared-chrome): the holding screen and
5187 // the room hold still while they settle, and the room's title menu
5188 // speaks the account menu's words. The room used to paint a lone self
5189 // tile at filmstrip size and then fill the stage with it, a layout
5190 // shift of 0.43–0.80 on some joins, so the room is loaded eight times.
5191 async chrome() {
5192 // The audit's own viewport, where both shifts were measured.
5193 const ctx = await newContext('member A', { cookie: cookieA, options: { viewport: { width: 1440, height: 900 } } });
5194 await ctx.addInitScript(() => {
5195 window.__cls = 0;
5196 window.__shifts = [];
5197 new PerformanceObserver((l) => {
5198 for (const e of l.getEntries()) {
5199 if (e.hadRecentInput) continue;
5200 window.__cls += e.value;
5201 window.__shifts.push({ v: +e.value.toFixed(4), t: Math.round(e.startTime), src: (e.sources || []).map((s) => (s.node && (s.node.id || s.node.className)) || '?').join(',') });
5202 }
5203 }).observe({ type: 'layout-shift', buffered: true });
5204 });
5205 const page = await ctx.newPage();
5206 const cls = () => page.evaluate(() => ({ cls: window.__cls, shifts: window.__shifts }));
5207
5208 await page.goto(origin + '/m/' + sqid + '/join');
5209 const ready = await until(async () => await page.locator('#join').isEnabled(), 15000);
5210 check(ready, 'the holding screen is ready — status: ' + (await page.locator('#status').textContent()));
5211 await new Promise((r) => setTimeout(r, 1000));
5212 const holding = await cls();
5213 check(holding.cls < 0.01, 'the holding screen holds still while its devices arrive (CLS ' + holding.cls.toFixed(4) + ' ' + JSON.stringify(holding.shifts) + ')');
5214 check(await page.locator('header[estilo-bar]').count() === 1, 'the holding screen wears the estilo bar');
5215
5216 // With script, Copy link shows, and Share… only where the browser
5217 // can share: rst-btn's display used to beat its hidden attribute.
5218 const [share] = await Promise.all([ctx.waitForEvent('page'), page.click('form[action$="/share"] button')]);
5219 await share.waitForLoadState('load');
5220 const canShare = await share.evaluate(() => !!navigator.share);
5221 check(await share.locator('#copy-invitation').isVisible() && (await share.locator('#share-invitation').isVisible()) === canShare,
5222 'the Share page shows Copy link, and Share… only where the browser can share (navigator.share ' + canShare + ')');
5223 await share.close();
5224
5225 await Promise.all([page.waitForURL('**/m/' + sqid), page.click('#join')]);
5226 await page.waitForFunction(() => !!window.meet, null, { timeout: 20000 });
5227 check(await until(async () => (await state(page)).admitted, 20000), 'joined from the holding screen');
5228 await new Promise((r) => setTimeout(r, 1500));
5229 const joined = await cls();
5230 check(joined.cls < 0.01, 'holding screen → room holds still (CLS ' + joined.cls.toFixed(4) + ' ' + JSON.stringify(joined.shifts) + ')');
5231
5232
5233 // The More menu (2026-10-02): the account menu's order and words,
5234 // which the title pill's ☰ menu carried until the pill went.
5235 await page.mouse.move(200, 200);
5236 await page.click('#call-more-toggle');
5237 const menu = (await page.locator('#call-more').innerText()).split('\n').map((l) => l.trim()).filter(Boolean);
5238 const want = ['You’re in this call as paul.', 'Signed in as', 'Meetings', 'Settings', 'Help', 'Open source', 'Sign out', 'End meeting for everyone'];
5239 const order = want.map((w) => menu.indexOf(w));
5240 check(order.every((i, n) => i >= 0 && (n === 0 || i > order[n - 1])), 'the More menu reads ' + want.join(' / ') + ' in that order (' + JSON.stringify(menu) + ')');
5241 check(await page.getByText('Sign out', { exact: true }).count() === 1, 'the room has exactly one Sign out');
5242 const onTop = await page.evaluate(() => {
5243 const m = document.getElementById('call-more');
5244 const r = m.getBoundingClientRect();
5245 const hit = document.elementFromPoint(r.left + r.width / 2, r.top + 12);
5246 return !!hit && m.contains(hit);
5247 });
5248 check(onTop, 'the open More menu is above everything under it');
5249 check(await page.locator('#call-nav, #call-state').count() === 0, 'there is no title pill or ☰ menu');
5250 await page.keyboard.press('Escape');
5251 await leaveRoom(page);
5252
5253 // The room loaded straight from the list, eight times: the shift
5254 // came on some joins and not others.
5255 let worst = { cls: 0, shifts: [] };
5256 for (let i = 0; i < 8; i++) {
5257 await page.goto(origin + '/');
5258 await page.goto(origin + '/m/' + sqid);
5259 await page.waitForFunction(() => !!window.meet, null, { timeout: 20000 });
5260 await until(async () => (await state(page)).admitted, 20000);
5261 await new Promise((r) => setTimeout(r, 1200));
5262 const c = await cls();
5263 if (c.cls >= worst.cls) worst = c;
5264 await leaveRoom(page);
5265 }
5266 check(worst.cls < 0.01, 'the room