browser gate: chat to the admitted only, the share round trip
Two scenarios, both passing, neither skipped.
share (1.4 s): two members connected with frames both ways; the
higher-id context clicks Share screen; the lower is told whose screen
is coming, draws a second tile captioned "<name>'s screen", decodes
real frames under "<peer>#share", and its camera count keeps rising
across the renegotiation; the button reads aria-pressed true with the
word On. Then the sharer clicks again: the tile and state().shares go
within five seconds, the received track fires its own `ended` into a
listener armed before the click, the camera goes on rising, and the
button is back to false and Off. Chromium gains
--auto-accept-this-tab-capture: --use-fake-ui-for-media-stream covers
getUserMedia only. The fake monitor it picks really produces frames,
so this asserts on pixels and not only on a message.
chat (4.0 s): two members and a guest that has signed in through the
emailed link and is knocking. A types into #chat-text and presses
Enter: B's log gains the line under A's name within two seconds and
A's own log shows it as A's; B's received count is polled first, so
the two seconds the guest is then watched for are a window in which
the message demonstrably existed. The guest, still waiting, has no
panel, an empty log and a received count of zero throughout. One line
typed is one POST, and its body carries no to, seq or gen. Then A
admits the guest, the guest's roster arrives, A sends again and the
guest gets that line — and nothing that was said before it was let
in. Nothing anybody typed reaches any console.
The whole gate is 29 scenarios in 423 s. Four full runs were made:
three lost one arbitrary scenario each to this environment's media
stall (senddeadline, share, icefail, each with a connected transport,
live tracks and frames stuck at {}), and every one of them passed
alone and in the fourth run, which was 29/29.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
2 files changed,
+413
−2
internal/meettest/browser_test.go+2 −0test/browser/call.mjs+411 −2
CI passed — run details
diff --git a/internal/meettest/browser_test.go b/internal/meettest/browser_test.go| index 43dff5e..957ec71 100644 |
| --- a/internal/meettest/browser_test.go |
| +++ b/internal/meettest/browser_test.go |
| @@ -75,12 +75,14 @@ const ( |
| var scenarios = []string{ |
| "devices", |
| "call", |
| + "share", |
| "renewal", |
| "ice", |
| "icerenewal", |
| "icefail", |
| "icestuck", |
| "guest", |
| + "chat", |
| "removed", |
| "allowlist", |
| "departure", |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index d5c1558..4a31999 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -25,11 +25,12 @@ |
| // gives every context a camera and a microphone that always work; |
| // --use-fake-ui-for-media-stream answers the permission prompt; |
| // --autoplay-policy=no-user-gesture-required lets a remote <video> play |
| -// without a click; and --disable-features=WebRtcHideLocalIpsWithMdns |
| +// without a click; --disable-features=WebRtcHideLocalIpsWithMdns |
| // turns off mDNS candidate obfuscation, which in a container resolves to |
| // nothing at all — with no ICE servers in this slice (spec §5.5), host |
| // candidates are the only ones there are, and an obfuscated one connects |
| -// to nobody. |
| +// to nobody; and --auto-accept-this-tab-capture answers getDisplayMedia |
| +// with no picker, which the fake-ui flag does not cover (§5.4). |
| import { writeSync } from 'node:fs'; |
| import { createInterface } from 'node:readline'; |
| import { chromium } from 'playwright'; |
| @@ -65,6 +66,15 @@ const LAUNCH_ARGS = [ |
| '--use-fake-ui-for-media-stream', |
| '--autoplay-policy=no-user-gesture-required', |
| '--disable-features=WebRtcHideLocalIpsWithMdns', |
| + // §5.4's half of the permission story. --use-fake-ui-for-media-stream |
| + // answers getUserMedia and nothing else; getDisplayMedia has its own |
| + // picker, and in headless Chromium this is what accepts it without |
| + // one. It picks a fake 1280x720 monitor that really produces frames — |
| + // watched here at ~20/s over a loopback peer connection — so the |
| + // share scenario asserts on pixels and not only on a message. The |
| + // older --auto-select-desktop-capture-source needs a real display and |
| + // is not reached for. |
| + '--auto-accept-this-tab-capture', |
| // The relay host in the ICE scenarios' contract does not exist, and |
| // this is what makes it not exist *here*, rather than out on the |
| // internet: the name never leaves the machine, and Chromium fails it |
| @@ -244,6 +254,14 @@ function state(page) { |
| // page.evaluate as they are and are handed straight out. |
| ice: s.ice, |
| renewals: s.renewals, |
| + // §6 and §5.4. `chat` is two counts, `shares` a list of peer ids |
| + // and `sharing` a boolean, so all three survive page.evaluate as |
| + // they are. `frames` above is keyed by TILE rather than by peer |
| + // now, so a peer sharing a screen has a second entry under |
| + // `<peer>#share`. |
| + chat: s.chat, |
| + sharing: s.sharing, |
| + shares: s.shares, |
| }; |
| }); |
| } |
| @@ -267,6 +285,9 @@ async function diagnose(page, label) { |
| return { |
| name: (f.querySelector('figcaption span') || {}).textContent || '', |
| note: (f.querySelector('.meet-tile-note') || {}).textContent || '', |
| + // §5.4: a screen and a face are the same <figure> with |
| + // different content, and only the class says which. |
| + share: f.classList.contains('meet-tile-share'), |
| hasStream: !!(v && v.srcObject), |
| readyState: v ? v.readyState : null, |
| videoWidth: v ? v.videoWidth : null, |
| @@ -329,6 +350,13 @@ async function diagnose(page, label) { |
| // afterwards, which is the next question and the one |
| // nothing else answers. |
| candidates: s.candidates, |
| + // §6 and §5.4: lines in and out, and whose screens this |
| + // page is showing. A share tile that never arrived and one |
| + // that arrived and stopped read the same in `tiles` below; |
| + // these two say which it was. |
| + chat: s.chat, |
| + sharing: s.sharing, |
| + shares: s.shares, |
| } |
| : null, |
| tiles, |
| @@ -763,6 +791,69 @@ async function queueNames(page) { |
| Array.from(document.querySelectorAll('#knocks li span')).map((s) => s.textContent.trim())); |
| } |
| +// ── the chat panel and the share tile ──────────────────────────────── |
| + |
| +// chatLog is the panel's log as a reader has it: who each line is from, |
| +// what it says, and whether the panel drew it as this tab's own. Read |
| +// through the class names chat.js builds rather than through the ids, so |
| +// the panel keeps the freedom its own report claims — a host may rename |
| +// every `chat-*` id, and the classes are what the stylesheet and this |
| +// gate both hang on. |
| +function chatLog(page) { |
| + return page.evaluate(() => |
| + Array.from(document.querySelectorAll('#chat-log li')).map((li) => ({ |
| + name: ((li.querySelector('.chat-name') || {}).textContent || '').trim(), |
| + text: ((li.querySelector('.chat-text') || {}).textContent || '').trim(), |
| + self: li.classList.contains('chat-line-self'), |
| + }))); |
| +} |
| + |
| +// selfName is the server's word for the person in this tab, read off |
| +// the room element the page was rendered with. Not off their own tile: |
| +// that caption is this name with " (you)" appended, and what the other |
| +// contexts see — the name on an arriving chat line, the name in |
| +// "<name>’s screen" — is the name without it. Reading the dataset is |
| +// reading exactly what room.js reads. |
| +function selfName(page) { |
| + return page.evaluate(() => { |
| + const root = document.getElementById('room'); |
| + return root ? (root.dataset.selfName || '') : ''; |
| + }); |
| +} |
| + |
| +// shareTiles is every screen currently on this page: its caption and |
| +// whether the <video> under it has anything to play. A tile with a |
| +// caption and no stream is exactly what `share {on:true}` draws before |
| +// the media arrives, so the two are read together and never conflated. |
| +function shareTiles(page) { |
| + return page.evaluate(() => |
| + Array.from(document.querySelectorAll('#tiles figure.meet-tile-share')).map((f) => { |
| + const v = f.querySelector('video'); |
| + return { |
| + name: ((f.querySelector('figcaption span') || {}).textContent || '').trim(), |
| + hasStream: !!(v && v.srcObject), |
| + width: v ? v.videoWidth : null, |
| + paused: v ? v.paused : null, |
| + }; |
| + })); |
| +} |
| + |
| +// shareControl is the Share button's three cues at once. Colour is not |
| +// one of them (AGENTS.md), so what is read here is the word and the |
| +// attribute a screen reader announces — never the outline. |
| +function shareControl(page) { |
| + return page.evaluate(() => { |
| + const b = document.getElementById('share'); |
| + if (!b) return null; |
| + const s = document.getElementById('share-state'); |
| + return { |
| + pressed: b.getAttribute('aria-pressed'), |
| + state: s ? (s.textContent || '').trim() : null, |
| + disabled: !!b.disabled, |
| + }; |
| + }); |
| +} |
| + |
| // ── the scenarios ──────────────────────────────────────────────────── |
| const run = { |
| @@ -825,6 +916,154 @@ const run = { |
| await ctxB.close(); |
| }, |
| + // §5.4 and §13's slice-4 sentence: "the higher-id context shares its |
| + // screen and the lower receives a second video track, then stops, the |
| + // received track ends, the share tile is removed and the camera track |
| + // keeps flowing." |
| + // |
| + // Every clause of that is an assertion here, and the camera is watched |
| + // across both halves rather than only at the end: a share that |
| + // renegotiated the call into silence and then gave it back would pass |
| + // a check made once, at rest. |
| + async share() { |
| + 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) { |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + return; |
| + } |
| + |
| + // §5.1's own tie-break, read the same way room.js reads it. §13 |
| + // names the higher id as the one that shares, and it is the |
| + // interesting half: the higher id is the impolite peer, so the |
| + // offer its new transceiver causes is the one that does not roll |
| + // back on a collision. |
| + const aHigher = String(pair.a.peer) > String(pair.b.peer); |
| + const sharer = aHigher |
| + ? { page: pageA, label: 'member A', peer: pair.a.peer } |
| + : { page: pageB, label: 'member B', peer: pair.b.peer }; |
| + const viewer = aHigher |
| + ? { page: pageB, label: 'member B', peer: pair.b.peer } |
| + : { page: pageA, label: 'member A', peer: pair.a.peer }; |
| + line('the higher-id context is ' + sharer.label); |
| + |
| + // The caption is built from the roster's name and the typographic |
| + // apostrophe room.js writes, not from a name typed here: the whole |
| + // point of the `share` message is that no label crosses the wire. |
| + const name = await selfName(sharer.page); |
| + check(name !== '', 'the sharer has a name for its screen to be captioned with (' + name + ')'); |
| + const caption = name + '’s screen'; |
| + const key = sharer.peer + '#share'; |
| + |
| + const resting = await shareControl(sharer.page); |
| + check(resting && resting.pressed === 'false' && resting.state === 'Off' && !resting.disabled, |
| + 'the Share button rests off, in a word and in aria-pressed (' + JSON.stringify(resting) + ')'); |
| + check((await shareTiles(viewer.page)).length === 0, |
| + 'nobody is showing a screen before anybody shares one'); |
| + |
| + const cameraBefore = (await state(viewer.page)).frames[sharer.peer] || 0; |
| + await sharer.page.click('#share'); |
| + |
| + // The message first: `shares` follows the tile, and the tile follows |
| + // the `share {on:true}` — which is what lets a name appear before |
| + // the media and is why these are three assertions and not one. |
| + const announced = await until(async () => { |
| + const s = await state(viewer.page); |
| + return s.shares.includes(sharer.peer) ? s : false; |
| + }, 5000); |
| + check(announced, 'the other context is told whose screen is coming within five seconds — ' + |
| + (await diagnose(viewer.page, viewer.label))); |
| + |
| + const captioned = await until(async () => { |
| + const tiles = await shareTiles(viewer.page); |
| + return tiles.length === 1 && tiles[0].name === caption ? tiles : false; |
| + }, 5000); |
| + check(captioned, 'a second tile says whose screen it is (' + |
| + JSON.stringify(await shareTiles(viewer.page)) + ', want ' + JSON.stringify(caption) + ')'); |
| + |
| + const pixels = await until(async () => { |
| + const s = await state(viewer.page); |
| + return (s.frames[key] || 0) > 0 ? s : false; |
| + }, 5000); |
| + check(pixels, 'the screen is decoding real frames and not only labelled — ' + |
| + (await diagnose(viewer.page, viewer.label))); |
| + |
| + const cameraOn = await until(async () => { |
| + const s = await state(viewer.page); |
| + return (s.frames[sharer.peer] || 0) > cameraBefore ? s : false; |
| + }, 10000); |
| + check(cameraOn, 'the camera kept flowing across the renegotiation (' + cameraBefore + ' -> ' + |
| + ((await state(viewer.page)).frames[sharer.peer] || 0) + ')'); |
| + |
| + const pressed = await shareControl(sharer.page); |
| + check(pressed && pressed.pressed === 'true' && pressed.state === 'On', |
| + 'the Share button says it is on, in both cues (' + JSON.stringify(pressed) + ')'); |
| + check((await state(sharer.page)).sharing === true, 'the sharing tab says it is sharing'); |
| + check((await shareTiles(sharer.page)).length === 0, |
| + 'the sharer is not shown its own screen back (' + JSON.stringify(await shareTiles(sharer.page)) + ')'); |
| + |
| + // The received track's own ending, armed before anything stops. |
| + // `ended` fires once and never again, so a listener installed after |
| + // the click would be an assertion about a moment already gone — and |
| + // the track object is kept on the page too, because the tile that |
| + // was holding it is about to be removed and the failure message |
| + // wants its readyState. |
| + const armed = await viewer.page.evaluate(() => { |
| + const v = document.querySelector('#tiles figure.meet-tile-share video'); |
| + const track = v && v.srcObject ? v.srcObject.getVideoTracks()[0] : null; |
| + if (!track) return null; |
| + window.__shareEnded = []; |
| + window.__shareTrack = track; |
| + track.addEventListener('ended', () => window.__shareEnded.push(track.id)); |
| + return { id: track.id, readyState: track.readyState }; |
| + }); |
| + check(armed && armed.readyState === 'live', |
| + 'the received screen track is live, and watched for its ending (' + JSON.stringify(armed) + ')'); |
| + |
| + const cameraMid = (await state(viewer.page)).frames[sharer.peer] || 0; |
| + await sharer.page.click('#share'); |
| + |
| + const removed = await until(async () => { |
| + const s = await state(viewer.page); |
| + if (s.shares.length !== 0) return false; |
| + return (await shareTiles(viewer.page)).length === 0 ? s : false; |
| + }, 5000); |
| + check(removed, 'the screen tile and the shared state are gone within five seconds — ' + |
| + (await diagnose(viewer.page, viewer.label))); |
| + |
| + const ended = await until(() => |
| + viewer.page.evaluate(() => ((window.__shareEnded || []).length > 0)), 5000); |
| + check(ended, 'the received track ended rather than merely stopping being drawn (readyState ' + |
| + (await viewer.page.evaluate(() => (window.__shareTrack || {}).readyState || '(no track)')) + ')'); |
| + |
| + const cameraAfter = await until(async () => { |
| + const s = await state(viewer.page); |
| + return (s.frames[sharer.peer] || 0) > cameraMid ? s : false; |
| + }, 10000); |
| + check(cameraAfter, 'the camera is still flowing after the share stopped (' + cameraMid + ' -> ' + |
| + ((await state(viewer.page)).frames[sharer.peer] || 0) + ')'); |
| + |
| + const off = await until(async () => { |
| + const b = await shareControl(sharer.page); |
| + return b && b.pressed === 'false' && b.state === 'Off' ? b : false; |
| + }, 5000); |
| + check(off, 'the Share button is back off, in both cues (' + |
| + JSON.stringify(await shareControl(sharer.page)) + ')'); |
| + check((await state(sharer.page)).sharing === false, 'the sharing tab says it has stopped'); |
| + |
| + 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(); |
| + }, |
| + |
| // A stream lives five minutes and is then renewed by a resume. Six |
| // seconds here, through a header only development honours, so the |
| // renewal happens several times while the call is watched — and six |
| @@ -1405,6 +1644,176 @@ const run = { |
| await ctxG.close(); |
| }, |
| + // §6 and §13's other slice-4 sentence: "chat arrives at the admitted |
| + // context and not at a waiting one." |
| + // |
| + // The guest is what makes that a claim rather than a description. It |
| + // is signed in, it has a participation, it is knocking — everything |
| + // except admitted — so a fan-out that reached everybody with a stream |
| + // would reach it. The window it is watched for is opened only after |
| + // the *member* has the line, so "silence" here means a message that |
| + // demonstrably arrived somewhere and demonstrably did not arrive here. |
| + // Then it is let in, and the very next line has to reach it. |
| + async chat() { |
| + const ctxA = await newContext('member A', { cookie: cookieA }); |
| + const ctxB = await newContext('member B', { cookie: cookieB }); |
| + const ctxG = await newContext('guest'); |
| + |
| + // Every chat POST A makes, kept whole. §4.3's wire shape is a |
| + // negative claim — no `to`, no `seq`, no `gen` — and the body A |
| + // actually sent is the only place a browser can prove it. The count |
| + // matters as much as the shape: one line typed is one POST however |
| + // many people hear it, which is the whole difference between a |
| + // broadcast and a loop over the roster. |
| + const chatPosts = []; |
| + await ctxA.route('**/signal', async (route) => { |
| + try { |
| + const msg = JSON.parse(route.request().postData() || ''); |
| + if (msg && msg.type === 'chat') chatPosts.push(msg); |
| + } catch { |
| + // No body, or not JSON: not a chat. |
| + } |
| + await route.continue(); |
| + }); |
| + |
| + const pageA = await openRoom(ctxA); |
| + const pageB = await openRoom(ctxB); |
| + const pair = await assertPair(pageA, pageB, 'member A', 'member B'); |
| + if (!pair) { |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + await ctxG.close(); |
| + return; |
| + } |
| + |
| + check(await until(async () => !(await pageA.locator('#chat').isHidden()), 10000), |
| + "an admitted member's chat panel is on the page — " + (await diagnose(pageA, 'member A'))); |
| + check(await pageA.locator('#chat-text').isEditable(), "A's composer is live"); |
| + check(await pageB.locator('#chat-send').isEnabled(), "B's Send button is live"); |
| + |
| + // The third context: signed in through the emailed link, knocking, |
| + // not admitted. |
| + await signInAsGuest(ctxG); |
| + const pageG = await openRoom(ctxG); |
| + check(await until(async () => ((await state(pageG)).peer ? true : false), 20000), |
| + 'the guest got a peer id and knocked — ' + (await diagnose(pageG, 'guest'))); |
| + check(await waitStatus(pageG, 'Waiting for someone to let you in.', 10000), |
| + 'the guest is told it is waiting — status: ' + (await statusText(pageG))); |
| + check((await state(pageG)).admitted === false, 'the guest is waiting, not admitted'); |
| + check(await pageG.locator('#chat').isHidden(), |
| + 'a guest that has only knocked is shown no chat panel'); |
| + |
| + const nameA = await selfName(pageA); |
| + check(nameA !== '', "A's own tile names it (" + nameA + ')'); |
| + |
| + const HELLO = 'hello from A'; |
| + await pageA.fill('#chat-text', HELLO); |
| + await pageA.press('#chat-text', 'Enter'); |
| + |
| + const heard = await until(async () => { |
| + const log = await chatLog(pageB); |
| + return log.find((l) => l.text === HELLO) || false; |
| + }, 2000); |
| + check(heard, "B's log gained the line within two seconds (" + |
| + JSON.stringify(await chatLog(pageB)) + ')'); |
| + if (heard) { |
| + check(heard.name === nameA, |
| + "the line B sees carries A's name (" + JSON.stringify(heard.name) + ' vs ' + JSON.stringify(nameA) + ')'); |
| + check(heard.self === false, "the line B sees is not drawn as B's own"); |
| + } |
| + |
| + const echo = (await chatLog(pageA)).find((l) => l.text === HELLO); |
| + check(echo, "A's own log shows what A said (" + JSON.stringify(await chatLog(pageA)) + ')'); |
| + if (echo) check(echo.self === true, "A's echo is drawn as A's own line"); |
| + |
| + // The member side is polled first, deliberately: the guest's |
| + // two-second window has to be a window in which the message |
| + // demonstrably existed, or it is a test of nothing having happened |
| + // yet. |
| + const counted = await until(async () => { |
| + const s = await state(pageB); |
| + return s.chat.received >= 1 ? s : false; |
| + }, 5000); |
| + check(counted, 'B counted the line it put in its log (' + |
| + JSON.stringify((await state(pageB)).chat) + ')'); |
| + check((await state(pageA)).chat.sent === 1, |
| + 'A counted exactly the one line the hub accepted (' + |
| + JSON.stringify((await state(pageA)).chat) + ')'); |
| + |
| + const silent = await stayed(pageG, 2000, (s) => s.chat.received === 0 && s.admitted === false); |
| + check(silent === null, |
| + 'the waiting guest was sent no chat at all — ' + JSON.stringify(silent)); |
| + check(await pageG.locator('#chat').isHidden(), |
| + "the waiting guest's chat panel is still not there to put anything in"); |
| + check((await chatLog(pageG)).length === 0, |
| + "the waiting guest's log is empty (" + JSON.stringify(await chatLog(pageG)) + ')'); |
| + |
| + check(chatPosts.length === 1, |
| + 'one line typed is one POST, however many people hear it (' + chatPosts.length + ')'); |
| + const sent = chatPosts[0] || {}; |
| + check(sent.type === 'chat' && sent.peer === pair.a.peer && |
| + sent.payload && sent.payload.text === HELLO, |
| + 'the POST carries this tab\'s own peer and the text (' + JSON.stringify(Object.keys(sent)) + ')'); |
| + check(!('to' in sent) && !('seq' in sent) && !('gen' in sent), |
| + 'a broadcast names no target and no ordering (' + JSON.stringify(Object.keys(sent)) + ')'); |
| + |
| + // And now the other half of the sentence. |
| + const queued = await until(async () => (await pageA.locator('#knocks li').count()) === 1, 20000); |
| + check(queued, "the member's queue shows the knock — " + (await diagnose(pageA, 'member A'))); |
| + if (queued) { |
| + check(await admitKnock(pageA, 'guest'), |
| + 'the queue names the guest to admit (' + JSON.stringify(await queueNames(pageA)) + ')'); |
| + } |
| + |
| + const admitted = await until(async () => { |
| + const s = await state(pageG); |
| + return s.admitted && s.rosters >= 1 ? s : false; |
| + }, 25000); |
| + check(admitted, 'the guest was admitted and has its roster — ' + (await diagnose(pageG, 'guest'))); |
| + check(await until(async () => !(await pageG.locator('#chat').isHidden()), 10000), |
| + "the admitted guest's chat panel is on the page — " + (await diagnose(pageG, 'guest'))); |
| + |
| + const AGAIN = 'and now the guest is here'; |
| + await pageA.fill('#chat-text', AGAIN); |
| + await pageA.press('#chat-text', 'Enter'); |
| + |
| + const guestHeard = await until(async () => { |
| + const log = await chatLog(pageG); |
| + return log.find((l) => l.text === AGAIN) || false; |
| + }, 5000); |
| + check(guestHeard, 'the admitted guest receives the next line (' + |
| + JSON.stringify(await chatLog(pageG)) + ')'); |
| + if (guestHeard) { |
| + check(guestHeard.name === nameA, |
| + "the guest's line carries A's name too (" + JSON.stringify(guestHeard.name) + ')'); |
| + } |
| + // §6 has no replay and no history: what was said before this guest |
| + // was let in is not caught up with, and nothing offers to. |
| + check((await chatLog(pageG)).every((l) => l.text !== HELLO), |
| + 'and is handed nothing that was said before it was let in (' + |
| + JSON.stringify(await chatLog(pageG)) + ')'); |
| + check(chatPosts.length === 2, |
| + 'the second line is one POST as well (' + chatPosts.length + ')'); |
| + check((await state(pageB)).chat.received >= 2, |
| + 'B heard both lines (' + JSON.stringify((await state(pageB)).chat) + ')'); |
| + |
| + // §6 and §5.2's promise together: what people say is not logged. |
| + for (const label of ['member A', 'member B', 'guest']) { |
| + const lines = linesFor(label); |
| + check(!lines.includes(HELLO) && !lines.includes(AGAIN), |
| + 'nothing anybody typed reached the console on ' + label); |
| + } |
| + 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(pageG); |
| + await leaveRoom(pageA); |
| + await leaveRoom(pageB); |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + await ctxG.close(); |
| + }, |
| + |
| // §3.5: removal is a member's own confirm page, in its own tab, and |
| // the person removed is told so in a sentence. |
| async removed() { |