oficina / meet Public

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
Paul Campbell pushed by paul@keymail.dev 53b9e9722e0c036b985ed4bc56c830c23df33258 parent 11676c5
2 files changed, +413 −2
  • internal/meettest/browser_test.go +2 −0
  • test/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() {