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