oficina / meet Public

Calls gate: meters, autoplay and audiotest scenarios

meters: a real call's meters move both ways, a beeping microphone is
never called silent, a muted peer shows Muted and never "No sound", and
a microphone track disabled under an unmuted report is "No sound from"
on one side and "isn't getting any sound from you" on the other, both
clearing when sound returns.

autoplay: a Chromium under its real document-user-activation-required
policy plays the remote tile with no gesture and no banner (the page
reports its own first play, because Playwright's evaluate is itself a
gesture). A forced refusal then raises the banner, reaches the other
peer as a notice, and a click on the heading puts everything back.

audiotest: the holding screen's three tests, with no request leaving
the page while they run, and Join still working afterwards.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 6a3da4962b36a6c8bb2eb8fbb0052271b6edc13d parent 4873145
2 files changed, +398 −32
  • internal/callstest/browser_test.go +3 −0
  • test/browser/call.mjs +395 −32
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go
index c7de78b..d69bc59 100644
--- a/internal/callstest/browser_test.go
+++ b/internal/callstest/browser_test.go
@@ -83,8 +83,10 @@ var scenarios = []string{
"guestdirect",
"layout",
"devices",
+ "audiotest",
"call",
"mute",
+ "meters",
"spacemute",
"switching",
"share",
@@ -115,6 +117,7 @@ var scenarios = []string{
"signedout",
"nowebrtc",
"playfallback",
+ "autoplay",
"scriptless",
}
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index d3f071f..194c835 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -329,8 +329,10 @@ const consoleErrors = new Map(); // label -> string[]
// measured against the error channel alone.
const consoleLines = new Map(); // label -> string[]
-async function newContext(label, { cookie = null, javaScriptEnabled = true } = {}) {
- const ctx = await browser.newContext({ javaScriptEnabled });
+// `via` is the browser to open the context in, for the one scenario
+// (autoplay) that needs a Chromium launched under a different policy.
+async function newContext(label, { cookie = null, javaScriptEnabled = true, via = browser } = {}) {
+ const ctx = await via.newContext({ javaScriptEnabled });
if (cookie) {
await ctx.addCookies([{ name: cookieName, value: cookie, url: origin }]);
}
@@ -402,6 +404,14 @@ function state(page) {
chat: s.chat,
sharing: s.sharing,
shares: s.shares,
+ // §15.20. `hearing` is keyed by tile ('self' for this tab's own):
+ // what each tile says about its audio, its levels between 0 and
+ // 1, its lit bars and any notice. All plain values.
+ hearing: s.hearing,
+ audioBlocked: s.audioBlocked,
+ soundBanner: s.soundBanner,
+ noSoundFrom: s.noSoundFrom,
+ remoteMedia: s.remoteMedia,
};
});
}
@@ -1157,6 +1167,51 @@ function tileMarks(page, key) {
}, key);
}
+// refuseFirstPlay is an init script: the first unmuted play() on the
+// page is refused the way a blocking autoplay policy refuses it, and the
+// element held paused until a later play() — which only a gesture
+// should be asking for.
+const refuseFirstPlay = () => {
+ const real = HTMLMediaElement.prototype.play;
+ let refused = 0;
+ let blocked = null;
+ // A rejected promise on its own is not what a blocking autoplay
+ // policy does: the element also stays paused. Rejecting and
+ // letting the element play anyway (its own `autoplay` attribute
+ // does that, and the launch flag permits it) left every "the
+ // gesture started the video" assertion true before the gesture
+ // as well, which is the hole Codex S2-9 is about. So the refusal
+ // pauses the element and keeps it paused until the gesture.
+ const holdPaused = (e) => {
+ if (blocked === e.target) e.target.pause();
+ };
+ HTMLMediaElement.prototype.play = function play() {
+ if (!this.muted && refused < 1) {
+ refused += 1;
+ blocked = this;
+ this.autoplay = false;
+ this.pause();
+ this.addEventListener('play', holdPaused);
+ return Promise.reject(new DOMException('autoplay refused', 'NotAllowedError'));
+ }
+ if (blocked === this) {
+ blocked = null;
+ this.removeEventListener('play', holdPaused);
+ }
+ return real.apply(this, arguments);
+ };
+};
+
+// meterBars is the meter beside a tile's name as it is drawn: whether it
+// is showing, and how many bars are lit.
+function meterBars(page, key) {
+ return page.evaluate((k) => {
+ const m = document.querySelector('#tiles figure[data-tile="' + k + '"] .calls-meter');
+ if (!m) return null;
+ return { shown: !m.hidden && m.getClientRects().length > 0, bars: Number(m.dataset.level) || 0 };
+ }, key);
+}
+
function mediaLive(page) {
return page.evaluate(() => ((document.getElementById('media-live') || {}).textContent || '').trim());
}
@@ -1227,6 +1282,88 @@ const run = {
await ctx.close();
},
+ // Spec §15.20: the holding screen's audio test. The fake microphone
+ // beeps, so the microphone test says it works; the test sound plays;
+ // and the playback test records, plays back and throws the recording
+ // away — with no request leaving the page while it does, which is the
+ // promise the section makes in words.
+ async audiotest() {
+ const ctx = await newContext('member A', { cookie: cookieA });
+ const page = await ctx.newPage();
+ const requests = [];
+ page.on('request', (r) => {
+ const body = r.postDataBuffer();
+ requests.push({ method: r.method(), path: where({ url: () => r.url() }), body: body ? body.length : 0 });
+ });
+ await page.goto(origin + '/m/' + sqid + '/join', { waitUntil: 'domcontentloaded' });
+ const ready = await until(async () => await page.locator('#join').isEnabled(), 15000);
+ check(ready, 'the holding screen is ready — status: ' + (await page.locator('#status').textContent()));
+ check(await page.locator('#audio-test').isVisible(), 'the audio test is offered');
+ await page.click('#audio-test summary');
+ const test = () => page.evaluate(() => window.meetDevices.audioTest());
+ const sent = requests.length;
+
+ await page.click('#test-mic');
+ const working = await until(async () => {
+ const t = await test();
+ return t.mic.verdict === 'working' ? t : false;
+ }, 10000);
+ check(working, 'the microphone test hears the microphone (' + JSON.stringify(working || (await test())) + ')');
+ const micText = ((await page.locator('#mic-result').textContent()) || '').trim();
+ check(micText === 'Your microphone is working.', 'and says so in words (' + micText + ')');
+ const micMeter = await until(async () => {
+ const v = await page.locator('#mic-level').evaluate((m) => m.value);
+ return v > 0 ? v : false;
+ }, 5000);
+ check(micMeter, 'its meter moves (' + micMeter + ')');
+
+ await page.click('#test-speaker');
+ const played = await until(async () => {
+ const t = await test();
+ return t.speaker.played && t.speaker.peak > 0 ? t : false;
+ }, 10000);
+ check(played, 'the test sound plays, and its meter moves (' + JSON.stringify(played || (await test())) + ')');
+ const speakerText = await until(async () => {
+ const t = ((await page.locator('#speaker-result').textContent()) || '').trim();
+ return t.startsWith('Played a test sound through') ? t : false;
+ }, 5000);
+ check(speakerText, 'and says where it played (' + speakerText + ')');
+ check(await test().then((t) => t.running === '' && t.mic.verdict === 'working'),
+ 'starting one test stopped the other');
+
+ await page.click('#test-loopback');
+ const recording = await until(async () => ((await test()).loopback.phase === 'recording' ? true : false), 5000);
+ check(recording, 'the playback test records');
+ const back = await until(async () => {
+ const t = await test();
+ return t.loopback.phase === 'played' || t.loopback.phase === 'failed' ? t : false;
+ }, 20000);
+ check(back && back.loopback.phase === 'played' && back.loopback.bytes > 0 && back.loopback.peak > 0,
+ 'records, and plays it back (' + JSON.stringify(back || (await test())) + ')');
+ const loopText = ((await page.locator('#loopback-result').textContent()) || '').trim();
+ check(loopText === 'That is how you sound to the others. The recording has been thrown away.',
+ 'and says so (' + loopText + ')');
+ const released = await page.evaluate(() => {
+ const a = document.getElementById('test-audio');
+ return !a.srcObject;
+ });
+ check(released, 'the player lets go of the recording');
+
+ const during = requests.slice(sent);
+ const carried = during.filter((r) => r.method !== 'GET' || r.body > 0);
+ check(carried.length === 0, 'nothing left the page while the tests ran (' + JSON.stringify(carried) + ')');
+
+ // And Join still works afterwards.
+ await page.click('#join');
+ const joined = await until(async () => page.url().endsWith('/m/' + sqid), 10000);
+ check(joined, 'Join still goes to the room (' + where(page) + ')');
+ await page.waitForFunction(() => !!window.meet, null, { timeout: 20000 }).catch(() => {});
+ await leaveRoom(page);
+
+ check(errorsFor('member A') === '', 'no console errors (' + errorsFor('member A') + ')');
+ await ctx.close();
+ },
+
// The whole media path in one assertion each way: getUserMedia, join,
// roster, perfect negotiation, trickle ICE, a decoded frame.
async call() {
@@ -1332,6 +1469,131 @@ const run = {
await ctxB.close();
},
+ // Spec §15.20: the meters and the five-second rule, in a real call.
+ // Chromium's fake microphone beeps about once a second, so a working
+ // call is one where every meter moves and nobody is ever called
+ // silent. Then the far side mutes, which is the Muted badge and not a
+ // silence; and then it goes silent *without* muting — its microphone
+ // track disabled under it, the shape of a microphone that hears
+ // nothing — which is "No sound from …" here and "… isn't getting any
+ // sound from you" there, over the report.
+ async meters() {
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxB = await newContext('member B', { cookie: cookieB });
+ const pageA = await openRoom(ctxA);
+ const pageB = await openRoom(ctxB);
+
+ const pair = await assertPair(pageA, pageB, 'member A', 'member B');
+ if (!pair) return;
+ const a = pair.a.peer;
+ const b = pair.b.peer;
+ const nameA = await selfName(pageA);
+ const nameB = await selfName(pageB);
+ const hearingOf = async (page, key) => ((await state(page)).hearing || {})[key] || null;
+
+ const heard = await until(async () => {
+ const h = await hearingOf(pageB, a);
+ return h && h.peak > 0 && h.shown === 'meter' ? h : false;
+ }, 15000);
+ check(heard, 'B’s meter for A reads a level above zero (' + JSON.stringify(heard || (await hearingOf(pageB, a))) + ')');
+ const lit = await until(async () => {
+ const m = await meterBars(pageB, a);
+ return m && m.shown && m.bars > 0 ? m : false;
+ }, 10000);
+ check(lit, 'the meter beside A’s name on B lights at least one bar (' + JSON.stringify(lit || (await meterBars(pageB, a))) + ')');
+ const self = await until(async () => {
+ const h = await hearingOf(pageA, 'self');
+ return h && h.peak > 0 ? h : false;
+ }, 15000);
+ check(self, 'A’s own meter reads its microphone (' + JSON.stringify(self || (await hearingOf(pageA, 'self'))) + ')');
+ const selfLit = await until(async () => {
+ const m = await meterBars(pageA, 'self');
+ return m && m.shown && m.bars > 0 ? m : false;
+ }, 10000);
+ check(selfLit, 'and A’s own tile shows it (' + JSON.stringify(selfLit || (await meterBars(pageA, 'self'))) + ')');
+
+ // A beeping microphone is not a silent one, on either side, for
+ // longer than the rule's five seconds.
+ const falseAlarm = await stayed(pageB, 7000, (s) =>
+ s.hearing[a] && s.hearing[a].shown === 'meter' && !s.noSoundFrom.length && !s.hearing.self.text);
+ check(!falseAlarm, 'a working microphone is never called silent (' +
+ JSON.stringify(falseAlarm && { hearing: falseAlarm.hearing, noSoundFrom: falseAlarm.noSoundFrom }) + ')');
+
+ // Muted is the badge, not a meter and not a silence.
+ await pageA.click('#mute');
+ const muted = await until(async () => {
+ const h = await hearingOf(pageB, a);
+ const m = await tileMarks(pageB, a);
+ return h && h.shown === 'muted' && m && m.muted ? { h, m } : false;
+ }, 10000);
+ check(muted, 'B shows A as Muted once A mutes (' + JSON.stringify(muted || (await hearingOf(pageB, a))) + ')');
+ const meterGone = await meterBars(pageB, a);
+ check(meterGone && !meterGone.shown, 'the meter gives way to the badge (' + JSON.stringify(meterGone) + ')');
+ const mutedQuiet = await stayed(pageB, 6500, (s) =>
+ s.hearing[a] && s.hearing[a].shown === 'muted' && s.hearing[a].text === '' && !s.noSoundFrom.length);
+ check(!mutedQuiet, 'a muted peer is never "No sound from" (' + JSON.stringify(mutedQuiet && mutedQuiet.hearing[a]) + ')');
+ const selfMuted = await hearingOf(pageA, 'self');
+ check(selfMuted && selfMuted.shown === 'muted' && selfMuted.text === '',
+ 'and muted, A’s own tile warns of nothing (' + JSON.stringify(selfMuted) + ')');
+ await pageA.click('#mute');
+ const back = await until(async () => {
+ const h = await hearingOf(pageB, a);
+ return h && h.shown === 'meter' ? h : false;
+ }, 10000);
+ check(back, 'unmuting brings the meter back (' + JSON.stringify(back || (await hearingOf(pageB, a))) + ')');
+
+ // Silence without muting: the microphone track is disabled under the
+ // call, so packets keep flowing and carry nothing, and A's report
+ // still says unmuted — which is exactly a microphone that hears
+ // nothing, as far as anybody listening can tell.
+ await pageA.evaluate(() => {
+ for (const pc of window.__pcs || []) {
+ for (const sn of pc.getSenders()) if (sn.track && sn.track.kind === 'audio') sn.track.enabled = false;
+ }
+ });
+ const silent = await until(async () => {
+ const s = await state(pageB);
+ const h = s.hearing[a];
+ return h && h.shown === 'silent' && s.noSoundFrom.includes(a) ? { h, noSoundFrom: s.noSoundFrom } : false;
+ }, 15000);
+ check(silent, 'B calls A silent after five flat seconds (' + JSON.stringify(silent || (await hearingOf(pageB, a))) + ')');
+ check(silent && silent.h.text === 'No sound from ' + nameA,
+ 'in words, by name (' + (silent && silent.h.text) + ')');
+ const told = await until(async () => {
+ const h = await hearingOf(pageA, b);
+ return h && /isn.t getting any sound from you\./.test(h.notice) ? h : false;
+ }, 10000);
+ check(told, 'A’s tile for B says B is getting no sound from A (' + JSON.stringify(told || (await hearingOf(pageA, b))) + ')');
+ check(told && told.notice === nameB + ' isn\u2019t getting any sound from you.',
+ 'naming B (' + (told && told.notice) + ')');
+ const saidA = await mediaLive(pageA);
+ check(/isn.t getting any sound from you/.test(saidA), 'and A’s live region says so once (' + saidA + ')');
+
+ // Sound again ends it on both sides.
+ await pageA.evaluate(() => {
+ for (const pc of window.__pcs || []) {
+ for (const sn of pc.getSenders()) if (sn.track && sn.track.kind === 'audio') sn.track.enabled = true;
+ }
+ });
+ const cleared = await until(async () => {
+ const s = await state(pageB);
+ return s.hearing[a] && s.hearing[a].shown === 'meter' && !s.noSoundFrom.length ? s.hearing[a] : false;
+ }, 10000);
+ check(cleared, 'sound again clears "No sound" on B (' + JSON.stringify(cleared || (await hearingOf(pageB, a))) + ')');
+ const untold = await until(async () => {
+ const h = await hearingOf(pageA, b);
+ return h && h.notice === '' ? h : false;
+ }, 10000);
+ check(untold, 'and the notice on A (' + JSON.stringify(untold || (await hearingOf(pageA, b))) + ')');
+
+ check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
+ check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
+ await leaveRoom(pageA);
+ await leaveRoom(pageB);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
// Space toggles mute (the keyboard path to the same toggle as the
// button, so the peer's badge follows over the same `media` report).
// Held Space is one toggle, not a flicker; Space on the focused Mute
@@ -3840,36 +4102,7 @@ const run = {
// seen to work.
async playfallback() {
const ctxA = await newContext('member A', { cookie: cookieA });
- await ctxA.addInitScript(() => {
- const real = HTMLMediaElement.prototype.play;
- let refused = 0;
- let blocked = null;
- // A rejected promise on its own is not what a blocking autoplay
- // policy does: the element also stays paused. Rejecting and
- // letting the element play anyway (its own `autoplay` attribute
- // does that, and the launch flag permits it) left every "the
- // gesture started the video" assertion true before the gesture
- // as well, which is the hole Codex S2-9 is about. So the refusal
- // pauses the element and keeps it paused until the gesture.
- const holdPaused = (e) => {
- if (blocked === e.target) e.target.pause();
- };
- HTMLMediaElement.prototype.play = function play() {
- if (!this.muted && refused < 1) {
- refused += 1;
- blocked = this;
- this.autoplay = false;
- this.pause();
- this.addEventListener('play', holdPaused);
- return Promise.reject(new DOMException('autoplay refused', 'NotAllowedError'));
- }
- if (blocked === this) {
- blocked = null;
- this.removeEventListener('play', holdPaused);
- }
- return real.apply(this, arguments);
- };
- });
+ await ctxA.addInitScript(refuseFirstPlay);
const ctxB = await newContext('member B', { cookie: cookieB });
const pageA = await openRoom(ctxA);
const pageB = await openRoom(ctxB);
@@ -3916,6 +4149,136 @@ const run = {
await ctxB.close();
},
+ // Spec §15.20, the autoplay half, in a Chromium launched under its
+ // real desktop policy rather than the gate's permissive one, and never
+ // given a gesture. First what the incident of 2026-09-25 settled:
+ // Chromium plays a MediaStream without a gesture whatever its policy,
+ // so the call just plays and no banner appears — if that ever changes,
+ // this is where it shows. Then a refusal, forced the way playfallback
+ // forces one: the banner, the report reaching the other side as a
+ // notice on this tab's tile, and a click anywhere putting it all back.
+ async autoplay() {
+ const strict = await chromium.launch({
+ args: LAUNCH_ARGS.filter((a) => !a.startsWith('--autoplay-policy=')).concat(
+ '--autoplay-policy=document-user-activation-required'),
+ });
+ try {
+ const ctxA = await newContext('member A', { cookie: cookieA, via: strict });
+ const ctxB = await newContext('member B', { cookie: cookieB, via: strict });
+ // No evaluate may touch A's page until its remote tile has played:
+ // Playwright runs page.evaluate as a user gesture, so the gate's
+ // own polling would be the activation this is proving is not
+ // needed. So A's page reports for itself, through the console, the
+ // moment an unmuted element starts playing and whether it had been
+ // activated by then — and A is opened with a bare goto, not
+ // openRoom, whose waitForFunction is an evaluate too.
+ await ctxA.addInitScript(() => {
+ document.addEventListener('playing', (e) => {
+ const el = e.target;
+ if (!el || el.muted || el.dataset.reported) return;
+ el.dataset.reported = '1';
+ const active = navigator.userActivation ? navigator.userActivation.hasBeenActive : 'unknown';
+ console.log('calls-autoplay: playing active=' + active);
+ }, true);
+ });
+ let pageA = await ctxA.newPage();
+ const firstPlay = new Promise((resolve) => {
+ pageA.on('console', (m) => {
+ if (m.text().startsWith('calls-autoplay:')) resolve(m.text());
+ });
+ });
+ await pageA.goto(origin + '/m/' + sqid, { waitUntil: 'domcontentloaded' });
+ const pageB = await openRoom(ctxB);
+ const played = await Promise.race([firstPlay, sleep(30000).then(() => null)]);
+ check(played, 'with no gesture, A’s remote tile starts playing under the real policy (' + played + ')');
+ check(played === 'calls-autoplay: playing active=false',
+ 'and A’s page had really had no gesture when it did (' + played + ')');
+ const pair = await assertPair(pageA, pageB, 'member A', 'member B');
+ if (!pair) return;
+ const nameA = await selfName(pageA);
+
+ const plays = await until(async () => {
+ const t = await remoteTile(pageA);
+ return t && t.hasStream && !t.paused && t.time > 0.5 ? t : false;
+ }, 15000);
+ check(plays, 'and it goes on playing (' + JSON.stringify(plays || (await remoteTile(pageA))) + ')');
+ const calm = await stayed(pageA, 3000, (s) => !s.soundBanner && !s.audioBlocked);
+ check(!calm, 'no banner and nothing blocked (' + JSON.stringify(calm && { banner: calm.soundBanner, blocked: calm.audioBlocked }) + ')');
+ check(!(await pageA.locator('#sound-banner').isVisible()), 'the banner is not on the page');
+
+ // Now a browser that refuses. A comes back in a fresh page whose
+ // first unmuted play() is refused.
+ await leaveRoom(pageA);
+ await pageA.close();
+ await ctxA.addInitScript(refuseFirstPlay);
+ pageA = await openRoom(ctxA);
+ const again = await waitConnected(pageA);
+ check(again, 'A is back in the call — ' + (await diagnose(pageA, 'member A')));
+ if (!again) return;
+ const a = again.peer;
+ const b = pair.b.peer;
+
+ const banner = await until(async () => {
+ const s = await state(pageA);
+ return s.soundBanner && s.audioBlocked ? s : false;
+ }, 20000);
+ check(banner, 'the refusal puts the banner up and marks A blocked — ' + (await diagnose(pageA, 'member A')));
+ const bannerText = ((await pageA.locator('#sound-banner').textContent()) || '').trim();
+ check(await pageA.locator('#sound-banner').isVisible() && bannerText.startsWith('Click anywhere to turn on sound'),
+ 'the banner says what to do, in words (' + bannerText + ')');
+ check(await pageA.locator('#sound-banner button').isVisible(), 'with a button a keyboard can reach');
+ const refusedTile = await until(async () => {
+ const s = await state(pageA);
+ return s.hearing[b] && s.hearing[b].shown === 'blocked' ? s.hearing[b] : false;
+ }, 10000);
+ check(refusedTile && refusedTile.text === 'Sound blocked — click to hear',
+ 'B’s tile on A says the sound is blocked (' + JSON.stringify(refusedTile) + ')');
+ const still = await remoteTile(pageA);
+ check(still && still.paused, 'and the refused video is paused (' + JSON.stringify(still) + ')');
+
+ const notice = await until(async () => {
+ const s = await state(pageB);
+ const h = s.hearing[a];
+ return h && /blocking sound/.test(h.notice) && s.remoteMedia[a] && s.remoteMedia[a].audioBlocked ? h : false;
+ }, 15000);
+ check(notice, 'B hears about it: A’s tile on B carries the notice (' +
+ JSON.stringify(notice || ((await state(pageB)).hearing || {})[a]) + ')');
+ check(notice && notice.notice === nameA + '’s browser is blocking sound — they need to click to hear you.',
+ 'naming A (' + (notice && notice.notice) + ')');
+
+ // A click on nothing in particular: the page's own heading.
+ const heading = await pageA.locator('h1').first().boundingBox();
+ await pageA.mouse.click(heading.x + 4, heading.y + heading.height / 2);
+ const resumed = await until(async () => {
+ const s = await state(pageA);
+ const t = await remoteTile(pageA);
+ return !s.soundBanner && !s.audioBlocked && t && !t.paused ? { t, banner: s.soundBanner } : false;
+ }, 15000);
+ check(resumed, 'a click anywhere plays the tile and takes the banner down — ' + (await diagnose(pageA, 'member A')));
+ if (resumed) {
+ const moving = await until(async () => {
+ const t = await remoteTile(pageA);
+ return t && !t.paused && t.time > resumed.t.time ? t : false;
+ }, 10000);
+ check(moving, 'and the video is really playing (' + JSON.stringify(moving || (await remoteTile(pageA))) + ')');
+ }
+ const cleared = await until(async () => {
+ const s = await state(pageB);
+ return s.hearing[a] && s.hearing[a].notice === '' && !(s.remoteMedia[a] || {}).audioBlocked ? s.hearing[a] : false;
+ }, 15000);
+ check(cleared, 'and B’s notice clears (' + JSON.stringify(cleared || ((await state(pageB)).hearing || {})[a]) + ')');
+
+ check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
+ check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
+ await leaveRoom(pageA);
+ await leaveRoom(pageB);
+ await ctxA.close();
+ await ctxB.close();
+ } finally {
+ await strict.close();
+ }
+ },
+
// The floor: both pages load with JavaScript switched off, say so in
// plain words, and show nothing that pretends to be a call.
async layout() {