oficina / meet Public

room.js: one capture per gesture, and a screen without a camera

Two clicks while the picker is open took two captures, and only the second was ever stopped: sharePending closes it, and a capture that arrives after the call ended is stopped rather than announced. A message typed while a 404 was being diagnosed waited on a pump that had already returned; the lane now answers everything left in it when the session ends, because a sender holding a promise that never settles is a composer that never comes back.

The classifier's blind spot was reachable after all, and the report said it was not: startLocal has an audio-only rung, so a peer really can have no camera m-line and its screen really is the first video track its peers see. Four signals now, strongest first — the transceiver's direction on a connection that is sending a camera, the camera's mid, the camera's stream, and the stream this peer's other media has been arriving in — with the share message breaking only ties. A share transceiver is added only to a stable connection and asked for again after a rollback, so two peers sharing at once cannot each take the m-line the other built. The panel is no longer un-hidden over a mount chat.js could only no-op on, the getStats fallback tells a peer's two video streams apart, and a 404 whose budget was spent gets five seconds to diagnose rather than none.

room-lane.test.mjs is new: the three loops whose contract is what they do rather than what they answer.
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 a7c91d31f1866e00098007bdc7ce8e01bd2cab93 parent 53b9e97
6 files changed, +939 −64
  • Makefile +11 −8
  • docs/superpowers/copy/2026-09-10-slice-2-strings.md +1 −0
  • internal/meet/static/room.js +245 −37
  • test/browser/negotiation.test.mjs +38 −1
  • test/browser/room-lane.test.mjs +460 −0
  • test/browser/room-share.test.mjs +184 −18

CI passed — run details

