| index 4a31999..e63d2e5 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -179,6 +179,27 @@ async function stayed(page, ms, pred) { |
| } |
| } |
| |
| +// bothStable is `stayed` for a pair of connections rather than one |
| +// page's state: it watches both for the whole window and returns the |
| +// first side that was not settled, or null if neither ever wobbled. A |
| +// renegotiation still in flight leaves signalingState somewhere other |
| +// than `stable`, and the shape worth catching is a rollback that never |
| +// comes back — which a single read taken too early and one taken too |
| +// late would both miss, in opposite directions. |
| +async function bothStable(watch, ms) { |
| + const deadline = Date.now() + ms; |
| + for (;;) { |
| + for (const w of watch) { |
| + const s = await state(w.page); |
| + if (s.signaling[w.other] !== 'stable') { |
| + return { label: w.label, signaling: s.signaling, connection: s.connection }; |
| + } |
| + } |
| + if (Date.now() > deadline) return null; |
| + await sleep(250); |
| + } |
| +} |
| + |
| // ── the browser ────────────────────────────────────────────────────── |
| |
| const browser = await chromium.launch({ args: LAUNCH_ARGS }); |
| @@ -278,7 +299,7 @@ function statusText(page) { |
| // segment of the URL goes in — see where(). |
| async function diagnose(page, label) { |
| try { |
| - const d = await page.evaluate(() => { |
| + const d = await page.evaluate(async () => { |
| const s = window.meet ? window.meet.state() : null; |
| const tiles = Array.from(document.querySelectorAll('#tiles figure')).map((f) => { |
| const v = f.querySelector('video'); |
| @@ -304,10 +325,58 @@ async function diagnose(page, label) { |
| // not find a pair. The rest says whether there was anything to |
| // offer: a transceiver per direction and kind, and the state of |
| // the tracks behind them. |
| - const pcState = (window.__pcs || []).map((pc) => { |
| + // |
| + // And, under each connection, what the receiver actually got. A |
| + // connected transport carrying no pixels is the failure this gate |
| + // meets most often, and `frames: {}` alone cannot tell its three |
| + // causes apart: framesReceived at zero means nothing arrived; |
| + // framesReceived climbing while framesDecoded stays flat means the |
| + // decoder is the problem; freezeCount and pliCount rising mean the |
| + // pixels arrived and then stopped. The receiving track's own |
| + // `muted` sits beside them because a muted track decodes nothing |
| + // and looks, from the tile, exactly like a dead one. |
| + const pcState = []; |
| + for (const pc of window.__pcs || []) { |
| try { |
| - if (pc.connectionState === 'closed') return null; |
| - return { |
| + if (pc.connectionState === 'closed') continue; |
| + const muted = new Map(); |
| + for (const r of pc.getReceivers()) { |
| + if (r.track) muted.set(r.track.id, r.track.muted); |
| + } |
| + const inbound = []; |
| + const outbound = []; |
| + try { |
| + const report = await pc.getStats(); |
| + report.forEach((v) => { |
| + if (v.kind !== 'video') return; |
| + if (v.type === 'inbound-rtp') { |
| + inbound.push({ |
| + ssrc: v.ssrc, |
| + framesReceived: v.framesReceived, |
| + framesDecoded: v.framesDecoded, |
| + framesDropped: v.framesDropped, |
| + pliCount: v.pliCount, |
| + freezeCount: v.freezeCount, |
| + muted: muted.has(v.trackIdentifier) ? muted.get(v.trackIdentifier) : null, |
| + }); |
| + } else if (v.type === 'outbound-rtp') { |
| + // The other half of the same question. Inbound alone |
| + // cannot tell "this browser stopped sending" from "this |
| + // browser stopped receiving", and with two video streams |
| + // per connection that is the first thing to know. |
| + outbound.push({ |
| + ssrc: v.ssrc, |
| + framesSent: v.framesSent, |
| + framesEncoded: v.framesEncoded, |
| + active: v.active, |
| + limited: v.qualityLimitationReason, |
| + }); |
| + } |
| + }); |
| + } catch { |
| + // getStats on a connection that is closing: nothing to add. |
| + } |
| + pcState.push({ |
| signaling: pc.signalingState, |
| connection: pc.connectionState, |
| ice: pc.iceConnectionState, |
| @@ -315,21 +384,50 @@ async function diagnose(page, label) { |
| local: pc.localDescription && pc.localDescription.type, |
| remote: pc.remoteDescription && pc.remoteDescription.type, |
| senders: pc.getSenders().length, |
| - transceivers: pc.getTransceivers().map((t) => t.direction + ':' + |
| + // mid/wanted/negotiated:kind. `currentDirection` is the |
| + // one that decides whether media flows — `direction` is |
| + // only what this side asked for — and the mid says which |
| + // m-line each transceiver ended up on, which is the whole |
| + // question when two peers renegotiate at once. |
| + transceivers: pc.getTransceivers().map((t) => (t.mid == null ? '-' : t.mid) + ' ' + |
| + t.direction + '/' + (t.currentDirection || '-') + ':' + |
| ((t.receiver && t.receiver.track && t.receiver.track.kind) || |
| (t.sender && t.sender.track && t.sender.track.kind) || '?')), |
| tracks: pc.getSenders().filter((sn) => sn.track) |
| .map((sn) => sn.track.kind + ':' + sn.track.readyState), |
| - }; |
| + inbound, |
| + outbound, |
| + }); |
| + } catch { |
| + // A connection that went away under the walk. |
| + } |
| + } |
| + |
| + // §5.4: what this tab is capturing, when it is capturing at all. |
| + // Read off the sender that carries it, because room.js does not |
| + // put its capture on `window` and should not; `displaySurface` is |
| + // the one settings key only getDisplayMedia produces, so it is |
| + // also how a display track is told from a camera. Null on every |
| + // page that is not sharing. |
| + let displaySurface = null; |
| + for (const pc of window.__pcs || []) { |
| + try { |
| + if (pc.connectionState === 'closed') continue; |
| + for (const sn of pc.getSenders()) { |
| + if (!sn.track || sn.track.kind !== 'video' || !sn.track.getSettings) continue; |
| + const settings = sn.track.getSettings(); |
| + if (settings && settings.displaySurface) displaySurface = settings.displaySurface; |
| + } |
| } catch { |
| - return null; |
| + // Same connection, same reason. |
| } |
| - }).filter(Boolean); |
| + } |
| |
| return { |
| at: '/' + (location.pathname.split('/')[1] || ''), |
| status: (document.getElementById('status') || {}).textContent || '', |
| roomHidden: (document.getElementById('room') || {}).hidden, |
| + displaySurface, |
| pcState, |
| state: s |
| ? { |
| @@ -801,7 +899,7 @@ async function queueNames(page) { |
| // gate both hang on. |
| function chatLog(page) { |
| return page.evaluate(() => |
| - Array.from(document.querySelectorAll('#chat-log li')).map((li) => ({ |
| + Array.from(document.querySelectorAll('[data-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'), |
| @@ -838,6 +936,30 @@ function shareTiles(page) { |
| })); |
| } |
| |
| +// capturedSurface is what this tab is actually sending as a screen: |
| +// `displaySurface` off the settings of a video track it has out, which |
| +// is the one key only getDisplayMedia produces. A tab sharing its |
| +// camera down the share m-line would answer null here, and every other |
| +// assertion about sharing would still hold — which is what makes this |
| +// worth reading. Null on a page that is not sharing. |
| +function capturedSurface(page) { |
| + return page.evaluate(() => { |
| + for (const pc of window.__pcs || []) { |
| + try { |
| + if (pc.connectionState === 'closed') continue; |
| + for (const sn of pc.getSenders()) { |
| + if (!sn.track || sn.track.kind !== 'video' || !sn.track.getSettings) continue; |
| + const settings = sn.track.getSettings(); |
| + if (settings && settings.displaySurface) return settings.displaySurface; |
| + } |
| + } catch { |
| + // A connection that went away under the walk. |
| + } |
| + } |
| + return 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. |
| @@ -1064,6 +1186,189 @@ const run = { |
| await ctxB.close(); |
| }, |
| |
| + // Both of them at once, three times over. §5.4 gives each connection |
| + // one sendonly transceiver for a screen, and a simultaneous share is |
| + // where that goes wrong: two peers renegotiating the same connection |
| + // in the same moment, the polite one rolling its own offer back — and |
| + // taking its share m-line with it. Either each screen ends up on its |
| + // own m-line, or one of them silently takes the other's and the |
| + // second screen arrives on a transceiver nothing is sending down. |
| + // |
| + // Nothing is staggered on purpose, because the stagger is the bug's |
| + // hiding place. Both clicks go out together, both directions are |
| + // asserted separately, and the frames half of each is what catches an |
| + // inactive m-line: `shares` and the caption move on the `share` |
| + // message alone, so a screen that was announced and never arrived |
| + // looks exactly like one that works until the pixels are counted. |
| + // |
| + // Three rounds, because a rollback that is recovered from once may |
| + // not be recovered from twice: the transceiver a round leaves behind |
| + // is the one the next round has to reuse or replace. |
| + async mutualshare() { |
| + 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 nameA = await selfName(pageA); |
| + const nameB = await selfName(pageB); |
| + check(nameA !== '' && nameB !== '' && nameA !== nameB, |
| + 'the two contexts have different names, so a caption can name the wrong one (' + |
| + JSON.stringify([nameA, nameB]) + ')'); |
| + |
| + // `other` is the peer id on the far side of this page's one |
| + // connection, so every assertion below reads as "this page, about |
| + // the other one" and the two directions never get crossed. |
| + const sides = [ |
| + { page: pageA, label: 'member A', other: pair.b.peer, otherName: nameB }, |
| + { page: pageB, label: 'member B', other: pair.a.peer, otherName: nameA }, |
| + ]; |
| + |
| + // Both buttons hovered first, so Playwright's actionability checks |
| + // are already satisfied and the two clicks are two CDP messages and |
| + // nothing else. Trusted input rather than a scripted |
| + // element.click(): getDisplayMedia needs transient activation and a |
| + // synthesised click does not grant it. This is as near the same tick |
| + // as two browser contexts get. |
| + const bothClick = async () => { |
| + await Promise.all([pageA.hover('#share'), pageB.hover('#share')]); |
| + await Promise.all([pageA.click('#share'), pageB.click('#share')]); |
| + }; |
| + |
| + for (let round = 1; round <= 3; round++) { |
| + const at = 'round ' + round + ': '; |
| + for (const side of sides) { |
| + side.cameraBefore = (await state(side.page)).frames[side.other] || 0; |
| + } |
| + |
| + await bothClick(); |
| + |
| + for (const side of sides) { |
| + const key = side.other + '#share'; |
| + |
| + const announced = await until(async () => { |
| + const s = await state(side.page); |
| + return s.shares.includes(side.other) ? s : false; |
| + }, 10000); |
| + check(announced, at + side.label + " was told about the other's screen — " + |
| + (await diagnose(side.page, side.label))); |
| + |
| + // The half a message cannot fake. A share m-line the far side |
| + // took for its own, or one left inactive by a rollback, is |
| + // announced exactly like a working one and carries nothing. |
| + const pixels = await until(async () => { |
| + const s = await state(side.page); |
| + return (s.frames[key] || 0) > 0 ? s : false; |
| + }, 10000); |
| + check(pixels, at + side.label + ' is decoding the other screen, not just showing its name — ' + |
| + (await diagnose(side.page, side.label))); |
| + |
| + const tiles = await shareTiles(side.page); |
| + check(tiles.length === 1 && tiles[0].name === side.otherName + '’s screen', |
| + at + side.label + ' shows one screen, captioned with the other name (' + |
| + JSON.stringify(tiles) + ', want ' + JSON.stringify(side.otherName + '’s screen') + ')'); |
| + |
| + const camera = await until(async () => { |
| + const s = await state(side.page); |
| + return (s.frames[side.other] || 0) > side.cameraBefore ? s : false; |
| + }, 10000); |
| + check(camera, at + side.label + " is still receiving the other's camera (" + |
| + side.cameraBefore + ' -> ' + ((await state(side.page)).frames[side.other] || 0) + ')'); |
| + } |
| + |
| + check((await state(pageA)).sharing === true && (await state(pageB)).sharing === true, |
| + at + 'both tabs say they are sharing'); |
| + |
| + // Five seconds of not wobbling. Glare's worst outcome is not a |
| + // screen that never arrives but a connection left mid-offer: it |
| + // goes on carrying the media it already had, so every count above |
| + // keeps rising while nothing further can ever be negotiated. |
| + const wobbled = await bothStable(sides, 5000); |
| + check(wobbled === null, at + 'negotiation settled and stayed settled — ' + JSON.stringify(wobbled)); |
| + |
| + if (round === 1) { |
| + for (const side of sides) { |
| + const surface = await capturedSurface(side.page); |
| + check(surface !== null, |
| + side.label + ' is sending a display capture and not a second camera (displaySurface ' + |
| + JSON.stringify(surface) + ')'); |
| + } |
| + } |
| + |
| + if (round === 3) break; |
| + |
| + await bothClick(); |
| + for (const side of sides) { |
| + const gone = await until(async () => { |
| + const s = await state(side.page); |
| + if (s.shares.length !== 0) return false; |
| + return (await shareTiles(side.page)).length === 0 ? s : false; |
| + }, 10000); |
| + check(gone, at + side.label + ' lost the screen tile before the next round — ' + |
| + (await diagnose(side.page, side.label))); |
| + } |
| + check((await state(pageA)).sharing === false && (await state(pageB)).sharing === false, |
| + at + 'both tabs say they have stopped'); |
| + } |
| + |
| + // And the asymmetric ending, which is the one a symmetrical |
| + // scenario would never reach: one of them stops and the other does |
| + // not. The m-line that goes has to be the one that was stopped. |
| + const [stopper, keeper] = sides; |
| + const keptKey = stopper.other + '#share'; |
| + const keptBefore = (await state(stopper.page)).frames[keptKey] || 0; |
| + const cameraBefore = (await state(keeper.page)).frames[keeper.other] || 0; |
| + |
| + await stopper.page.click('#share'); |
| + |
| + const dropped = await until(async () => { |
| + const s = await state(keeper.page); |
| + if (s.shares.length !== 0) return false; |
| + return (await shareTiles(keeper.page)).length === 0 ? s : false; |
| + }, 10000); |
| + check(dropped, keeper.label + ' lost only the screen that stopped — ' + |
| + (await diagnose(keeper.page, keeper.label))); |
| + |
| + const kept = await until(async () => { |
| + const s = await state(stopper.page); |
| + return s.shares.includes(stopper.other) && (s.frames[keptKey] || 0) > keptBefore ? s : false; |
| + }, 10000); |
| + check(kept, stopper.label + ' still has the other screen, and it is still arriving (' + |
| + keptBefore + ' -> ' + ((await state(stopper.page)).frames[keptKey] || 0) + ') — ' + |
| + (await diagnose(stopper.page, stopper.label))); |
| + |
| + const stillCamera = await until(async () => { |
| + const s = await state(keeper.page); |
| + return (s.frames[keeper.other] || 0) > cameraBefore ? s : false; |
| + }, 10000); |
| + check(stillCamera, keeper.label + ' still has the camera through the one-sided stop (' + |
| + cameraBefore + ' -> ' + ((await state(keeper.page)).frames[keeper.other] || 0) + ')'); |
| + |
| + check((await state(stopper.page)).sharing === false, |
| + stopper.label + ' says it has stopped'); |
| + check((await state(keeper.page)).sharing === true, |
| + keeper.label + ' says it is still sharing'); |
| + |
| + const settledAfter = await bothStable(sides, 5000); |
| + check(settledAfter === null, |
| + 'the one-sided stop left both connections settled — ' + JSON.stringify(settledAfter)); |
| + |
| + 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 keeper.page.click('#share'); |
| + 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 |
| @@ -1717,8 +2022,13 @@ const run = { |
| check(heard, "B's log gained the line within two seconds (" + |
| JSON.stringify(await chatLog(pageB)) + ')'); |
| if (heard) { |
| + // Not "the envelope's name": what a browser can see is that the |
| + // line B was given is attributed to A and not to B, and with two |
| + // distinct local parts on the two addresses (browser_test.go says |
| + // why) that is a claim this check can fail. |
| check(heard.name === nameA, |
| - "the line B sees carries A's name (" + JSON.stringify(heard.name) + ' vs ' + JSON.stringify(nameA) + ')'); |
| + "the line B sees is attributed to A, not to B (" + JSON.stringify(heard.name) + |
| + ' vs ' + JSON.stringify(nameA) + ')'); |
| check(heard.self === false, "the line B sees is not drawn as B's own"); |
| } |
| |
| @@ -1726,27 +2036,46 @@ const run = { |
| 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"); |
| |
| + // §4.3: the hub never sends a chat back to the tab that sent it, and |
| + // the line in A's log is the panel's own echo. Those two facts are |
| + // one line each and they are what stop a double-drawn message |
| + // passing every check above. |
| + check((await state(pageA)).chat.received === 0, |
| + 'A was not sent its own line back (' + JSON.stringify((await state(pageA)).chat) + ')'); |
| + check((await chatLog(pageA)).filter((l) => l.text === HELLO).length === 1, |
| + "A's log holds the line once, not twice (" + JSON.stringify(await chatLog(pageA)) + ')'); |
| + check((await chatLog(pageB)).filter((l) => l.text === HELLO).length === 1, |
| + "B's log holds the line once, not twice (" + JSON.stringify(await chatLog(pageB)) + ')'); |
| + |
| // 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. |
| + // yet. Every guest check below is guarded on it for the same reason |
| + // — an unsent message is silent everywhere, and this scenario would |
| + // be reporting that as a pass. |
| 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(counted && counted.chat.received === 1, |
| + 'and counted it once (' + JSON.stringify(counted && counted.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)) + ')'); |
| + if (counted) { |
| + 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)) + ')'); |
| + } else { |
| + check(false, 'the guest was not watched for silence: nothing was proven to have been said'); |
| + } |
| |
| check(chatPosts.length === 1, |
| 'one line typed is one POST, however many people hear it (' + chatPosts.length + ')'); |
| @@ -1785,7 +2114,7 @@ const run = { |
| JSON.stringify(await chatLog(pageG)) + ')'); |
| if (guestHeard) { |
| check(guestHeard.name === nameA, |
| - "the guest's line carries A's name too (" + JSON.stringify(guestHeard.name) + ')'); |
| + "the guest's line is attributed to A 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. |
| @@ -1794,8 +2123,21 @@ const run = { |
| 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) + ')'); |
| + // Exactly two, not at least two: §6 has no replay and no gap, so a |
| + // count that climbed past what was said would mean something was |
| + // delivered twice, and a count short of it would mean a line went |
| + // missing. Both are the same assertion. |
| + const both = await until(async () => { |
| + const s = await state(pageB); |
| + return s.chat.received >= 2 ? s : false; |
| + }, 5000); |
| + check(both && both.chat.received === 2, |
| + 'B heard both lines, each once (' + JSON.stringify((await state(pageB)).chat) + ')'); |
| + check((await chatLog(pageB)).filter((l) => l.text === AGAIN).length === 1, |
| + "and drew the second one once (" + JSON.stringify(await chatLog(pageB)) + ')'); |
| + check((await state(pageA)).chat.received === 0, |
| + 'A was sent neither of its own lines back (' + |
| + JSON.stringify((await state(pageA)).chat) + ')'); |
| |
| // §6 and §5.2's promise together: what people say is not logged. |
| for (const label of ['member A', 'member B', 'guest']) { |