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
6 files changed,
+939
−64
Makefile+11 −8docs/superpowers/copy/2026-09-10-slice-2-strings.md+1 −0internal/meet/static/room.js+245 −37test/browser/negotiation.test.mjs+38 −1test/browser/room-lane.test.mjs+460 −0test/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); |
| }); |
| // --------------------------------------------------------------- |