diff --git a/Makefile b/Makefile
index 32bae4f..2d22d0c 100644
--- a/Makefile
+++ b/Makefile
@@ -128,19 +128,22 @@ ci: vet fmt-check test migration-check
# -count=1 because call.mjs is not an input to Go's test cache: node
# opens it, not the test, so an edited browser script would otherwise be
# answered with a cached pass from before the edit.
-# The three .test.mjs files run first because they need no browser at
-# all: they lift pure functions out of room.js and chat.js by name and
-# check §5.1's glare decision against the W3C truth table; §6's DOM
-# safety, the log cap and §4.4's byte counter; and §5.4's camera-or-
-# screen judgement with §4.3's fan-out wire shape — in under a tenth of
-# a second between them. Named explicitly rather than by directory —
-# node's test runner treats everything under test/ as a test file, and
-# call.mjs is not one.
+# The four .test.mjs files run first because they need no browser at
+# all: they lift functions out of room.js and chat.js by name — pure
+# ones for their answers, whole loops for what they do — and check
+# §5.1's glare decision against the W3C truth table; §6's DOM safety,
+# the log cap and §4.4's byte counter; §5.4's camera-or-screen judgement
+# with §4.3's fan-out wire shape; and the two sequencing contracts,
+# every sender on the broadcast lane answered exactly once and one
+# gesture taking one capture. Under a second between them. Named
+# explicitly rather than by directory — node's test runner treats
+# everything under test/ as a test file, and call.mjs is not one.
browser:
go vet -tags browser ./internal/meettest/
node --test test/browser/negotiation.test.mjs
node --test test/browser/chat.test.mjs
node --test test/browser/room-share.test.mjs
+ node --test test/browser/room-lane.test.mjs
npm install --no-save playwright@1.62.1
npx playwright install chromium
go test -tags browser -count=1 -run TestCallInARealBrowser -v ./internal/meettest/
diff --git a/docs/superpowers/copy/2026-09-10-slice-2-strings.md b/docs/superpowers/copy/2026-09-10-slice-2-strings.md
index 1853bae..7a41df4 100644
--- a/docs/superpowers/copy/2026-09-10-slice-2-strings.md
+++ b/docs/superpowers/copy/2026-09-10-slice-2-strings.md
@@ -269,3 +269,4 @@ Developer-facing, in a `console.error` prefix rather than on a screen:
| String | Where | Note |
| --- | --- | --- |
| meet: share | `room.js`, the share button's rejection handler | Same shape as `meet: start`, `meet: roster`, `meet: renew`. Never rendered, and it prints an error object — never a capture, a track id or a peer's name. |
+| meet: chat | `room.js`, `runBroadcast()` — the broadcast lane's rejection handler | As above. Nothing awaits the pump, so a rejection escaping it would go where neither a person nor a gate could see it. It prints an error object and never a message: §4.4 says nothing of an accepted chat reaches a log line, and that includes this one. |
diff --git a/internal/meet/static/room.js b/internal/meet/static/room.js
index ee0f852..f258f35 100644
--- a/internal/meet/static/room.js
+++ b/internal/meet/static/room.js
@@ -127,6 +127,13 @@
// is its key: not a peer id, and it cannot collide with one, because
// every peer id is thirty-two hex characters.
const BROADCAST = '*';
+ // A 404 on the broadcast lane is diagnosed inside the message's own
+ // deadline — but a POST that spent all thirty seconds getting that 404
+ // leaves nothing to diagnose with, and a recovery with no budget
+ // decides nothing at all. This is the floor it gets instead: enough
+ // for one probe and one retry, and short enough that a tab which has
+ // really lost its participation is not left looking live.
+ const MIN_RECOVERY = 5000;
// §5.4: the tile key a peer's screen gets, built by shareKey.
const SHARE_SUFFIX = '#share';
const HAS_DISPLAY_MEDIA = typeof navigator !== 'undefined' &&
@@ -200,6 +207,10 @@
// when it is not. One capture, whatever the mesh's size — every
// connection carries the same track on a transceiver of its own.
let share = null; // {stream, track} while sharing
+ // True from the moment getDisplayMedia is asked until it answers. The
+ // picker takes as long as a person takes, and without this a second
+ // click during it takes a second capture that nothing ever stops.
+ let sharePending = false;
// §6: the mounted panel, once there is one. `chatSent` counts lines
// the hub accepted, not lines typed, and `chatReceived` counts lines
// put in the log — the two facts a gate cannot read off the DOM
@@ -1004,8 +1015,24 @@
// anything to show in it.
cameraMid: '',
cameraStream: '',
+ // The stream everything else from this peer has been arriving in,
+ // recorded from the first track of any kind. A sender puts its
+ // camera and its microphone in one MediaStream and its screen in
+ // another, so this places a video track even before there is a
+ // camera to compare it with — which is the audio-only sharer, whose
+ // screen is the first video track its peers ever see.
+ peerStream: '',
+ // Whether this connection carries a camera of ours. Not the same
+ // question as `local` having a video track: addTrack can throw,
+ // and then this tab has a camera and this connection has a video
+ // m-line it built for nobody. classifyTrack's direction rule turns
+ // on which of the two is true.
+ sentVideo: false,
sharing: false,
share: null,
+ // The mid the peer's screen arrives on, so the getStats fallback
+ // can tell two inbound video streams apart.
+ shareMid: '',
};
pcs.set(id, entry);
@@ -1056,6 +1083,13 @@
// renewal.
pc.addEventListener('signalingstatechange', () => {
if (pcs.get(id) !== entry) return;
+ // §5.4: the same quiet moment is when a deferred share transceiver
+ // is added. addShareTo will not add one to a connection that is
+ // mid-negotiation, because an unassociated sendonly transceiver is
+ // exactly what a remote offer's m-line matching will claim on a
+ // rollback — and then the screen is being sent on the m-line the
+ // far side means for something else.
+ addShareTo(entry);
adoptWhenQuiet(id, entry);
});
@@ -1069,7 +1103,10 @@
// that has ended counts as absent, because it is.
try {
if (local) {
- for (const track of local.getTracks()) pc.addTrack(track, local);
+ for (const track of local.getTracks()) {
+ pc.addTrack(track, local);
+ if (track.kind === 'video' && track.readyState !== 'ended') entry.sentVideo = true;
+ }
}
} catch {
// A track the browser will not add — ended between the ladder and
@@ -1089,6 +1126,13 @@
// the screen with everything else, rather than waiting for the next
// time the button is pressed. Someone joining a call mid-share sees
// the screen.
+ //
+ // Last, and that is load-bearing: the m-lines of the first offer
+ // come out in the order the transceivers were created, so the camera
+ // and the microphone take the places the far side's own createPC
+ // built for them and the screen takes a place of its own after them.
+ // Added before them, the screen would sit in the m-line a peer built
+ // for a camera.
addShareTo(entry);
return entry;
@@ -1209,6 +1253,12 @@
entry.isSettingRemoteAnswerPending = false; // this one is an offer
await entry.pc.setRemoteDescription(desc);
entry.ignoreOffer = false;
+ // §5.4: the polite peer's implicit rollback happened inside that
+ // call, and a share transceiver that was still unassociated can
+ // come out of it stopped, or with its m-line taken by the offer.
+ // Either way the screen is no longer being sent on this
+ // connection, and it needs one again.
+ regainShare(entry);
entry.descSeq = msg.seq || entry.descSeq;
await drainCandidates(entry);
await entry.pc.setLocalDescription();
@@ -2245,6 +2295,15 @@
return verdict === 'sent';
}
+ // recoveryDeadline is the budget a 404's diagnosis actually gets. The
+ // message's own deadline, unless the POST that produced the 404 spent
+ // it — a recovery with nothing left decides nothing, leaves the
+ // participation unexamined, and the tab goes on looking live. Pure, so
+ // the node test can lift it by name.
+ function recoveryDeadline(deadline, now) {
+ return Math.max(deadline, now + MIN_RECOVERY);
+ }
+
// broadcast queues one untargeted message and resolves when the hub
// has answered for it: true if it was accepted, false for every other
// ending, including a deadline that ran out with nothing decided.
@@ -2254,7 +2313,17 @@
const q = queue.get(BROADCAST) || [];
q.push({ body: broadcastBody(state.peer, type, payload), resolve });
queue.set(BROADCAST, q);
- pumpBroadcast();
+ runBroadcast();
+ });
+ }
+
+ // runBroadcast is the fire-and-forget face of pumpBroadcast: nothing
+ // awaits the pump, and a rejection escaping an async function nobody
+ // awaits goes where neither a person nor a gate could see it. Same
+ // shape as requestReset's, and as the share button's.
+ function runBroadcast() {
+ pumpBroadcast().catch((err) => {
+ console.error('meet: chat', err);
});
}
@@ -2281,7 +2350,7 @@
if (Date.now() >= head.deadline) {
// Out of time with nothing decided: the sender is told so, and
// the next message in the lane starts on a budget of its own.
- settle(q, false);
+ settleBroadcast(q, false);
wait = BACKOFF_MIN;
continue;
}
@@ -2294,7 +2363,7 @@
return;
}
if (verdict === 'sent' || verdict === 'drop') {
- settle(q, accepted(verdict));
+ settleBroadcast(q, accepted(verdict));
wait = BACKOFF_MIN;
continue;
}
@@ -2306,12 +2375,14 @@
// fan-out message never had one.
queue.set(BROADCAST, []);
drainBroadcast(q);
- await recoverFromNotFound(res.data.boot, null, { deadline: head.deadline });
+ await recoverFromNotFound(res.data.boot, null, {
+ deadline: recoveryDeadline(head.deadline, Date.now()),
+ });
return;
}
const ms = verdict === 'wait' ? retryAfter(res.retryAfter) : wait;
if (!(await waitWithin(ms, head.deadline))) {
- settle(q, false);
+ settleBroadcast(q, false);
wait = BACKOFF_MIN;
continue;
}
@@ -2320,12 +2391,22 @@
} finally {
inflight.delete(BROADCAST);
const rest = queue.get(BROADCAST);
- if (!state.stopped && rest && rest.length) pumpBroadcast();
+ if (rest && rest.length) {
+ // Something was queued while this pump was busy — during the
+ // 404's diagnosis, most of the time, which is the one arm that
+ // returns while the tab may still be alive. If that diagnosis
+ // ended the session there is nobody left to send it, and a
+ // sender left holding a promise that never settles is a composer
+ // that never comes back; otherwise it gets this pump.
+ if (state.stopped) drainBroadcast(rest);
+ else runBroadcast();
+ }
}
}
- // settle answers the message at the head of the lane and drops it.
- function settle(q, ok) {
+ // settleBroadcast answers the message at the head of the lane and
+ // drops it.
+ function settleBroadcast(q, ok) {
const head = q.shift();
if (head && head.resolve) head.resolve(ok);
}
@@ -2333,7 +2414,7 @@
// drainBroadcast answers everything still queued with false: the lane
// is finished, and nothing in it was delivered.
function drainBroadcast(q) {
- while (q.length) settle(q, false);
+ while (q.length) settleBroadcast(q, false);
}
function rosterGen(id) {
@@ -2624,6 +2705,17 @@
function mountChat() {
if (chat) return chat;
if (!chatEl || !window.Chat || typeof window.Chat.mount !== 'function') return null;
+ // chat.js answers a root it cannot use with a working no-op rather
+ // than a throw, which is right for it and wrong to trust here: an
+ // un-hidden panel backed by a no-op is a composer that swallows
+ // everything typed into it in silence. These are the three elements
+ // its own mount requires, so this is the same question asked before
+ // the answer stops being distinguishable.
+ if (!chatEl.querySelector('[data-chat-log]') ||
+ !chatEl.querySelector('[data-chat-form]') ||
+ !chatEl.querySelector('[data-chat-text]')) {
+ return null;
+ }
const panel = window.Chat.mount(chatEl, { name: selfName, send: sendChat });
if (!panel) return null;
chat = panel;
@@ -2658,10 +2750,12 @@
// is in the room. The text is re-validated as a string here, because
// what reaches `append` reaches the DOM.
function onChat(msg) {
- const panel = mountChat();
- if (!panel) return;
+ // Validated before anything is built: a malformed message is not a
+ // reason to put a panel on the screen.
const text = msg.payload && msg.payload.text;
if (typeof text !== 'string' || !text) return;
+ const panel = mountChat();
+ if (!panel) return;
chatReceived += 1;
panel.append({
from: msg.from || '',
@@ -2700,23 +2794,52 @@
// one judgement §5.4's receiving half turns on. Pure, and lifted by
// name in the node test.
//
- // The camera is the first video track a peer sends: by then nothing
- // has been recorded, so there is nothing to be a second of. What was
- // recorded — the transceiver's `mid`, and the id of the stream the
- // track arrived in — is then what a later video track is compared
- // with. The `mid` is preferred: it is assigned once and survives
- // every renegotiation and ICE restart, where a stream id is only as
- // good as the sender's having named a stream at all. A second video
- // track that matches neither is the screen; one that matches the
- // camera's mid is the camera again, which is what a replaced camera
- // track looks like.
- function classifyTrack(known, kind, streamId, mid) {
- if (kind !== 'video') return 'audio';
- if (!known.cameraMid && !known.cameraStream) return 'camera';
- if (known.cameraMid && mid) return mid === known.cameraMid ? 'camera' : 'share';
- if (known.cameraStream && streamId) return streamId === known.cameraStream ? 'camera' : 'share';
- // Neither a mid nor a stream id to compare it with, and a camera
- // already known: a second video track is the screen.
+ // known what this connection has recorded: cameraMid, cameraStream,
+ // peerStream, sharing
+ // info the arriving track: {kind, streamId, mid, direction},
+ // direction being the transceiver's currentDirection
+ // self this tab: {hasVideo}, a live local video track
+ //
+ // "The first video track is the camera" is the rule this started as,
+ // and it is wrong for one sender that really exists: the audio-only
+ // rung of startLocal. A peer whose camera was in use or absent has no
+ // camera m-line at all, so its *screen* is the first video track its
+ // peers ever see, and the rule would caption it as a face.
+ //
+ // So four signals, strongest first.
+ //
+ // 1. The transceiver's direction. This connection builds a video
+ // m-line of its own — sendrecv when it is sending a camera,
+ // recvonly when it has none to send — so a video track arriving
+ // `recvonly` on a connection that *is* sending a camera is a track
+ // on an m-line the far side added, and the only m-line it adds is
+ // the screen.
+ // 2. The camera's own mid, once one is known. Assigned once, and it
+ // survives every renegotiation and ICE restart.
+ // 3. The camera's stream id, for a browser that reports no mid.
+ // 4. The stream everything else from this peer has arrived in. A
+ // sender puts its camera and its microphone in one MediaStream and
+ // its screen in another, so a video track in a *different* stream
+ // from the peer's audio is the screen even when no camera has ever
+ // arrived — which is the audio-only sharer above.
+ //
+ // And, when none of the four can speak — a peer that has sent nothing
+ // else at all — `sharing`, the `share {on:true}` message, breaks the
+ // tie. It is last on purpose: it says this peer is showing a screen
+ // somewhere, not that *this* track is it.
+ function classifyTrack(known, info, self) {
+ if (info.kind !== 'video') return 'audio';
+ if (info.direction === 'recvonly' && self.hasVideo) return 'share';
+ if (known.cameraMid && info.mid) return info.mid === known.cameraMid ? 'camera' : 'share';
+ if (known.cameraStream && info.streamId) {
+ return info.streamId === known.cameraStream ? 'camera' : 'share';
+ }
+ if (known.peerStream && info.streamId) {
+ return info.streamId === known.peerStream ? 'camera' : 'share';
+ }
+ if (!known.cameraMid && !known.cameraStream) return known.sharing ? 'share' : 'camera';
+ // A camera is known but there is nothing on this track to compare
+ // with it: a second video track is the screen.
return 'share';
}
@@ -2727,9 +2850,24 @@
const track = ev.track;
const stream = (ev.streams && ev.streams.length && ev.streams[0]) || null;
const mid = (ev.transceiver && ev.transceiver.mid) || '';
- const what = classifyTrack(entry, track.kind, (stream && stream.id) || '', mid);
+ const tx = ev.transceiver;
+ const what = classifyTrack(entry, {
+ kind: track.kind,
+ streamId: (stream && stream.id) || '',
+ mid,
+ // currentDirection is the negotiated one and the one to believe —
+ // but it is null until an *answer* has been applied, so on the
+ // side that answers an offer it says nothing at all, which is
+ // exactly the side a screen arrives at. `direction` is what the
+ // browser set when it built the transceiver, and a transceiver
+ // built by setRemoteDescription for an m-line the far side added
+ // is recvonly. Falling back to it is what makes the rule work in
+ // both roles.
+ direction: (tx && (tx.currentDirection || tx.direction)) || '',
+ }, { hasVideo: !!entry.sentVideo });
if (what === 'share') {
entry.sharing = true;
+ entry.shareMid = mid || entry.shareMid;
entry.share = { stream: stream || new MediaStream([track]), track };
if (!stream) entry.share.stream.addTrack(track);
// §5.4's first ending: the sender stopped the transceiver, so
@@ -2749,6 +2887,9 @@
if (mid) entry.cameraMid = mid;
if (stream) entry.cameraStream = stream.id;
}
+ // Recorded from the first track of any kind, camera or microphone:
+ // it is what places a video track for a peer that has no camera.
+ if (stream && !entry.peerStream) entry.peerStream = stream.id;
entry.stream = stream || entry.stream || new MediaStream([track]);
if (!stream) entry.stream.addTrack(track);
renderTiles();
@@ -2762,6 +2903,7 @@
if (!entry.sharing && !entry.share) return;
entry.sharing = false;
entry.share = null;
+ entry.shareMid = '';
renderTiles();
}
@@ -2780,7 +2922,14 @@
// first: a person who changes their mind at the browser's own picker
// has shared nothing, and nothing should have been announced.
async function startSharing() {
- if (share || state.stopped || !HAS_DISPLAY_MEDIA) return;
+ if (share || sharePending || state.stopped || !HAS_DISPLAY_MEDIA) return;
+ // The picker is open for as long as a person takes to answer it, and
+ // the button is live the whole time. Without this a second click
+ // starts a second capture: the peers would be watching the first,
+ // `share` would point at the second, and Off would stop only the one
+ // nobody could see — leaving a screen being sent that this tab no
+ // longer believes in.
+ sharePending = true;
let stream;
try {
stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
@@ -2789,10 +2938,15 @@
// sentence, and no diagnosis of which.
say(SHARE_REFUSED);
return;
+ } finally {
+ sharePending = false;
}
const track = stream.getVideoTracks()[0];
- if (!track || state.stopped) {
- for (const t of stream.getTracks()) t.stop();
+ // Read again on this side of the await: a capture that arrives after
+ // the call ended, or after some other path started one, is a capture
+ // nothing will ever stop unless it is stopped here.
+ if (!track || share || state.stopped) {
+ stopStream(stream);
return;
}
share = { stream, track };
@@ -2814,6 +2968,14 @@
// offer with no help from here.
function addShareTo(entry) {
if (!share || !entry || entry.shareTx) return;
+ // Only on a stable connection. A transceiver added mid-negotiation
+ // is unassociated when the far side's offer arrives, and m-line
+ // matching will hand it that offer's m-line — so two peers sharing
+ // at the same moment end up with each one's screen in the place the
+ // other built for its own. The connection's own signalingstatechange
+ // brings this back the moment it settles, which is where every
+ // deferred one is added.
+ if (entry.pc.signalingState !== 'stable') return;
try {
entry.shareTx = entry.pc.addTransceiver(share.track, {
direction: 'sendonly',
@@ -2826,6 +2988,27 @@
}
}
+ // shareTxLost: this connection's share transceiver is no longer the
+ // one carrying our display track. A rollback can stop it, or give its
+ // m-line to the description that rolled us back — and a transceiver
+ // that is not sending our track is not a share transceiver, however
+ // much it looks like one. Pure, and lifted by name in the node test.
+ function shareTxLost(tx, track) {
+ if (!tx) return false;
+ if (tx.stopped) return true;
+ return !(tx.sender && tx.sender.track === track);
+ }
+
+ // regainShare puts the screen back on a connection that lost it in a
+ // rollback. Called where the rollback happens — inside onOffer, on the
+ // far side of setRemoteDescription — and a no-op for every connection
+ // that kept it.
+ function regainShare(entry) {
+ if (!share || !entry) return;
+ if (shareTxLost(entry.shareTx, share.track)) entry.shareTx = null;
+ addShareTo(entry);
+ }
+
// stopSharing is the off half, and every way out uses it: the button,
// the browser's own stop bar, and the end of the call.
function stopSharing() {
@@ -2838,7 +3021,13 @@
// Last, so the transceivers are stopped and the message is queued
// before the capture goes: stopping the track first would fire its
// own `ended` into a path that has already run.
- for (const t of held.stream.getTracks()) {
+ stopStream(held.stream);
+ }
+
+ // stopStream ends a capture, whether it was ever used or not.
+ function stopStream(stream) {
+ if (!stream) return;
+ for (const t of stream.getTracks()) {
try {
t.stop();
} catch {
@@ -3399,6 +3588,23 @@
video.requestVideoFrameCallback(step);
}
+ // frameKey places one inbound video report on one tile. A peer that is
+ // sharing sends two video streams, and the loop below used to write
+ // both under the peer's own key — so the camera's count was whatever
+ // the last report happened to be, and the screen had none at all.
+ // Pure, and lifted by name in the node test.
+ //
+ // The empty string means "cannot say": a report with no mid, on a
+ // connection that has two video tiles, is left out rather than
+ // credited to the wrong one. A connection with one tile is not
+ // ambiguous, so a missing mid there still counts.
+ function frameKey(id, entry, mid) {
+ if (mid && entry.shareMid && mid === entry.shareMid) return shareKey(id);
+ if (mid && entry.cameraMid && mid === entry.cameraMid) return id;
+ if (!entry.share && !entry.sharing) return id;
+ return '';
+ }
+
async function pollFrames() {
// The fallback for a browser without requestVideoFrameCallback:
// framesDecoded off the inbound video stats.
@@ -3407,10 +3613,12 @@
try {
const stats = await entry.pc.getStats();
stats.forEach((report) => {
- if (report.type === 'inbound-rtp' && report.kind === 'video' &&
- typeof report.framesDecoded === 'number') {
- frames.set(id, report.framesDecoded);
+ if (report.type !== 'inbound-rtp' || report.kind !== 'video' ||
+ typeof report.framesDecoded !== 'number') {
+ return;
}
+ const key = frameKey(id, entry, report.mid || '');
+ if (key) frames.set(key, report.framesDecoded);
});
} catch {
// getStats on a closing connection: nothing to report.
diff --git a/test/browser/negotiation.test.mjs b/test/browser/negotiation.test.mjs
index 945b2dc..0e751a3 100644
--- a/test/browser/negotiation.test.mjs
+++ b/test/browser/negotiation.test.mjs
@@ -87,7 +87,7 @@ test('a remote offer already applied is glare', () => {
// when the next offer arrives, which is the race Codex S2-7 named and
// which the six cases above would pass without.
const handlers = new Function(
- 'accept', 'send', 'describe', 'drainCandidates',
+ 'accept', 'send', 'describe', 'drainCandidates', 'regainShare',
[lift(src, 'offerCollision'), lift(src, 'onOffer'), lift(src, 'onAnswer')].join('\n') +
'\nreturn { onOffer, onAnswer };',
);
@@ -142,6 +142,7 @@ test('an offer arriving while an answer is being applied is answered, not discar
(to, type) => sent.push(type),
(d) => d,
async () => {},
+ () => {},
);
// The answer goes in and is left half-applied: its promise is still
@@ -162,6 +163,42 @@ test('an offer arriving while an answer is being applied is answered, not discar
assert.equal(e.isSettingRemoteAnswerPending, false, 'the flag must be cleared once the answer is applied');
});
+test('a screen lost in the polite side\u2019s rollback is asked for again, before the answer', () => {
+ // §5.4: the implicit rollback lives inside setRemoteDescription, and a
+ // share transceiver that was still unassociated can come out of it
+ // stopped or with its m-line given to the offer that rolled us back.
+ // Nothing else in this file runs at that instant, so the check belongs
+ // here — and before the answer goes out, so the description this side
+ // sends already carries the screen again.
+ const pc = fakePC();
+ pc.signalingState = 'stable';
+ const e = {
+ pc,
+ polite: true,
+ makingOffer: false,
+ ignoreOffer: false,
+ isSettingRemoteAnswerPending: false,
+ pending: [],
+ descSeq: 0,
+ };
+ const order = [];
+ const { onOffer } = handlers(
+ () => e,
+ (to, type) => order.push('sent:' + type),
+ (d) => d,
+ async () => {},
+ (entry) => {
+ order.push('regain');
+ assert.equal(entry, e, 'the share was regained for some other connection');
+ },
+ );
+
+ return onOffer({ from: 'other', seq: 1, payload: { description: { type: 'offer', sdp: 'o' } } })
+ .then(() => {
+ assert.deepEqual(order, ['regain', 'sent:answer']);
+ });
+});
+
// The recovery ladder, lifted the same way. This one is not about
// negotiation at all — it lives here because this file is where room.js
// can be run without a browser, and because a browser scenario cannot
diff --git a/test/browser/room-lane.test.mjs b/test/browser/room-lane.test.mjs
new file mode 100644
index 0000000..b9c3e9b
--- /dev/null
+++ b/test/browser/room-lane.test.mjs
@@ -0,0 +1,460 @@
+// room-lane.test.mjs — the two pieces of room.js that are about
+// *sequencing* rather than about a value, driven without a browser.
+//
+// node --test test/browser/room-lane.test.mjs
+//
+// room-share.test.mjs lifts pure functions and checks their answers.
+// These three cannot be gated that way: the broadcast lane's contract is
+// that every sender is answered exactly once however the lane ends,
+// screen share's is that one gesture takes one capture however many
+// times the button is pressed, and the chat panel's door is about what
+// is on the screen afterwards. All three are properties of what happens
+// rather than of a value, so all three are lifted whole — with their
+// free variables handed in as arguments, the way negotiation.test.mjs
+// drives `reconcile` — and run against stubs that record what happened.
+//
+// Nothing here sleeps. The lane's own deadline arithmetic is driven by
+// handing it a SEND_DEADLINE of zero, and its backoff by a `waitWithin`
+// that answers the same question the real one does without waiting.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const roomJS = fileURLToPath(new URL('../../internal/meet/static/room.js', import.meta.url));
+const src = readFileSync(roomJS, 'utf8');
+
+function lift(source, name) {
+ let start = source.indexOf('function ' + name + '(');
+ assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing');
+ if (source.slice(start - 6, start) === 'async ') start -= 6;
+ let depth = 0;
+ for (let i = source.indexOf('{', start); i < source.length; i++) {
+ if (source[i] === '{') depth += 1;
+ else if (source[i] === '}') {
+ depth -= 1;
+ if (depth === 0) return source.slice(start, i + 1);
+ }
+ }
+ assert.fail('unbalanced braces reading ' + name + ' out of room.js');
+}
+
+const quiet = { error() {} };
+
+// ---------------------------------------------------------------
+// The broadcast lane
+// ---------------------------------------------------------------
+
+// Everything the lane is made of, lifted together: the queue, the pump,
+// the two ways a message is answered, and the three pure judgements the
+// pump reads. Only what the lane calls *out* to is stubbed.
+const laneSource = [
+ 'broadcast', 'runBroadcast', 'pumpBroadcast', 'settleBroadcast', 'drainBroadcast',
+ 'broadcastBody', 'broadcastVerdict', 'accepted', 'recoveryDeadline',
+].map((name) => lift(src, name)).join('\n');
+
+const laneBox = new Function(
+ 'state', 'queue', 'inflight', 'BROADCAST', 'BACKOFF_MIN', 'BACKOFF_MAX',
+ 'SEND_DEADLINE', 'MIN_RECOVERY', 'postSignal', 'retryAfter', 'waitWithin',
+ 'terminal', 'recoverFromNotFound', 'console',
+ laneSource + '\nreturn { broadcast };',
+);
+
+// lane builds one lane over stubs. `answers` is a list of responses, one
+// per POST, in order; the last one is repeated if the lane asks again.
+function lane(answers, opts) {
+ const o = opts || {};
+ const posted = [];
+ let live = 0;
+ let most = 0;
+ const state = { peer: 'a'.repeat(32), stopped: false };
+ const events = [];
+ const box = laneBox(
+ state,
+ new Map(),
+ new Set(),
+ '*',
+ 1,
+ 2,
+ o.deadline === undefined ? 30000 : o.deadline,
+ 5000,
+ async (body) => {
+ posted.push(body);
+ live += 1;
+ most = Math.max(most, live);
+ await Promise.resolve();
+ live -= 1;
+ const at = Math.min(posted.length - 1, answers.length - 1);
+ return Object.assign(
+ { status: 200, data: {}, signedOut: false, retryAfter: null },
+ answers[at] || {},
+ );
+ },
+ () => 0,
+ // waitWithin, without the waiting: the real one sleeps and then
+ // answers whether there is budget left, and only the answer is what
+ // the pump branches on.
+ async (ms, deadline) => Date.now() < deadline,
+ (reason) => { events.push('terminal:' + reason); state.stopped = true; },
+ o.recover || (async () => { events.push('recovered'); }),
+ quiet,
+ );
+ return { broadcast: box.broadcast, posted, events, state, inFlight: () => most };
+}
+
+// settled turns a promise into its value or the word 'pending', so a
+// test can assert that a sender was answered at all — which is the whole
+// contract — without hanging when it was not.
+async function settled(p) {
+ const pending = Symbol('pending');
+ const answer = await Promise.race([
+ p,
+ new Promise((resolve) => setTimeout(() => resolve(pending), 50)),
+ ]);
+ return answer === pending ? 'pending' : answer;
+}
+
+test('the lane holds one POST in flight and sends in order', async () => {
+ const l = lane([{ status: 200 }]);
+ const answers = await Promise.all([
+ l.broadcast('chat', { text: 'one' }),
+ l.broadcast('chat', { text: 'two' }),
+ l.broadcast('chat', { text: 'three' }),
+ ]);
+ assert.deepEqual(answers, [true, true, true]);
+ assert.equal(l.inFlight(), 1, 'two POSTs were in flight at once');
+ assert.deepEqual(l.posted.map((b) => b.payload.text), ['one', 'two', 'three']);
+ // §4.3's wire shape, asserted on what actually went out rather than on
+ // the builder alone.
+ for (const body of l.posted) {
+ assert.deepEqual(Object.keys(body).sort(), ['payload', 'peer', 'type']);
+ }
+});
+
+test('a message with no budget left is answered false, and the lane carries on', async () => {
+ // SEND_DEADLINE of zero: the head's deadline is stamped and already
+ // spent, which is the arm a slow POST leaves the *next* message in.
+ const l = lane([{ status: 200 }], { deadline: 0 });
+ const first = await settled(l.broadcast('chat', { text: 'one' }));
+ const second = await settled(l.broadcast('chat', { text: 'two' }));
+ assert.equal(first, false);
+ assert.equal(second, false);
+ assert.equal(l.posted.length, 0, 'nothing is posted with no budget to post it in');
+});
+
+test('every ending answers the sender, and only a 200 answers true', async () => {
+ for (const [status, want] of [[200, true], [400, false], [413, false], [409, false]]) {
+ const l = lane([{ status }]);
+ assert.equal(await settled(l.broadcast('chat', { text: 'x' })), want, 'status ' + status);
+ }
+});
+
+test('a signed-out answer ends the call and answers everyone waiting', async () => {
+ const l = lane([{ status: 401, signedOut: true }]);
+ const a = l.broadcast('chat', { text: 'one' });
+ const b = l.broadcast('chat', { text: 'two' });
+ assert.deepEqual([await settled(a), await settled(b)], [false, false]);
+ assert.deepEqual(l.events, ['terminal:signedout']);
+});
+
+test('a sender queued during a 404 recovery that ends the session is still answered', async () => {
+ // The defect this test exists for: the 404 arm empties the lane,
+ // answers everyone in it and then awaits the diagnosis. Anything sent
+ // *during* that diagnosis lands in a fresh queue with the pump still
+ // marked in flight — so it waited for a pump that had already
+ // returned. If the diagnosis ended the session, nobody was ever going
+ // to send it, and the composer it came from never came back.
+ let during = null;
+ const l = lane([{ status: 404, data: { boot: 'b1' } }], {
+ recover: async () => {
+ during = l.broadcast('chat', { text: 'typed while the tab was being diagnosed' });
+ l.state.stopped = true;
+ },
+ });
+ const first = l.broadcast('chat', { text: 'the one that got the 404' });
+ assert.equal(await settled(first), false);
+ assert.equal(await settled(during), false, 'the sender queued during the recovery was never answered');
+});
+
+test('a sender queued during a recovery that leaves the tab alive is sent', async () => {
+ // The other half: the diagnosis found nothing wrong, so the message
+ // typed during it goes out on the pump the `finally` restarts.
+ let during = null;
+ const l = lane([{ status: 404, data: { boot: 'b1' } }, { status: 200 }], {
+ recover: async () => {
+ during = l.broadcast('chat', { text: 'still here' });
+ },
+ });
+ await settled(l.broadcast('chat', { text: 'the one that got the 404' }));
+ assert.equal(await settled(during), true);
+ assert.deepEqual(l.posted.map((b) => b.payload.text),
+ ['the one that got the 404', 'still here']);
+});
+
+test('a lane that has stopped answers everything left in it', async () => {
+ const l = lane([{ status: 401, signedOut: true }]);
+ const many = [];
+ for (let i = 0; i < 5; i++) many.push(l.broadcast('chat', { text: 'm' + i }));
+ const answers = await Promise.all(many.map(settled));
+ assert.deepEqual(answers, [false, false, false, false, false]);
+});
+
+test('nothing is queued once the call is over', async () => {
+ const l = lane([{ status: 200 }]);
+ l.state.stopped = true;
+ assert.equal(await settled(l.broadcast('chat', { text: 'x' })), false);
+ assert.equal(l.posted.length, 0);
+});
+
+// ---------------------------------------------------------------
+// Screen share: one gesture, one capture
+// ---------------------------------------------------------------
+
+const shareBox = new Function(
+ 'state', 'HAS_DISPLAY_MEDIA', 'navigator', 'pcs', 'addShareTo', 'removeShareFrom',
+ 'renderShare', 'broadcast', 'say', 'SHARE_REFUSED',
+ 'let share = null; let sharePending = false;\n' +
+ [lift(src, 'startSharing'), lift(src, 'stopSharing'), lift(src, 'stopStream')].join('\n') +
+ '\nreturn { startSharing, stopSharing, capture: () => share };',
+);
+
+function fakeTrack(id) {
+ return {
+ id,
+ kind: 'video',
+ readyState: 'live',
+ addEventListener() {},
+ stop() { this.readyState = 'ended'; },
+ };
+}
+
+function fakeStream(id) {
+ const tracks = [fakeTrack(id + '-video')];
+ return {
+ id,
+ tracks,
+ getTracks: () => tracks,
+ getVideoTracks: () => tracks.filter((t) => t.kind === 'video'),
+ };
+}
+
+// sharer builds the share half over stubs, with a picker whose answer is
+// released by hand — which is the whole point: the button is live while
+// the real one is open.
+function sharer(opts) {
+ const o = opts || {};
+ const captures = [];
+ const added = [];
+ const removed = [];
+ const sent = [];
+ const said = [];
+ const state = { stopped: false };
+ let release = null;
+ const opened = new Promise((resolve) => { release = resolve; });
+ const box = shareBox(
+ state,
+ true,
+ {
+ mediaDevices: {
+ async getDisplayMedia() {
+ const stream = fakeStream('capture-' + (captures.length + 1));
+ captures.push(stream);
+ await opened;
+ if (o.refuse) throw new Error('NotAllowedError');
+ return stream;
+ },
+ },
+ },
+ new Map([['peer1', { pc: {} }]]),
+ (entry) => added.push(entry),
+ (entry) => removed.push(entry),
+ () => {},
+ (type, payload) => { sent.push([type, payload.on]); return Promise.resolve(true); },
+ (text) => said.push(text),
+ 'Screen sharing was not allowed.',
+ );
+ return Object.assign(box, { captures, added, removed, sent, said, state, release });
+}
+
+test('a second click while the picker is open takes no second capture', async () => {
+ // The defect: the picker is open for as long as a person takes, and a
+ // second click during it used to start a second capture. The peers
+ // would be watching the first, `share` would point at the second, and
+ // Off would stop only the one nobody could see.
+ const s = sharer();
+ const first = s.startSharing();
+ const second = s.startSharing();
+ s.release();
+ await first;
+ await second;
+
+ assert.equal(s.captures.length, 1, 'two clicks, two captures');
+ assert.equal(s.added.length, 1, 'the screen was added to the connection twice');
+ assert.deepEqual(s.sent, [['share', true]], 'the room was told twice that a screen went on');
+ assert.equal(s.capture().stream, s.captures[0]);
+});
+
+test('and Off then stops the capture that the peers were actually watching', async () => {
+ const s = sharer();
+ const first = s.startSharing();
+ const second = s.startSharing();
+ s.release();
+ await first;
+ await second;
+
+ s.stopSharing();
+
+ assert.deepEqual(s.sent, [['share', true], ['share', false]]);
+ assert.equal(s.removed.length, 1);
+ for (const capture of s.captures) {
+ for (const t of capture.getTracks()) {
+ assert.equal(t.readyState, 'ended', 'a capture was left running with nothing to stop it');
+ }
+ }
+ assert.equal(s.capture(), null);
+});
+
+test('a capture that arrives after the call ended is stopped, not sent', async () => {
+ // The picker was open when the meeting ended. Nothing can be
+ // negotiated and nothing can be announced, and a capture nobody stops
+ // is a screen still being read from by a tab that has given up.
+ const s = sharer();
+ const start = s.startSharing();
+ s.state.stopped = true;
+ s.release();
+ await start;
+
+ assert.equal(s.capture(), null);
+ assert.deepEqual(s.sent, [], 'a share was announced for a call that had ended');
+ assert.deepEqual(s.added, []);
+ assert.equal(s.captures[0].getTracks()[0].readyState, 'ended');
+});
+
+test('a refused picker says so and leaves the button ready to try again', async () => {
+ const s = sharer({ refuse: true });
+ const first = s.startSharing();
+ s.release();
+ await first;
+
+ assert.deepEqual(s.said, ['Screen sharing was not allowed.']);
+ assert.equal(s.capture(), null);
+ assert.deepEqual(s.sent, []);
+
+ // And the pending flag was cleared, or the button would be dead for
+ // the rest of the call.
+ const again = s.startSharing();
+ await again;
+ assert.equal(s.captures.length, 2, 'a refusal left the Share button permanently pending');
+});
+
+// ---------------------------------------------------------------
+// The chat panel's door
+// ---------------------------------------------------------------
+
+const doorBox = new Function(
+ 'chatEl', 'window', 'selfName', 'sendChat', 'state',
+ 'let chat = null;\n' +
+ [lift(src, 'mountChat'), lift(src, 'chatEnabled')].join('\n') +
+ '\nreturn { mountChat, chatEnabled, panel: () => chat };',
+);
+
+// A root with exactly the hooks chat.js looks for, minus whichever ones
+// a test takes away. No innerHTML anywhere, deliberately: the fake has
+// none, so anything built by assigning markup would read back empty.
+function fakeRoot(missing) {
+ const gone = new Set(missing || []);
+ return {
+ hidden: true,
+ querySelector(sel) {
+ return gone.has(sel) ? null : { sel };
+ },
+ };
+}
+
+function door(root, opts) {
+ const o = opts || {};
+ const mounted = [];
+ const enabled = [];
+ const panel = {
+ append() {},
+ setEnabled(on) { enabled.push(on); },
+ clear() {},
+ };
+ const win = o.noChat ? {} : {
+ Chat: {
+ mount(r, options) {
+ mounted.push({ root: r, name: options.name });
+ return o.noop ? null : panel;
+ },
+ },
+ };
+ const box = doorBox(
+ root,
+ win,
+ 'Ann',
+ async () => true,
+ { admitted: o.admitted !== false, stopped: !!o.stopped },
+ );
+ return Object.assign(box, { mounted, enabled });
+}
+
+test('a whole panel is mounted, named and un-hidden', () => {
+ const root = fakeRoot();
+ const d = door(root);
+ assert.notEqual(d.mountChat(), null);
+ assert.equal(d.mounted.length, 1);
+ assert.equal(d.mounted[0].root, root);
+ assert.equal(d.mounted[0].name, 'Ann', 'the local echo would have no name on it');
+ assert.equal(root.hidden, false);
+ assert.deepEqual(d.enabled, [true]);
+});
+
+test('a panel chat.js could only no-op on is never un-hidden', () => {
+ // chat.js answers a root it cannot use with a working no-op rather
+ // than a throw — right for it, and not something to trust here: an
+ // un-hidden composer backed by a no-op swallows everything typed into
+ // it in silence, and every test of the panel still passes.
+ for (const hook of ['[data-chat-log]', '[data-chat-form]', '[data-chat-text]']) {
+ const root = fakeRoot([hook]);
+ const d = door(root);
+ assert.equal(d.mountChat(), null, 'mounted over a missing ' + hook);
+ assert.deepEqual(d.mounted, [], 'chat.js was called for a root it cannot use: ' + hook);
+ assert.equal(root.hidden, true, 'a panel with no ' + hook + ' was put on the screen');
+ }
+});
+
+test('no chat.js at all leaves the panel hidden', () => {
+ // The form would otherwise submit and navigate away from the call.
+ const root = fakeRoot();
+ const d = door(root, { noChat: true });
+ assert.equal(d.mountChat(), null);
+ assert.equal(root.hidden, true);
+});
+
+test('a panel is mounted once, however often admission says so', () => {
+ const root = fakeRoot();
+ const d = door(root);
+ const first = d.mountChat();
+ assert.equal(d.mountChat(), first);
+ assert.equal(d.mountChat(), first);
+ assert.equal(d.mounted.length, 1);
+});
+
+test('a panel mounted before admission starts disabled', () => {
+ // The window this exists for: a `chat` fanned out to this tab while
+ // §5.2's fetch still has its own roster waiting behind it. The message
+ // is shown, and the composer opens when the roster says it may.
+ const root = fakeRoot();
+ const d = door(root, { admitted: false });
+ d.mountChat();
+ assert.deepEqual(d.enabled, [false]);
+ d.chatEnabled(true);
+ assert.deepEqual(d.enabled, [false, true]);
+});
+
+test('chatEnabled is silent with no panel to enable', () => {
+ const d = door(fakeRoot(), { noChat: true });
+ d.chatEnabled(true);
+ d.chatEnabled(false);
+ assert.deepEqual(d.enabled, []);
+});
diff --git a/test/browser/room-share.test.mjs b/test/browser/room-share.test.mjs
index 665352b..db15c02 100644
--- a/test/browser/room-share.test.mjs
+++ b/test/browser/room-share.test.mjs
@@ -16,10 +16,14 @@
// classifyTrack a peer's screen told from its camera
// shareKey the tile key a screen gets, which must never
// collide with a peer id
+// shareTxLost a share transceiver a rollback took away
+// frameKey which of a peer's two video tiles a stats report
+// belongs to
// broadcastBody the wire shape of an untargeted message — the
// contract is what is *not* in it
-// broadcastVerdict every status the wire contract names, and
-// accepted the strict boolean chat.js echoes on
+// broadcastVerdict every status the wire contract names,
+// accepted the strict boolean chat.js echoes on, and
+// recoveryDeadline the floor under a 404's diagnosis
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -53,18 +57,32 @@ function readConst(source, name) {
return m[1];
}
+// readNumber does the same for a number, so no timing constant is
+// retyped here either.
+function readNumber(source, name) {
+ const m = new RegExp('const ' + name + ' = (\\d+);').exec(source);
+ assert.notEqual(m, null, name + ' is not a number constant in room.js any more');
+ return Number(m[1]);
+}
+
const SHARE_SUFFIX = readConst(src, 'SHARE_SUFFIX');
const BROADCAST = readConst(src, 'BROADCAST');
+const MIN_RECOVERY = readNumber(src, 'MIN_RECOVERY');
const build = (name, prelude) =>
// eslint-disable-next-line no-new-func
new Function((prelude || '') + lift(src, name) + '; return ' + name + ';')();
-const shareKey = build('shareKey', 'const SHARE_SUFFIX = ' + JSON.stringify(SHARE_SUFFIX) + ';');
+const suffix = 'const SHARE_SUFFIX = ' + JSON.stringify(SHARE_SUFFIX) + ';';
+const shareKey = build('shareKey', suffix);
const classifyTrack = build('classifyTrack');
+const shareTxLost = build('shareTxLost');
+const frameKey = build('frameKey', suffix + lift(src, 'shareKey'));
const broadcastBody = build('broadcastBody');
const broadcastVerdict = build('broadcastVerdict');
const accepted = build('accepted');
+const recoveryDeadline = build('recoveryDeadline',
+ 'const MIN_RECOVERY = ' + readNumber(src, 'MIN_RECOVERY') + ';');
// ---------------------------------------------------------------
// The two sentinels
@@ -106,47 +124,195 @@ test('two peers sharing at once keep their screens apart', () => {
// classifyTrack (§5.4, receiving)
// ---------------------------------------------------------------
-const nothing = { cameraMid: '', cameraStream: '' };
-const known = { cameraMid: '0', cameraStream: 'cam' };
+// A connection as the classifier sees one, an arriving track, and this
+// tab. Written out rather than defaulted, because every field of the
+// three is a signal and a silent default would hide which one decided.
+const peer = (o) => Object.assign(
+ { cameraMid: '', cameraStream: '', peerStream: '', sharing: false }, o || {});
+const track = (o) => Object.assign(
+ { kind: 'video', streamId: '', mid: '', direction: '' }, o || {});
+const me = (hasVideo) => ({ hasVideo: !!hasVideo });
+
+const nothing = peer();
+const known = peer({ cameraMid: '0', cameraStream: 'cam', peerStream: 'cam' });
test('audio is never a screen, whatever else is known', () => {
- assert.equal(classifyTrack(nothing, 'audio', 'cam', '1'), 'audio');
- assert.equal(classifyTrack(known, 'audio', 'other', '9'), 'audio');
+ assert.equal(classifyTrack(nothing, track({ kind: 'audio', streamId: 'cam', mid: '1' }), me(true)), 'audio');
+ assert.equal(classifyTrack(known, track({ kind: 'audio', streamId: 'other', mid: '9' }), me(true)), 'audio');
});
test('the first video track a peer sends is the camera', () => {
- assert.equal(classifyTrack(nothing, 'video', 'cam', '0'), 'camera');
+ assert.equal(classifyTrack(nothing, track({ streamId: 'cam', mid: '0' }), me(false)), 'camera');
// Even with nothing to compare at all: there is no second of
// something that has not arrived yet.
- assert.equal(classifyTrack(nothing, 'video', '', ''), 'camera');
+ assert.equal(classifyTrack(nothing, track(), me(false)), 'camera');
});
test('a second video track on another transceiver is the screen', () => {
- assert.equal(classifyTrack(known, 'video', 'screen', '2'), 'share');
+ assert.equal(classifyTrack(known, track({ streamId: 'screen', mid: '2' }), me(false)), 'share');
});
test('the camera track again is still the camera, not a screen', () => {
// A replaced camera track arrives on the same mid. Reading that as a
// screen would give the peer a second tile it never shared.
- assert.equal(classifyTrack(known, 'video', 'cam', '0'), 'camera');
+ assert.equal(classifyTrack(known, track({ streamId: 'cam', mid: '0' }), me(false)), 'camera');
// The mid is what decides when there is one: a camera whose stream id
// changed under a renegotiation is still the camera.
- assert.equal(classifyTrack(known, 'video', 'renamed', '0'), 'camera');
+ assert.equal(classifyTrack(known, track({ streamId: 'renamed', mid: '0' }), me(false)), 'camera');
});
test('with no mid to compare, the stream id decides', () => {
- const byStream = { cameraMid: '', cameraStream: 'cam' };
- assert.equal(classifyTrack(byStream, 'video', 'cam', ''), 'camera');
- assert.equal(classifyTrack(byStream, 'video', 'screen', ''), 'share');
+ const byStream = peer({ cameraStream: 'cam' });
+ assert.equal(classifyTrack(byStream, track({ streamId: 'cam' }), me(false)), 'camera');
+ assert.equal(classifyTrack(byStream, track({ streamId: 'screen' }), me(false)), 'share');
// A known mid but none on this track falls through to the stream id.
- assert.equal(classifyTrack(known, 'video', 'cam', ''), 'camera');
- assert.equal(classifyTrack(known, 'video', 'screen', ''), 'share');
+ assert.equal(classifyTrack(known, track({ streamId: 'cam' }), me(false)), 'camera');
+ assert.equal(classifyTrack(known, track({ streamId: 'screen' }), me(false)), 'share');
});
test('a second video track with nothing to compare is the screen', () => {
// A sender that named no stream and a browser that reports no mid:
// the camera is already known, so this is the screen.
- assert.equal(classifyTrack(known, 'video', '', ''), 'share');
+ assert.equal(classifyTrack(known, track(), me(false)), 'share');
+});
+
+// The blind spot the review found, and the three signals that close it.
+// startLocal has an audio-only rung — a camera in use, or gone — and a
+// peer on it has no camera m-line at all, so its *screen* is the first
+// video track its peers ever see.
+
+test('a recvonly video track is the screen when this tab has a camera of its own', () => {
+ // This tab builds a video m-line for every connection, sendrecv from
+ // its own camera. A video track arriving recvonly is therefore on an
+ // m-line the far side added, and the only one it adds is the screen —
+ // even as the first video track that peer has ever sent.
+ assert.equal(classifyTrack(nothing, track({ streamId: 'screen', mid: '2', direction: 'recvonly' }), me(true)),
+ 'share');
+});
+
+test('recvonly says nothing when this tab has no camera either', () => {
+ // With no local video this tab offers recvonly itself, so the peer's
+ // camera arrives recvonly too and the direction cannot tell them
+ // apart. The rule must not fire, or every camera becomes a screen.
+ assert.equal(classifyTrack(nothing, track({ streamId: 'cam', mid: '1', direction: 'recvonly' }), me(false)),
+ 'camera');
+});
+
+test('a sendrecv camera is a camera even while this tab has video', () => {
+ assert.equal(classifyTrack(nothing, track({ streamId: 'cam', mid: '1', direction: 'sendrecv' }), me(true)),
+ 'camera');
+});
+
+test('a video track in a stream the peer\u2019s audio never used is the screen', () => {
+ // The audio-only sharer, seen by a peer that also has no camera: no
+ // camera has ever arrived, so there is no cameraMid and no
+ // cameraStream — but its microphone came in a stream of its own, and a
+ // sender puts its camera in that same stream and its screen in
+ // another.
+ const audioOnly = peer({ peerStream: 'theirs' });
+ assert.equal(classifyTrack(audioOnly, track({ streamId: 'screen', mid: '2' }), me(false)), 'share');
+ // And the camera, when there is one, arrives in that same stream.
+ assert.equal(classifyTrack(audioOnly, track({ streamId: 'theirs', mid: '1' }), me(false)), 'camera');
+});
+
+test('the share message breaks the tie, and only the tie', () => {
+ // Nothing else can speak: no camera known, no stream on the track, no
+ // direction, nothing from this peer before. Then the `share {on:true}`
+ // that always precedes the media is the only evidence there is.
+ assert.equal(classifyTrack(peer({ sharing: true }), track(), me(false)), 'share');
+ assert.equal(classifyTrack(peer({ sharing: false }), track(), me(false)), 'camera');
+ // But it never overrides a signal that can speak. A peer that clicked
+ // share before its own first negotiation finished sends its camera and
+ // its screen in one description, camera first — and the camera is in
+ // the stream its microphone was in.
+ const sharingPeer = peer({ sharing: true, peerStream: 'theirs' });
+ assert.equal(classifyTrack(sharingPeer, track({ streamId: 'theirs', mid: '1' }), me(false)), 'camera');
+ // Nor does it override the camera's own mid.
+ assert.equal(classifyTrack(peer({ sharing: true, cameraMid: '1' }), track({ mid: '1' }), me(false)),
+ 'camera');
+});
+
+// ---------------------------------------------------------------
+// shareTxLost (§5.4, sending: the rollback)
+// ---------------------------------------------------------------
+
+const display = { id: 'display' };
+
+test('a transceiver still sending our display track is not lost', () => {
+ assert.equal(shareTxLost({ stopped: false, sender: { track: display } }, display), false);
+});
+
+test('a stopped transceiver is lost', () => {
+ assert.equal(shareTxLost({ stopped: true, sender: { track: display } }, display), true);
+});
+
+test('a transceiver whose m-line went to the offer that rolled us back is lost', () => {
+ // What a rollback leaves behind: the transceiver is still there and
+ // still ours to read, but it is not carrying the screen any more.
+ assert.equal(shareTxLost({ stopped: false, sender: { track: null } }, display), true);
+ assert.equal(shareTxLost({ stopped: false, sender: { track: { id: 'camera' } } }, display), true);
+ assert.equal(shareTxLost({ stopped: false, sender: null }, display), true);
+});
+
+test('no transceiver at all is not a loss — there was nothing to lose', () => {
+ // The difference matters: `false` here means addShareTo decides, and
+ // `true` would mean clearing a field that is already empty.
+ assert.equal(shareTxLost(null, display), false);
+ assert.equal(shareTxLost(undefined, display), false);
+});
+
+// ---------------------------------------------------------------
+// frameKey (the getStats fallback, two video tiles)
+// ---------------------------------------------------------------
+
+const sharingEntry = { cameraMid: '1', shareMid: '2', share: {}, sharing: true };
+
+test('a peer sharing a screen counts its two video streams apart', () => {
+ assert.equal(frameKey('p1', sharingEntry, '1'), 'p1');
+ assert.equal(frameKey('p1', sharingEntry, '2'), 'p1#share');
+});
+
+test('a report with no mid on a peer with two tiles is left out, not guessed', () => {
+ // Crediting it to either tile would be inventing a number; the
+ // requestVideoFrameCallback path, which every current browser takes,
+ // never needs this.
+ assert.equal(frameKey('p1', sharingEntry, ''), '');
+ assert.equal(frameKey('p1', sharingEntry, '9'), '');
+});
+
+test('a peer with one video tile is never ambiguous', () => {
+ const plain = { cameraMid: '1', shareMid: '', share: null, sharing: false };
+ assert.equal(frameKey('p1', plain, ''), 'p1');
+ assert.equal(frameKey('p1', plain, '1'), 'p1');
+ // A mid this connection has never seen, and only one tile to put it
+ // on: it is the camera's.
+ assert.equal(frameKey('p1', plain, '7'), 'p1');
+});
+
+test('a screen announced but not yet arriving does not take the camera\u2019s count', () => {
+ // `share {on:true}` has drawn the tile and the track is still a
+ // renegotiation away. Writing the camera's frames under a mid that has
+ // not been recorded yet would move them onto the empty tile.
+ const announced = { cameraMid: '1', shareMid: '', share: null, sharing: true };
+ assert.equal(frameKey('p1', announced, '1'), 'p1');
+ assert.equal(frameKey('p1', announced, ''), '');
+});
+
+// ---------------------------------------------------------------
+// recoveryDeadline (the 404's floor)
+// ---------------------------------------------------------------
+
+test('a diagnosis with budget left keeps the message\u2019s own deadline', () => {
+ assert.equal(recoveryDeadline(10000, 1000), 10000);
+});
+
+test('a diagnosis with no budget left gets a floor rather than nothing', () => {
+ // The POST that produced the 404 can spend the whole thirty seconds.
+ // A recovery handed a deadline in the past probes nothing, decides
+ // nothing, and leaves the tab looking live with no participation
+ // behind it.
+ assert.equal(recoveryDeadline(1000, 5000), 5000 + MIN_RECOVERY);
+ assert.equal(recoveryDeadline(5000, 5000), 5000 + MIN_RECOVERY);
});
// ---------------------------------------------------------------