oficina / meet Public

browser gate: the §13 resume scenarios and the terminal screens

Eight scenarios join the nine already there, and the restart and
takeover scenarios are tightened.

The four §13 gates the Task 9 review handed to this task:

- allowlist: a member dropped from the team mid-call, through a new
  `drop-member-b` control verb (TestHandle.SetAllowlist plus the tick,
  both of which only Go can reach), is terminal on `standing` while the
  other context loses the peer id and the tile
- departure: A's attaching stream is cut while B leaves; A resumes,
  reconciles from the roster alone and has closed the connection to the
  peer that left — the `gone` never reached it
- replay: B forces an ICE restart while A is cut; B's own signalingState
  returning to stable is the proof that the offer was replayed to A on
  its resume and answered
- gap: forty targeted messages past a ring of thirty-two while A is cut;
  A's own `reset` POST is counted, so "it received gap" is observed
  rather than assumed, and both sides come back stable and connected

And the terminal screens, none of which had ever run in a browser:
stall (a 503 the probe cannot see, ending on `unreachable` with both
ways out), signedout (403 from the signal endpoint), nowebrtc (no
RTCPeerConnection at all) and playfallback (an autoplay policy that
refuses a remote video, and the Play control that answers it).

Tightenings: restart now proves both contexts held no peer connections
between the old participation and the new one, sampled from inside each
page every five milliseconds because the gap is one round trip wide; the
takeover scenario locates its link by href, so a terminal screen may
carry more than one way out.

Deviation: the cut scenarios send X-Meet-Test-TTL at two seconds so a
cut bites promptly — a stream that has already attached is not routed
again. Only the attaching GET is aborted; the probe forms go through, so
the client walks the whole §4.5 ladder rather than facing a dead network.

Gate: 17 of 17 pass in 129 s with the sandbox off.
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 03227f237153079b162b7ce4f9cf771d1ecc33c9 parent ab13570
2 files changed, +414 −6
  • internal/meettest/browser_test.go +22 −4
  • test/browser/call.mjs +392 −2
