oficina / meet Public

browser gate: prove the silence, and decide the mutual share

Fix round 1 on the two slice-4 scenarios, plus one new one.

Passing: share (26 checks), chat (52 checks).
Skipped: mutualshare (55 checks, 50 pass, 5 fail) — a client defect
under glare, kept whole and skipped with the exact failure and a
Task 4 fix round 2 marker.

chat: the three guest-silence checks now sit behind B's received
count, because an unsent message is silent everywhere and the
scenario was reporting that as proof. No echo and no duplicate are
pinned: A's own received count stays at zero, both logs hold each
line exactly once, and B's count is asserted at exactly one and then
exactly two rather than at least. Member B is bea@rslw.com, so the
two contexts no longer share the name "paul" and a line attributed
to the wrong tab can fail.

mutualshare: both Share buttons clicked together through two CDP
sessions, three rounds of share-and-stop, each direction asserted
separately, negotiation watched for five seconds after each, then one
stops while the other carries on. Every screen assertion passes in
every round — announced, captioned with the other name, real frames,
settled, and a displaySurface confirming a display and not a second
camera. The cameras stop dead from the first mutual share, both ways.
getStats says why: the stopper's only outbound video stream reports
framesSent 0 and framesEncoded 0 with active true and no quality
limitation, on a live track whose m-line stays sendrecv/sendrecv; the
other direction sends 1146 frames, 513 arrive, and the receiving
track reports muted true. Neither is visible when one side shares
alone.

