| 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() { |