diff --git a/internal/meettest/browser_test.go b/internal/meettest/browser_test.go
index d444262..6786d1c 100644
--- a/internal/meettest/browser_test.go
+++ b/internal/meettest/browser_test.go
@@ -62,11 +62,11 @@ const (
memberB = "paul@rslw.com"
)
-// scenarios are the nine passes, in the order they run. Each gets its
-// own node process and its own freshly restarted app, so a participation
-// one scenario left behind cannot 409 the next one's fresh stream.
+// scenarios are the passes, in the order they run. Each gets its own
+// node process and its own freshly restarted app, so a participation one
+// scenario left behind cannot 409 the next one's fresh stream.
//
-// All nine pass. A scenario that starts failing on the client rather
+// All of them pass. A scenario that starts failing on the client rather
// than on the gate keeps its place here and its assertions as written,
// and is skipped from inside its own subtest with t.Skip carrying the
// exact failure — never deleted, and never quietly loosened.
@@ -76,9 +76,17 @@ var scenarios = []string{
"renewal",
"guest",
"removed",
+ "allowlist",
+ "departure",
+ "replay",
+ "gap",
"restart",
"takeover",
"crosssite",
+ "stall",
+ "signedout",
+ "nowebrtc",
+ "playfallback",
"scriptless",
}
@@ -402,6 +410,16 @@ func (rig *browserRig) control(t *testing.T, verb string) string {
case verb == "restart":
rig.restart(t)
return "ok"
+ case verb == "drop-member-b":
+ // The team changes, and the tick that notices is run by hand:
+ // only this process holds the allowlist and the hub, and the
+ // browser has no way to reach either (spec §2.4).
+ rig.mu.Lock()
+ ctl := rig.ctl
+ rig.mu.Unlock()
+ ctl.SetAllowlist(memberA)
+ ctl.TickForTest()
+ return "ok"
case strings.HasPrefix(verb, "guest-link "):
address := strings.TrimSpace(strings.TrimPrefix(verb, "guest-link "))
// The browser has just posted the sign-in form; the link is in
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index d3386ee..0faadad 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -328,6 +328,44 @@ async function leaveRoom(page) {
}
}
+// cuttableStream is the instrument the three resume gates share: A's
+// *attaching* stream GET is aborted while `cut.on` is true, and only
+// that one. The probe forms go through untouched, because the whole
+// ladder §4.5 asks the client to walk — probe, read the status, then
+// attach — depends on the probe still answering; abort that too and the
+// scenario would be testing a dead network rather than a dead stream.
+//
+// The TTL header rides along for the same reason the renewal scenario
+// sends it: a stream that has already attached is not routed again, so
+// without a short life a cut would take effect five minutes from now.
+// Two seconds here rather than the renewal scenario's production-shaped
+// six, because these scenarios cut and restore within a few seconds and
+// want the cut to bite promptly.
+async function cuttableStream(ctx, ttl = '2s') {
+ const cut = { on: false, aborted: 0, attaches: 0 };
+ await ctx.route('**/stream*', async (route) => {
+ if (route.request().url().includes('probe=1')) {
+ await route.continue();
+ return;
+ }
+ if (cut.on) {
+ cut.aborted += 1;
+ await route.abort();
+ return;
+ }
+ cut.attaches += 1;
+ await route.continue({ headers: { ...route.request().headers(), 'x-meet-test-ttl': ttl } });
+ });
+ return cut;
+}
+
+// biteCut waits until the cut has actually taken an attaching GET away.
+// Setting the flag is not the cut: the stream that is already open runs
+// to its own TTL first.
+async function biteCut(cut, ms = 20000) {
+ return until(() => (cut.aborted > 0 ? cut.aborted : false), ms);
+}
+
// ── the guest ────────────────────────────────────────────────────────
// signInAsGuest is the whole outsider path from spec §3.2, driven in the
@@ -552,6 +590,207 @@ const run = {
await ctxG.close();
},
+ // §2.4 and §13: a member dropped from the team mid-call is dropped on
+ // the next tick, and the other context sees them go. Only Go can move
+ // the allowlist and run the tick, so the browser asks for both.
+ async allowlist() {
+ 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;
+ }
+
+ const answer = await control('drop-member-b');
+ check(answer === 'ok', 'B was taken off the team and the tick was run (' + answer + ')');
+
+ const dropped = await until(async () => {
+ const s = await state(pageB);
+ return s.terminal === 'standing' && s.admitted === false && s.peers.length === 0 ? s : false;
+ }, 25000);
+ check(dropped, 'the dropped member is terminal on `standing`, not admitted, and holds no connections — ' +
+ (await diagnose(pageB, 'member B')));
+ check(await waitStatus(pageB, 'You no longer have access to this meeting.', 10000),
+ 'the dropped member is told why in plain words — status: ' + (await statusText(pageB)));
+
+ const gone = await until(async () => {
+ const s = await state(pageA);
+ return !s.peers.includes(pair.b.peer) ? s : false;
+ }, 25000);
+ check(gone, 'the other context lost the dropped member from its peers — ' + (await diagnose(pageA, 'member A')));
+ const tiles = await until(async () => (await pageA.locator('#tiles figure').count()) === 1, 15000);
+ check(tiles, "the dropped member's tile is gone — " + (await diagnose(pageA, 'member A')));
+ check(!(await state(pageA)).terminal, 'the member still on the team is still in the call');
+
+ await leaveRoom(pageA);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
+ // §4.5 and §13: a context whose stream is cut while a peer leaves
+ // resumes, receives the roster, and has closed the connection to the
+ // peer that left. The `gone` event never reached it; the roster is
+ // what tells it.
+ async departure() {
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxB = await newContext('member B', { cookie: cookieB });
+ const cut = await cuttableStream(ctxA);
+ 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;
+ }
+
+ cut.on = true;
+ check(await biteCut(cut), "A's attaching stream is cut (" + cut.aborted + ' aborted)');
+
+ await leaveRoom(pageB);
+ cut.on = false;
+
+ const back = await until(async () => {
+ const s = await state(pageA);
+ return s.peer === pair.a.peer && !s.peers.includes(pair.b.peer) && !s.terminal ? s : false;
+ }, 45000);
+ check(back, 'A resumed its own participation and closed the connection to the peer that left — ' +
+ (await diagnose(pageA, 'member A')));
+ const tiles = await until(async () => (await pageA.locator('#tiles figure').count()) === 1, 15000);
+ check(tiles, 'A is left with its own tile only — ' + (await diagnose(pageA, 'member A')));
+
+ await leaveRoom(pageA);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
+ // §4.5 and §13: signalling whose SSE delivery is cut after the POST's
+ // 2xx is replayed on resume, and the pair connects. B forces an ICE
+ // restart while A cannot hear it; the offer sits in A's ring; A's
+ // resume replays it. B's own signalingState is the proof — it stays at
+ // have-local-offer until A answers, so B returning to `stable` means
+ // the replayed offer arrived and was answered.
+ async replay() {
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxB = await newContext('member B', { cookie: cookieB });
+ const cut = await cuttableStream(ctxA);
+ 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;
+ }
+
+ cut.on = true;
+ check(await biteCut(cut), "A's attaching stream is cut (" + cut.aborted + ' aborted)');
+
+ // The Retry control on B's tile for A is restartIce. The page only
+ // reveals it on a failed connection, so it is dispatched rather than
+ // clicked: this scenario is about the signalling, not the control.
+ await pageB.locator('#tiles figure:not(.meet-tile-self) figcaption button').first().dispatchEvent('click');
+ await sleep(5000);
+ cut.on = false;
+
+ const settled = await until(async () => {
+ const b = await state(pageB);
+ const a = await state(pageA);
+ return b.signaling[pair.a.peer] === 'stable' && b.connection[pair.a.peer] === 'connected' &&
+ a.peer === pair.a.peer && a.connection[pair.b.peer] === 'connected' ? { a, b } : false;
+ }, 45000);
+ check(settled, 'the offer B sent while A was cut was replayed on A’s resume and answered — ' +
+ (await diagnose(pageB, 'member B')) + ' ' + (await diagnose(pageA, 'member A')));
+
+ const before = (await state(pageA)).frames[pair.b.peer] || 0;
+ const rising = await until(async () => ((await state(pageA)).frames[pair.b.peer] || 0) > before, 25000);
+ check(rising, 'A is decoding video again after the replay (' + before + ' -> ' +
+ ((await state(pageA)).frames[pair.b.peer] || 0) + ')');
+
+ await leaveRoom(pageA);
+ await leaveRoom(pageB);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
+ // §4.5, §4.6 and §13: more targeted messages than the ring holds
+ // arrive while A is cut, so the replay cannot fill the hole. The hub
+ // answers `gap`, A asks for a reset, the hub bumps the generation and
+ // both sides rebuild.
+ async gap() {
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxB = await newContext('member B', { cookie: cookieB });
+ const cut = await cuttableStream(ctxA);
+ // A's own POSTs are counted, not touched: a `reset` from A is the
+ // observable proof that it received `gap` — nothing else in the room
+ // makes A ask for one.
+ let resets = 0;
+ await ctxA.route('**/signal', async (route) => {
+ const body = route.request().postData() || '';
+ if (body.includes('"type":"reset"')) resets += 1;
+ 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();
+ return;
+ }
+ check(resets === 0, 'nothing had reset this pair before the gap (' + resets + ')');
+
+ cut.on = true;
+ check(await biteCut(cut), "A's attaching stream is cut (" + cut.aborted + ' aborted)');
+
+ // Forty targeted messages, from B's own page so they carry B's
+ // session and peer, aimed at A. The ring holds thirty-two, and `seq`
+ // starts far above anything room.js has sent, so every one of them
+ // is above the cursor A resumes with and the eviction is a real
+ // hole rather than an already-delivered prefix. `gen` is 1 because
+ // nothing has reset this pair — the check above is what says so.
+ const posted = await pageB.evaluate(async ({ room, to, n }) => {
+ const me = window.meet.state().peer;
+ let ok = 0;
+ for (let i = 0; i < n; i++) {
+ const res = await fetch('/m/' + room + '/signal', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ peer: me, type: 'candidate', to, seq: 100000 + i, gen: 1, payload: {} }),
+ });
+ if (res.status === 200) ok += 1;
+ }
+ return ok;
+ }, { room: sqid, to: pair.a.peer, n: 40 });
+ check(posted === 40, 'B pushed forty targeted messages past A while it was cut (' + posted + ' accepted)');
+
+ cut.on = false;
+
+ const asked = await until(() => (resets > 0 ? resets : false), 45000);
+ check(asked, 'A received `gap` and asked for a reset (' + resets + ' resets posted)');
+
+ const rebuilt = await until(async () => {
+ const a = await state(pageA);
+ const b = await state(pageB);
+ return a.peer === pair.a.peer && !a.terminal &&
+ a.peers.includes(pair.b.peer) && b.peers.includes(pair.a.peer) &&
+ a.signaling[pair.b.peer] === 'stable' && a.connection[pair.b.peer] === 'connected' &&
+ b.signaling[pair.a.peer] === 'stable' && b.connection[pair.a.peer] === 'connected' ? { a, b } : false;
+ }, 45000);
+ check(rebuilt, 'both sides rebuilt the pair at the new generation and connected — ' +
+ (await diagnose(pageA, 'member A')) + ' ' + (await diagnose(pageB, 'member B')));
+
+ await leaveRoom(pageA);
+ await leaveRoom(pageB);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
// The process goes away and comes back. Both browsers see the stream
// die, find a boot they do not know, and rejoin (§4.5).
async restart() {
@@ -567,6 +806,23 @@ const run = {
return;
}
+ // §13 asks that both contexts have closed their old connections
+ // *before* rejoining, and that is a moment rather than a state: the
+ // gap between closeAllPCs and the roster of the new participation is
+ // one round trip wide. A poll from out here would step over it, so
+ // each page watches itself, five milliseconds at a time, from before
+ // the restart until after the new peer ids arrive.
+ for (const page of [pageA, pageB]) {
+ await page.evaluate(() => {
+ window.__sawEmpty = false;
+ window.__samples = 0;
+ window.__watch = setInterval(() => {
+ window.__samples += 1;
+ if (window.meet.state().peers.length === 0) window.__sawEmpty = true;
+ }, 5);
+ });
+ }
+
const answer = await control('restart');
check(answer === 'ok', 'the app restarted on the same port (' + answer + ')');
@@ -578,6 +834,15 @@ const run = {
check(back, 'both browsers took a new participation after the restart — ' +
(await diagnose(pageA, 'member A')) + ' ' + (await diagnose(pageB, 'member B')));
+ for (const [page, label] of [[pageA, 'member A'], [pageB, 'member B']]) {
+ const watched = await page.evaluate(() => {
+ clearInterval(window.__watch);
+ return { sawEmpty: window.__sawEmpty, samples: window.__samples };
+ });
+ check(watched.sawEmpty, label + ' held no peer connections at all between the old participation and the new one (' +
+ watched.samples + ' samples)');
+ }
+
if (back) await assertPair(pageA, pageB, 'member A', 'member B', 40000);
await leaveRoom(pageA);
@@ -610,13 +875,17 @@ const run = {
check(displaced === null,
'the first tab was not displaced by the second opening (' + JSON.stringify(displaced) + ')');
- const takeoverHref = await second.locator('#status a').getAttribute('href');
+ // Located by the href, not by "the link in #status": a terminal
+ // screen may carry more than one way out, and this scenario is about
+ // the one that takes the call over.
+ const takeover = second.locator('#status a[href$="/takeover"]');
+ const takeoverHref = await takeover.getAttribute('href');
check(takeoverHref === '/m/' + sqid + '/takeover',
'the second tab offers the takeover page (' + takeoverHref + ')');
await Promise.all([
second.waitForURL('**/m/' + sqid + '/takeover', { timeout: 15000 }),
- second.click('#status a'),
+ takeover.click(),
]);
await Promise.all([
second.waitForURL(origin + '/m/' + sqid, { timeout: 15000 }),
@@ -697,6 +966,127 @@ const run = {
await ctxA.close();
},
+ // A refusal the probe cannot name: the room is full, which the
+ // peerless probe answers 204 to because this session holds no peer
+ // here. Without a ceiling the client would retry that for ever behind
+ // "Connecting to the call."; with one it stops and says something
+ // true, with both ways out.
+ async stall() {
+ const ctx = await newContext('member A', { cookie: cookieA });
+ await ctx.route('**/stream*', async (route) => {
+ if (route.request().url().includes('probe=1')) {
+ await route.continue();
+ return;
+ }
+ await route.fulfill({ status: 503, contentType: 'application/json', body: '{"error":"full"}' });
+ });
+ const page = await openRoom(ctx);
+
+ // Deliberately generous: the ceiling is a count of rounds and a
+ // stretch of time, and the backoff between rounds is the client's
+ // own. What is asserted is where it ends up, never how long it took.
+ const ended = await until(async () => ((await state(page)).terminal === 'unreachable' ? true : false), 130000);
+ check(ended, 'a refusal the probe cannot name stops rather than retrying for ever — ' +
+ (await diagnose(page, 'member A')));
+ check(await waitStatus(page, 'Meet cannot reach the call.', 10000),
+ 'the stalled room says something true — status: ' + (await statusText(page)));
+ const hrefs = await page.locator('#status a').evaluateAll((as) => as.map((a) => a.getAttribute('href')));
+ check(hrefs.includes('/m/' + sqid) && hrefs.includes('/m/' + sqid + '/join'),
+ 'the stalled room offers both ways out (' + JSON.stringify(hrefs) + ')');
+
+ await ctx.close();
+ },
+
+ // An expired session answers the signal endpoint 403. That is not
+ // transient and never becomes true by waiting, so the room says so and
+ // offers the one thing that fixes it.
+ async signedout() {
+ const ctx = await newContext('member A', { cookie: cookieA });
+ await ctx.route('**/signal', (route) =>
+ route.fulfill({ status: 403, contentType: 'text/plain', body: 'forbidden' }));
+ const page = await openRoom(ctx);
+
+ const out = await until(async () => ((await state(page)).terminal === 'signedout' ? true : false), 30000);
+ check(out, 'a 403 from the signal endpoint ends the call as signed out — ' + (await diagnose(page, 'member A')));
+ check(await waitStatus(page, 'You have been signed out.', 10000),
+ 'the signed-out room says so — status: ' + (await statusText(page)));
+ const href = await page.locator('#status a').first().getAttribute('href');
+ check(href === '/signin', 'the signed-out room offers the way back in (' + href + ')');
+
+ await ctx.close();
+ },
+
+ // A browser with no WebRTC at all. The room needs another peer before
+ // it tries to build a connection, so B is in the call first.
+ async nowebrtc() {
+ const ctxB = await newContext('member B', { cookie: cookieB });
+ const pageB = await openRoom(ctxB);
+ check(await until(async () => (await state(pageB)).admitted, 20000),
+ 'the other member is in the room — ' + (await diagnose(pageB, 'member B')));
+
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ await ctxA.addInitScript(() => {
+ delete window.RTCPeerConnection;
+ });
+ const pageA = await openRoom(ctxA);
+
+ const ended = await until(async () => ((await state(pageA)).terminal === 'nowebrtc' ? true : false), 30000);
+ check(ended, 'a browser that cannot make calls is told so rather than sitting on an empty grid — ' +
+ (await diagnose(pageA, 'member A')));
+ check(await waitStatus(pageA, 'This browser cannot make calls.', 10000),
+ 'the sentence says what is wrong — status: ' + (await statusText(pageA)));
+ const href = await pageA.locator('#status a').first().getAttribute('href');
+ check(href === '/m/' + sqid + '/join', 'even this is not a dead end (' + href + ')');
+
+ await leaveRoom(pageB);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
+ // An autoplay policy that refuses a remote video: the tile offers the
+ // gesture rather than leaving a still frame nobody can explain. The
+ // refusal is applied to unmuted elements only, which is every remote
+ // tile and no local one, and it lasts one call so the control can be
+ // seen to work.
+ async playfallback() {
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ await ctxA.addInitScript(() => {
+ const real = HTMLMediaElement.prototype.play;
+ let refused = 0;
+ HTMLMediaElement.prototype.play = function play() {
+ if (!this.muted && refused < 1) {
+ refused += 1;
+ return Promise.reject(new DOMException('autoplay refused', 'NotAllowedError'));
+ }
+ return real.apply(this, arguments);
+ };
+ });
+ const ctxB = await newContext('member B', { cookie: cookieB });
+ const pageA = await openRoom(ctxA);
+ const pageB = await openRoom(ctxB);
+
+ const a = await waitConnected(pageA);
+ check(a, 'A is in the call with a peer — ' + (await diagnose(pageA, 'member A')));
+ const b = await waitConnected(pageB);
+ check(b, 'B is in the call with a peer — ' + (await diagnose(pageB, 'member B')));
+
+ // The two buttons in a remote tile's caption are Retry and Play, in
+ // that order.
+ const play = pageA.locator('#tiles figure:not(.meet-tile-self) figcaption button').nth(1);
+ const offered = await until(async () => await play.isVisible(), 25000);
+ check(offered, 'the refused remote video offers a Play control — ' + (await diagnose(pageA, 'member A')));
+ if (offered) {
+ await play.click();
+ const taken = await until(async () => !(await play.isVisible()), 20000);
+ check(taken, 'the gesture starts the video and the control goes — ' + (await diagnose(pageA, 'member A')));
+ }
+
+ await leaveRoom(pageA);
+ await leaveRoom(pageB);
+ await ctxA.close();
+ await ctxB.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 scriptless() {