diagnose now carries per-connection inbound-rtp (framesReceived,
framesDecoded, framesDropped, pliCount, freezeCount and the receiving
track's muted), the outbound half beside it, mids and currentDirection
on every transceiver, and the local capture's displaySurface. None of
it changes an assertion; without it the failure above could not have
been attributed.

shareCaption is lifted into room-share.test.mjs, so the words under a
screen are no longer gated by a browser alone.

The gate is 30 scenarios in 424 s: 29 pass, 1 skip, no re-run needed.
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 a11ad9a09e8beeda14260e8931cdac916df99a18 parent a7c91d3
3 files changed, +464 −23
  • internal/meettest/browser_test.go +79 −2
  • test/browser/call.mjs +363 −21
  • test/browser/room-share.test.mjs +22 −0

CI passed — run details

diff --git a/internal/meettest/browser_test.go b/internal/meettest/browser_test.go
index 957ec71..31d67f0 100644
--- a/internal/meettest/browser_test.go
+++ b/internal/meettest/browser_test.go
@@ -59,9 +59,16 @@ const scenarioTimeout = 3 * time.Minute
// memberA and memberB are the two AllowedAddresses this gate signs in.
// A guest signs itself in from the browser, through the emailed link,
// with an address that is deliberately not on the list.
+//
+// The two local parts differ, and that is load-bearing rather than
+// cosmetic: a display name is the address up to the "@", so the rest of
+// this package's `paul@example.com`/`paul@rslw.com` pair would give both
+// contexts here the name "paul". Every assertion about whose name is on
+// an arriving chat line, or in "<name>’s screen", would then hold just
+// as well with the wrong name on it.
const (
memberA = "paul@example.com"
- memberB = "paul@rslw.com"
+ memberB = "bea@rslw.com"
)
// scenarios are the passes, in the order they run. Each gets its own
@@ -76,6 +83,7 @@ var scenarios = []string{
"devices",
"call",
"share",
+ "mutualshare",
"renewal",
"ice",
"icerenewal",
@@ -168,7 +176,76 @@ var scenarios = []string{
// people already there, so for the commonest case there is no newer
// roster for the first door to prefer. The three ICE scenarios and
// staletarget have run in the gate ever since.
-var skipped = map[string]string{}
+var skipped = map[string]string{
+ // Task 4 fix round 2 owns this. The scenario keeps its place above
+ // and every assertion as written; what follows is exactly what the
+ // gate printed, and the evidence under it.
+ //
+ // mutualshare drives §5.4 the one way `share` cannot: both peers
+ // click Share screen together, three rounds of stop-and-share, and
+ // then one stops while the other carries on. Every screen assertion
+ // passes in every round — announced, captioned with the right name,
+ // decoding real frames, negotiation settling and staying settled,
+ // displaySurface saying both are really capturing a display. What
+ // fails is the camera, from the first mutual share onwards:
+ //
+ // FAIL round 2: member A is still receiving the other's camera (5 -> 5)
+ // FAIL round 2: member B is still receiving the other's camera (6 -> 6)
+ // FAIL round 3: member A is still receiving the other's camera (5 -> 5)
+ // FAIL round 3: member B is still receiving the other's camera (6 -> 6)
+ // FAIL member B still has the camera through the one-sided stop (6 -> 6)
+ //
+ // Round 1 passes only because 5 > 3: the count moves twice and then
+ // stops for the remaining seventy seconds, while the two screens go
+ // on to six hundred frames each on the same connections.
+ //
+ // It is not the tile, the counter or the encoder. Per-connection
+ // getStats, from the run that produced the lines above:
+ //
+ // A (has stopped sharing; audio mid 0, camera mid 1, B's screen mid 6):
+ // transceivers ["0 sendrecv/sendrecv:audio", "1 sendrecv/sendrecv:video", "6 recvonly/recvonly:video"]
+ // inbound [{ssrc 2155371570, framesReceived 513, framesDecoded 512, framesDropped 0, pliCount 0, freezeCount 0, muted false}
+ // {ssrc 572440383, framesReceived 513, framesDecoded 513, framesDropped 1, pliCount 1, freezeCount 0, muted true}]
+ // outbound [{ssrc 991534554, framesSent 0, framesEncoded 0, active true, limited "none"}]
+ //
+ // B (still sharing; audio 0, camera 1, its own screen 6, A's screen 7):
+ // transceivers ["0 sendrecv/sendrecv:audio", "1 sendrecv/sendrecv:video", "6 sendonly/sendonly:video", "7 recvonly/recvonly:video"]
+ // inbound [{ssrc 4101167316, framesReceived 508, framesDecoded 507, framesDropped 0, pliCount 0, freezeCount 0, muted false}
+ // {ssrc 991534554, framesReceived 6, framesDecoded 6, framesDropped 0, pliCount 1, freezeCount 0, muted true}]
+ // outbound [{ssrc 2155371570, framesSent 508, framesEncoded 508, active true, limited "none"}
+ // {ssrc 572440383, framesSent 1146, framesEncoded 1146, active true, limited "none"}]
+ //
+ // Two facts, and they are the whole diagnosis. A's only outbound
+ // video stream has sent zero frames and encoded zero frames, with
+ // active true and a quality limitation of "none" — so this is not a
+ // starved encoder in a headless browser, it is a stream that never
+ // started. A's camera track is live and its m-line is mid 1 at
+ // sendrecv/sendrecv throughout, which means the SSRC on that m-line
+ // was replaced by a renegotiation and the new one never encoded.
+ // And in the other direction B's camera is sending healthily (1146
+ // frames) and arriving at A (513 received) on a receiver whose track
+ // reports muted true, so none of it reaches the tile.
+ //
+ // Both are outcomes of the simultaneous renegotiation: two peers
+ // adding a share transceiver to one connection in the same moment,
+ // one of them rolling its own offer back. Neither is visible when
+ // only one side shares, which is why `share` passes.
+ //
+ // The fix belongs in room.js and nowhere in this gate. Delete this
+ // entry to put the scenario back — that, and nothing else, is what
+ // "fixed" means.
+ "mutualshare": "Task 4 fix round 2: a mutual share stops both cameras. " +
+ "round 2: member A is still receiving the other's camera (5 -> 5); " +
+ "round 2: member B is still receiving the other's camera (6 -> 6); " +
+ "round 3: member A is still receiving the other's camera (5 -> 5); " +
+ "round 3: member B is still receiving the other's camera (6 -> 6); " +
+ "member B still has the camera through the one-sided stop (6 -> 6). " +
+ "Every screen assertion passes; the camera m-line keeps sendrecv/sendrecv " +
+ "with a live track while its outbound stream reports framesSent 0, " +
+ "framesEncoded 0, active true, limited \"none\", and the other direction " +
+ "arrives (513 received) on a receiver track reporting muted true. " +
+ "See the account above this entry for the full getStats.",
+}
func TestCallInARealBrowser(t *testing.T) {
rig := newBrowserRig(t)
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
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']) {
diff --git a/test/browser/room-share.test.mjs b/test/browser/room-share.test.mjs
index db15c02..6fd3b4c 100644
--- a/test/browser/room-share.test.mjs
+++ b/test/browser/room-share.test.mjs
@@ -16,6 +16,8 @@
// classifyTrack a peer's screen told from its camera
// shareKey the tile key a screen gets, which must never
// collide with a peer id
+// shareCaption the words under a screen, which the browser gate
+// rebuilds from the roster and compares
// shareTxLost a share transceiver a rollback took away
// frameKey which of a peer's two video tiles a stats report
// belongs to
@@ -75,6 +77,7 @@ const build = (name, prelude) =>
const suffix = 'const SHARE_SUFFIX = ' + JSON.stringify(SHARE_SUFFIX) + ';';
const shareKey = build('shareKey', suffix);
+const shareCaption = build('shareCaption');
const classifyTrack = build('classifyTrack');
const shareTxLost = build('shareTxLost');
const frameKey = build('frameKey', suffix + lift(src, 'shareKey'));
@@ -120,6 +123,25 @@ test('two peers sharing at once keep their screens apart', () => {
assert.notEqual(shareKey(a), shareKey(b));
});
+// ---------------------------------------------------------------
+// shareCaption
+// ---------------------------------------------------------------
+//
+// The browser gate builds this string for itself, out of the roster's
+// name, and compares it with the caption on the tile. That makes the
+// words a claim two files agree on — and this is the file that can fail
+// in fifty milliseconds when someone changes them.
+
+test('a screen says whose it is, with the typographic apostrophe', () => {
+ assert.equal(shareCaption('Bea'), 'Bea’s screen');
+ // Not the ASCII one. TERMINAL.accept uses U+2019 and so does this;
+ // a stray "'" here would be a second convention in one file.
+ assert.equal(shareCaption('Bea').includes("'"), false);
+ // The name goes in whole: nothing trims it, shortens it or changes
+ // its case, because it is the roster's word and not this file's.
+ assert.equal(shareCaption('de Vries'), 'de Vries’s screen');
+});
+
// ---------------------------------------------------------------
// classifyTrack (§5.4, receiving)
// ---------------------------------------------------------------