oficina / meet Public
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). |
| 34 | import { writeSync, mkdirSync, writeFileSync } from 'node:fs'; |
| 35 | import { inflateSync } from 'node:zlib'; |
| 36 | import { createInterface } from 'node:readline'; |
| 37 | import { chromium } from 'playwright'; |
| 38 | |
| 39 | const [origin, sqid, scenario] = process.argv.slice(2); |
| 40 | const cookieName = process.env.CALLS_COOKIE_NAME; |
| 41 | const cookieA = process.env.CALLS_COOKIE_A; |
| 42 | const cookieB = process.env.CALLS_COOKIE_B; |
| 43 | const token = process.env.CALLS_JOIN_TOKEN; |
| 44 | |
| 45 | if (!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 | |
| 51 | const 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. |
| 58 | const 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. |
| 63 | const HARNESS_DEADLINE = 165000; |
| 64 | |
| 65 | const 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 | |
| 95 | const failures = []; |
| 96 | |
| 97 | function 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 | |
| 105 | function 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. |
| 118 | function 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 | |
| 128 | let rl = null; |
| 129 | const inbox = []; |
| 130 | const waiters = []; |
| 131 | |
| 132 | function 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 | |
| 150 | const 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. |
| 155 | async 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". |
| 173 | async 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. |
| 190 | async 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. |
| 220 | async 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. |
| 231 | function 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. |
| 239 | const MEDIA_WINDOWS = 3; |
| 240 | const 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. |
| 257 | async 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. |
| 289 | async 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 | |
| 325 | const browser = await chromium.launch({ args: LAUNCH_ARGS }); |
| 326 | |
| 327 | const 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. |
| 333 | const 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. |
| 337 | const 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. |
| 341 | async 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 | |
| 359 | function 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 | |
| 375 | function errorsFor(label) { |
| 376 | return (consoleErrors.get(label) || []).join('; '); |
| 377 | } |
| 378 | |
| 379 | function 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. |
| 386 | function 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 | |
| 433 | function 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(). |
| 443 | async 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 | |
| 615 | async 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. |
| 627 | async 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 | |
| 634 | async 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 | |
| 641 | async 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. |
| 647 | async 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. |
| 684 | async 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. |
| 698 | function chatMounted(page) { |
| 699 | return page.evaluate(() => { |
| 700 | const c = document.getElementById('chat'); |
| 701 | return !!c && !c.hidden; |
| 702 | }); |
| 703 | } |
| 704 | |
| 705 | async 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. |
| 715 | async 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. |
| 726 | async 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. |
| 749 | async 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. |
| 778 | async 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. |
| 789 | const ICE_WARNING = 'Calls could not reach the relay. Calls may not connect until it can.'; |
| 790 | const ICE_EXPIRED = 'The relay credential expired for this connection.'; |
| 791 | const 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. |
| 811 | async 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. |
| 855 | const 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. |
| 939 | function 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(). |
| 947 | function 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. |
| 954 | function 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. |
| 966 | function 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. |
| 973 | function 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. |
| 984 | function 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. |
| 1012 | function 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. |
| 1024 | function 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. |
| 1034 | function 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. |
| 1046 | async 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. |
| 1089 | async 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. |
| 1104 | function 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. |
| 1114 | function 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. |
| 1125 | async 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. |
| 1138 | function 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. |
| 1153 | function 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. |
| 1164 | function 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. |
| 1183 | function 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. |
| 1204 | function 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. |
| 1221 | function 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. |
| 1242 | const 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. |
| 1278 | function 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. |
| 1308 | function 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 | } |
| 1333 | function 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 | |
| 1341 | function 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 | |
| 1352 | function 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. |
| 1403 | function 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. |
| 1414 | async 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. |
| 1430 | async 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 | |
| 1450 | const 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 |