oficina / meet Public

room.js: chat over a broadcast lane, share as its own tx

Chat and share ride one queue keyed '*' with pump's discipline and none of §4.6's bookkeeping — no to, no seq, no gen — and every message resolves once, true only on a 200, because chat.js echoes a line into the log on exactly true. Screen share is one getDisplayMedia capture on a sendonly transceiver per connection, new connections included, stopped with transceiver.stop() so the receiver's track ends; a peer's screen is told from its camera by transceiver mid, then stream id, and gets a tile keyed peer#share captioned with its owner's name.

Three deviations. onChat mounts the panel if admission has not yet, because §5.2's fetch sits in front of the first roster and a chat can arrive in that window — dropping something a person typed to keep the ordering tidy is the wrong trade. A 409 on this lane is grouped with 400 and 413 as never-retried rather than left to the retry default: it cannot occur, and an impossible status should not cost a message thirty seconds. And the sharer gets no tile of their own screen — §5.4 shows the screens a grid receives, and the button already says this tab is sharing.

The Makefile gained the new node test, as Task 3's did.
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 11676c5deff5f9cd07136cda491af45fb4610850 parent 43d381c
7 files changed, +892 −19
  • Makefile +7 −5
  • docs/superpowers/copy/2026-09-10-slice-2-strings.md +21 −0
  • internal/meet/static/meet.css +10 −0
  • internal/meet/static/room.js +577 −6
  • internal/meet/templates/room.html +1 −0
  • test/browser/negotiation.test.mjs +55 −8
  • test/browser/room-share.test.mjs +221 −0

CI passed — run details

diff --git a/Makefile b/Makefile
index 962231d..32bae4f 100644
--- a/Makefile
+++ b/Makefile
@@ -128,17 +128,19 @@ 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.
-# negotiation.test.mjs and chat.test.mjs 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, and
-# §6's DOM safety, the log cap and §4.4's byte counter, in under a tenth
-# of a second between them. Named explicitly rather than by directory —
+# 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.
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
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 1164b29..1853bae 100644
--- a/docs/superpowers/copy/2026-09-10-slice-2-strings.md
+++ b/docs/superpowers/copy/2026-09-10-slice-2-strings.md
@@ -248,3 +248,24 @@ Neither sentence is carried by colour: `.chat-status` paints in
`--rst-text`, the ordinary text colour, and the weight it adds is a
second cue on top of the words rather than the signal itself
(AGENTS.md, "State — never colour alone").
+
+### The room: chat and screen share
+
+`internal/meet/static/room.js`, `internal/meet/templates/room.html` —
+the room's own half of the slice. These strings are Meet's, not the
+panel's: they name a control in this room and a thing that happened in
+this call.
+
+| String | Where | Note |
+| --- | --- | --- |
+| Share screen | `room.html`, `#share` in `#controls` | The verb and its object, because "Share" alone in a call could be a link, a file or a screen. The name never changes under the reader: the state moves in `aria-pressed` and in the aria-hidden span beside it, so the button is announced as the same control whether it is on or off — the rule the Microphone and Camera toggles already follow. The two device toggles carry the name again in `aria-label`; this one does not need to, because its visible text *is* the name and the only part that changes is hidden from the reader. |
+| On | `room.js`, `renderShare()` — `#share-state` | The word in the button while this tab is sharing. Same word as the other two toggles use for the same idea, on purpose. Never the whole signal: `aria-pressed` and meet.css's inset outline say it twice more, and neither is colour. |
+| Off | `room.html` and `room.js`, `renderShare()` — `#share-state` | The resting state, rendered by the server so a screen shows the truth before the script runs, and restored by `renderShare()` when sharing stops. |
+| *&lt;name&gt;*’s screen | `room.js`, `shareCaption()` — the `<figcaption>` of a share tile | The caption on the second tile a sharing peer gets. The name is the roster's, written with `textContent` like every other name in the room, and the apostrophe is the typographic one (U+2019) the rest of this app uses. A screen is captioned by whose it is, because in a grid of faces that is the only thing that tells two screens apart. Not "Screen" and not "Shared screen": both are true of every tile in the row. |
+| Screen sharing was not allowed. | `room.js`, `startSharing()` — `#status`, the room's own `role="status"` line | Shown when `getDisplayMedia` refuses: the person dismissed the browser's picker, or a policy forbade the capture. Past tense and passive on purpose — the browser refused, and this file cannot see which of the two it was, so it does not guess. It does not say "try again", because the button is still there and still says what it does. Cleared by the next thing the room has to say, like every other sentence on that line. |
+
+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. |
diff --git a/internal/meet/static/meet.css b/internal/meet/static/meet.css
index 4888cf4..81019b1 100644
--- a/internal/meet/static/meet.css
+++ b/internal/meet/static/meet.css
@@ -143,6 +143,16 @@
transform: scaleX(-1);
}
+/* A shared screen (spec §5.4) is not a face: the part of it that matters
+ is usually at the edges, so the whole screen is fitted into the tile
+ rather than cropped to fill it. The letterboxing this leaves is the
+ tile's own surface, so a wide screen in a 4:3 tile still reads as one
+ object and not as a video with holes in it. */
+.meet-tile-share video {
+ object-fit: contain;
+ background: var(--rst-surface-2);
+}
+
/* The toggles keep one accessible name (aria-label) and move only
aria-pressed, so the state is carried three ways and never by colour:
the word in .meet-toggle-state, the attribute a screen reader reads,
diff --git a/internal/meet/static/room.js b/internal/meet/static/room.js
index 966d3de..ee0f852 100644
--- a/internal/meet/static/room.js
+++ b/internal/meet/static/room.js
@@ -17,9 +17,16 @@
// §5.3 renewal per connection, at the server's `renewAt`, and
// complete only when a new ICE username fragment
// is on the selected candidate pair
+// §5.4 screen share getDisplayMedia as one sendonly transceiver
+// per connection, a `share` message so peers can
+// label the tile, and a stop that ends the
+// receiver's track rather than muting it
// §5.5 the floor an empty list is the floor: host candidates
// only, and the failed tile says whether a relay
// was there to fail
+// §6 chat the broadcast lane and the panel: chat.js owns
+// the words and the log, this file owns the wire
+// and when the panel is usable
//
// House rules this file is written under: no library, no inline script
// (the page's CSP is `default-src 'self'`), nothing built with
@@ -53,8 +60,13 @@
const muteButton = $('mute');
const cameraButton = $('camera');
const leaveButton = $('leave');
+ const shareButton = $('share');
const muteState = $('mute-state');
const cameraState = $('camera-state');
+ const shareState = $('share-state');
+ // The chat panel's root (§6). chat.js owns everything inside it; this
+ // file owns the wire under it and the moment it becomes usable.
+ const chatEl = $('chat');
const DEVICE_KEY = 'meet.devices';
const BACKOFF_MIN = 500;
@@ -111,6 +123,15 @@
// state().ice rather than looking idle for no reason (W3-12).
const MIN_MARGIN = 1000;
const PEER_ID = /^[0-9a-f]{32}$/;
+ // §4.3's two untargeted messages ride one queue of their own, and this
+ // is its key: not a peer id, and it cannot collide with one, because
+ // every peer id is thirty-two hex characters.
+ const BROADCAST = '*';
+ // §5.4: the tile key a peer's screen gets, built by shareKey.
+ const SHARE_SUFFIX = '#share';
+ const HAS_DISPLAY_MEDIA = typeof navigator !== 'undefined' &&
+ !!navigator.mediaDevices &&
+ typeof navigator.mediaDevices.getDisplayMedia === 'function';
const HAS_SINK_ID = typeof HTMLMediaElement !== 'undefined' &&
typeof HTMLMediaElement.prototype.setSinkId === 'function';
@@ -175,6 +196,17 @@
let pendingRosterAt = 0; // the tick that roster arrived on
let tick = 0;
let local = null; // the local MediaStream, or null
+ // §5.4: the display capture while this tab is sharing, and nothing
+ // 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
+ // §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
+ // without knowing the panel's own markup.
+ let chat = null;
+ let chatSent = 0;
+ let chatReceived = 0;
let prefs = loadPrefs();
let es = null; // the live EventSource
let backoff = BACKOFF_MIN;
@@ -301,7 +333,11 @@
// ---------------------------------------------------------------
const DOWNSTREAM = ['hello', 'roster', 'peer', 'gone', 'knock', 'knock-gone',
- 'full', 'lost', 'pair', 'gap', 'state', 'offer', 'answer', 'candidate'];
+ 'full', 'lost', 'pair', 'gap', 'state', 'offer', 'answer', 'candidate',
+ // §4.3's fan-out pair. They carry no `to`, no `seq` and no `gen`, so
+ // nothing about them goes through §4.6's inbound rules: the hub
+ // rebuilt the envelope and there is nothing to dedupe against.
+ 'chat', 'share'];
function streamPath(peer) {
let path = '/m/' + encodeURIComponent(sqid) + '/stream';
@@ -601,6 +637,8 @@
case 'offer': return onOffer(msg);
case 'answer': return onAnswer(msg);
case 'candidate': return onCandidate(msg);
+ case 'chat': return onChat(msg);
+ case 'share': return onShare(msg);
default: return undefined;
}
}
@@ -846,6 +884,11 @@
names.set(w.peer, w.name || '');
return { peer: w.peer, name: w.name || '' };
});
+ // §6: a roster is admission, and admission is when the chat panel
+ // appears and becomes usable. Mounting is idempotent, so every later
+ // roster costs one `setEnabled(true)` and nothing else.
+ mountChat();
+ chatEnabled(true);
renderTiles();
renderQueue();
renderStatus();
@@ -948,6 +991,21 @@
delivered: 0,
received: 0,
added: 0,
+ // §5.4, sending: the transceiver this connection carries our
+ // display track on, while we are sharing.
+ shareTx: null,
+ // §5.4, receiving: what this peer's camera came in on, and the
+ // screen once it arrives. `cameraMid` and `cameraStream` are
+ // recorded from the *first* video track — the camera, by
+ // definition — and `classifyTrack` reads them to tell the second
+ // video track apart from it. `sharing` is the `share {on:true}`
+ // message, which usually beats the track here by a renegotiation,
+ // and is what lets the tile carry the label before there is
+ // anything to show in it.
+ cameraMid: '',
+ cameraStream: '',
+ sharing: false,
+ share: null,
};
pcs.set(id, entry);
@@ -981,9 +1039,7 @@
pc.addEventListener('track', (ev) => {
if (pcs.get(id) !== entry) return;
- entry.stream = (ev.streams && ev.streams[0]) || entry.stream || new MediaStream([ev.track]);
- if (!ev.streams || !ev.streams.length) entry.stream.addTrack(ev.track);
- renderTiles();
+ onRemoteTrack(id, entry, ev);
});
pc.addEventListener('connectionstatechange', () => {
@@ -1029,6 +1085,11 @@
// offered to, which the other side will do if it has media.
}
}
+ // §5.4: a connection built while this tab is already sharing gets
+ // 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.
+ addShareTo(entry);
return entry;
}
@@ -1069,6 +1130,7 @@
lastIn.delete(id);
names.delete(id);
frames.delete(id);
+ frames.delete(shareKey(id));
}
}
@@ -2132,6 +2194,148 @@
requestReset(to);
}
+ // ---------------------------------------------------------------
+ // The broadcast lane (§4.3's chat and share)
+ // ---------------------------------------------------------------
+ //
+ // `send` above is §4.6: per target, with a seq, a generation and a
+ // pair to reset when it stops working. Chat and screen share are
+ // neither targeted nor paired — the hub fans them out to every other
+ // admitted peer and rebuilds the envelope itself — so they ride a
+ // queue of their own under the sentinel key '*', with the same pump
+ // discipline and none of §4.6's bookkeeping: no `to`, no `seq`, no
+ // `gen` on the wire, and `stale`/`gen` mean nothing here.
+ //
+ // The one thing this lane has that the other does not is an answer:
+ // the chat panel echoes a line into the log only when the hub took
+ // it, so every message resolves exactly once, and only a 200 resolves
+ // true.
+
+ // broadcastBody is the whole wire shape of an untargeted message. It
+ // is its own function because what is *not* in it is the contract:
+ // any `to`, `seq` or `gen` here is a 400 from /signal.
+ function broadcastBody(peer, type, payload) {
+ return { peer, type, payload };
+ }
+
+ // broadcastVerdict reads one POST answer, in the wire contract's own
+ // terms. Pure, so the node test can lift it by name.
+ function broadcastVerdict(res) {
+ // signedOut before the status, for the reason pump gives: an
+ // expired session answers 303 to /signin and fetch follows it, so
+ // the sign-in page comes back 200.
+ if (res.signedOut) return 'signedout';
+ if (res.status === 200) return 'sent';
+ if (res.status === 429) return 'wait';
+ if (res.status === 404) return 'recover';
+ // 400 is a bug in this file, 413 is a message the server will never
+ // take however often it is offered, and 409 belongs to the targeted
+ // lane and cannot mean anything here. None of the three is retried.
+ if (res.status === 400 || res.status === 413 || res.status === 409) return 'drop';
+ // A 5xx, a network failure, or a status the contract does not name:
+ // worth trying again inside the deadline.
+ return 'retry';
+ }
+
+ // accepted is the answer the sender gets, and the only true in it is
+ // a 200. chat.js echoes a line into the log on exactly `true` and on
+ // nothing else, so anything looser here would put a message on the
+ // screen that nobody received.
+ function accepted(verdict) {
+ return verdict === 'sent';
+ }
+
+ // 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.
+ function broadcast(type, payload) {
+ if (!state.peer || state.stopped) return Promise.resolve(false);
+ return new Promise((resolve) => {
+ const q = queue.get(BROADCAST) || [];
+ q.push({ body: broadcastBody(state.peer, type, payload), resolve });
+ queue.set(BROADCAST, q);
+ pumpBroadcast();
+ });
+ }
+
+ // pumpBroadcast is pump's discipline for the lane with no target: one
+ // POST in flight, in order, each message with its own absolute thirty
+ // seconds covering the requests, the backoff and the 429s.
+ async function pumpBroadcast() {
+ if (inflight.has(BROADCAST)) return;
+ inflight.add(BROADCAST);
+ let wait = BACKOFF_MIN;
+ try {
+ for (;;) {
+ const q = queue.get(BROADCAST);
+ if (!q || !q.length) return;
+ if (state.stopped) {
+ // Nothing more will be sent, and a sender waiting on a
+ // promise that never settles is a chat box that never comes
+ // back. Answer every one of them.
+ drainBroadcast(q);
+ return;
+ }
+ const head = q[0];
+ if (!head.deadline) head.deadline = Date.now() + SEND_DEADLINE;
+ 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);
+ wait = BACKOFF_MIN;
+ continue;
+ }
+ const res = await postSignal(head.body, { deadline: head.deadline });
+ if (queue.get(BROADCAST) !== q) return; // the queue was dropped underneath us
+ const verdict = broadcastVerdict(res);
+ if (verdict === 'signedout') {
+ drainBroadcast(q);
+ terminal('signedout');
+ return;
+ }
+ if (verdict === 'sent' || verdict === 'drop') {
+ settle(q, accepted(verdict));
+ wait = BACKOFF_MIN;
+ continue;
+ }
+ if (verdict === 'recover') {
+ // The participation is gone, or was never admitted. Nothing
+ // queued here can be delivered by anyone, so every sender is
+ // answered before the probe-first diagnosis decides what this
+ // tab does next — with no target to pass it, because a
+ // fan-out message never had one.
+ queue.set(BROADCAST, []);
+ drainBroadcast(q);
+ await recoverFromNotFound(res.data.boot, null, { deadline: head.deadline });
+ return;
+ }
+ const ms = verdict === 'wait' ? retryAfter(res.retryAfter) : wait;
+ if (!(await waitWithin(ms, head.deadline))) {
+ settle(q, false);
+ wait = BACKOFF_MIN;
+ continue;
+ }
+ if (verdict !== 'wait') wait = Math.min(wait * 2, BACKOFF_MAX);
+ }
+ } finally {
+ inflight.delete(BROADCAST);
+ const rest = queue.get(BROADCAST);
+ if (!state.stopped && rest && rest.length) pumpBroadcast();
+ }
+ }
+
+ // settle answers the message at the head of the lane and drops it.
+ function settle(q, ok) {
+ const head = q.shift();
+ if (head && head.resolve) head.resolve(ok);
+ }
+
+ // 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);
+ }
+
function rosterGen(id) {
if (!lastRoster) return 0;
for (const rp of lastRoster.peers || []) if (rp.peer === id) return rp.gen || 0;
@@ -2386,6 +2590,309 @@
connectStream();
}
+ // ---------------------------------------------------------------
+ // Chat (§6) and screen share (§5.4)
+ // ---------------------------------------------------------------
+ //
+ // The panel is chat.js's: it owns the log, the composer, the two
+ // sentences it can say and every word on a line. This file owns four
+ // things about it — the wire underneath it, the name on the local
+ // echo, when it appears, and when it is usable — and nothing else.
+ // Nothing below reads inside `#chat`.
+ //
+ // Screen share is §5.4 in full: one display capture, carried to every
+ // connection on a `sendonly` transceiver of its own so the camera
+ // m-line is untouched, announced with a `share` message so the far
+ // side can label the tile before the track lands, and stopped by
+ // stopping the transceiver — which ends the receiver's track rather
+ // than leaving a frozen last frame on the screen.
+
+ const SHARE_REFUSED = 'Screen sharing was not allowed.';
+
+ // mountChat hands chat.js the panel and the one thing it cannot know:
+ // how to put a line on the wire. It is called on admission and on the
+ // first arriving message, whichever comes first — a `chat` can be
+ // fanned out to us while §5.2's fetch still has our own roster
+ // waiting behind it, and a message that reached this tab is a message
+ // a person should see.
+ //
+ // Un-hiding is this file's job and not the panel's: `mount` never
+ // touches `hidden`, because when a panel is usable is the host's
+ // fact. And it is only un-hidden if chat.js is actually here — a
+ // visible composer with no script behind it would submit its form and
+ // navigate away from the call.
+ function mountChat() {
+ if (chat) return chat;
+ if (!chatEl || !window.Chat || typeof window.Chat.mount !== 'function') return null;
+ const panel = window.Chat.mount(chatEl, { name: selfName, send: sendChat });
+ if (!panel) return null;
+ chat = panel;
+ chatEl.hidden = false;
+ chat.setEnabled(!!state.admitted && !state.stopped);
+ return chat;
+ }
+
+ // chatEnabled is the panel's gate, and it moves with admission: the
+ // roster turns it on, terminal and leave turn it off. It is not the
+ // same thing as `hidden` — a panel that has been usable stays
+ // readable after the call ends.
+ function chatEnabled(on) {
+ if (!chat) return;
+ chat.setEnabled(!!on);
+ }
+
+ // sendChat is the panel's `send`: chat.js hands it text it has
+ // already trimmed and measured, and reads back a strict boolean. True
+ // is the only answer that echoes the line into the log, so anything
+ // this file is unsure about has to be false.
+ async function sendChat(text) {
+ const ok = await broadcast('chat', { text });
+ if (ok === true) chatSent += 1;
+ return ok === true;
+ }
+
+ // onChat renders one arrived message (§6). The name comes from the
+ // envelope and from nowhere else: the hub put the sender's own name
+ // on it, and a peer that is not in this tab's roster — one admitted
+ // while our own roster was in flight — is still someone the hub says
+ // 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;
+ const text = msg.payload && msg.payload.text;
+ if (typeof text !== 'string' || !text) return;
+ chatReceived += 1;
+ panel.append({
+ from: msg.from || '',
+ name: msg.name || '',
+ text,
+ at: Date.now(),
+ self: false,
+ });
+ }
+
+ // onShare is the label arriving ahead of the media, or the removal
+ // arriving ahead of the track's own `ended` — §5.4 says whichever
+ // comes first wins, and both endings meet in dropShare.
+ function onShare(msg) {
+ const from = msg.from;
+ const entry = from ? pcs.get(from) : null;
+ if (!entry) return;
+ const on = !!(msg.payload && msg.payload.on === true);
+ if (!on) {
+ dropShare(entry);
+ return;
+ }
+ entry.sharing = true;
+ renderTiles();
+ }
+
+ // shareKey is the tile key a peer's screen gets: the peer's own key
+ // with a suffix that no peer id can carry, so one peer holds two
+ // tiles and every map keyed by tile key — `tiles`, `frames` — keeps
+ // them apart without a second structure.
+ function shareKey(id) {
+ return id + SHARE_SUFFIX;
+ }
+
+ // classifyTrack tells a peer's screen from its camera, which is the
+ // 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.
+ return 'share';
+ }
+
+ // onRemoteTrack is the `track` handler's whole body. A camera track
+ // goes on the peer's own tile, exactly as it always did; a screen
+ // goes on a tile of its own and leaves the camera alone.
+ function onRemoteTrack(id, entry, ev) {
+ 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);
+ if (what === 'share') {
+ entry.sharing = true;
+ 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
+ // this track ends rather than freezing. The listener is dropped
+ // with the connection, and dropShare is idempotent, so the `share
+ // {on:false}` that usually arrives alongside it costs nothing.
+ track.addEventListener('ended', () => {
+ if (pcs.get(id) !== entry) return;
+ dropShare(entry);
+ });
+ renderTiles();
+ return;
+ }
+ if (what === 'camera') {
+ // Recorded from the first video track and never revised: this is
+ // what every later video track is judged against.
+ if (mid) entry.cameraMid = mid;
+ if (stream) entry.cameraStream = stream.id;
+ }
+ entry.stream = stream || entry.stream || new MediaStream([track]);
+ if (!stream) entry.stream.addTrack(track);
+ renderTiles();
+ }
+
+ // dropShare removes one peer's screen, from either ending. The frame
+ // count is deliberately left behind: it says the screen was really
+ // arriving before it went away, which is exactly what a gate watching
+ // a stop needs to be able to see afterwards.
+ function dropShare(entry) {
+ if (!entry.sharing && !entry.share) return;
+ entry.sharing = false;
+ entry.share = null;
+ renderTiles();
+ }
+
+ // hasShare: this peer holds a share tile. The message alone is enough
+ // — it beats the media by a renegotiation — so the tile can carry the
+ // label before there is anything to show in it.
+ function hasShare(entry) {
+ return !!(entry.sharing || entry.share);
+ }
+
+ function shareCaption(name) {
+ return name + '’s screen';
+ }
+
+ // startSharing is the button's on half. The capture is asked for
+ // 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;
+ let stream;
+ try {
+ stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
+ } catch {
+ // Refused at the picker, or not permitted by policy. One
+ // sentence, and no diagnosis of which.
+ say(SHARE_REFUSED);
+ return;
+ }
+ const track = stream.getVideoTracks()[0];
+ if (!track || state.stopped) {
+ for (const t of stream.getTracks()) t.stop();
+ return;
+ }
+ share = { stream, track };
+ // The browser's own "Stop sharing" bar ends the track without
+ // telling this file anything else. §5.4 gives it the same ending as
+ // the button.
+ track.addEventListener('ended', () => stopSharing());
+ for (const entry of pcs.values()) addShareTo(entry);
+ renderShare();
+ // The label, not the media: peers use it to draw the tile before
+ // the renegotiation lands, and the track carries no name of its own.
+ broadcast('share', { on: true });
+ }
+
+ // addShareTo gives one connection the display track on a transceiver
+ // of its own. `sendonly` and a stream of its own is what keeps it
+ // clear of the camera at the far end, and adding it fires
+ // `negotiationneeded`, so §5.1's perfect negotiation carries the
+ // offer with no help from here.
+ function addShareTo(entry) {
+ if (!share || !entry || entry.shareTx) return;
+ try {
+ entry.shareTx = entry.pc.addTransceiver(share.track, {
+ direction: 'sendonly',
+ streams: [share.stream],
+ });
+ } catch {
+ // A browser without addTransceiver, or a connection already
+ // closing: that peer simply never sees this screen, and the call
+ // carries on.
+ }
+ }
+
+ // 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() {
+ if (!share) return;
+ const held = share;
+ share = null;
+ for (const entry of pcs.values()) removeShareFrom(entry);
+ renderShare();
+ broadcast('share', { on: false });
+ // 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()) {
+ try {
+ t.stop();
+ } catch {
+ // Already stopped.
+ }
+ }
+ }
+
+ // removeShareFrom ends one connection's share m-line. `stop()` is
+ // §5.4's own instruction and the only one that makes the receiver's
+ // track *end*; the fallback is for a browser whose transceivers
+ // cannot be stopped, where nulling the sender and removing it at
+ // least stops the media and renegotiates.
+ function removeShareFrom(entry) {
+ const tx = entry.shareTx;
+ if (!tx) return;
+ entry.shareTx = null;
+ try {
+ if (typeof tx.stop === 'function') {
+ tx.stop();
+ return;
+ }
+ } catch {
+ // Fall through to the older way.
+ }
+ try {
+ const sender = tx.sender;
+ if (!sender) return;
+ if (typeof sender.replaceTrack === 'function') {
+ const replaced = sender.replaceTrack(null);
+ if (replaced && typeof replaced.catch === 'function') replaced.catch(() => {});
+ }
+ entry.pc.removeTrack(sender);
+ } catch {
+ // A connection that is closing: there is nothing left to remove.
+ }
+ }
+
+ // renderShare moves the button's state three ways and never by
+ // colour, exactly as the other two toggles do: aria-pressed, the word
+ // in the button, and meet.css's inset outline.
+ function renderShare() {
+ if (!shareButton) return;
+ shareButton.setAttribute('aria-pressed', String(!!share));
+ if (shareState) shareState.textContent = share ? 'On' : 'Off';
+ }
+
+ // sharingPeers: the peers holding a share tile, for the gate.
+ function sharingPeers() {
+ const out = [];
+ for (const [id, entry] of pcs) if (hasShare(entry)) out.push(id);
+ return out;
+ }
+
// ---------------------------------------------------------------
// Terminal states (§4.5, §4.2)
// ---------------------------------------------------------------
@@ -2424,9 +2931,16 @@
closeStream();
closeAllPCs();
stopLocal();
+ // §5.4: whatever is left of a share goes with the call. The
+ // transceivers are already closed with their connections, so this is
+ // the capture itself and the button's own state.
+ stopSharing();
renderTiles();
iceWarning(false);
controlsEnabled(false);
+ // §6: nothing can be sent from here, so the composer says so. The
+ // log stays readable — what was said is still worth reading.
+ chatEnabled(false);
say(TERMINAL[reason] || TERMINAL.ended);
wayOut(reason);
}
@@ -2461,6 +2975,11 @@
muteButton.disabled = !on;
cameraButton.disabled = !on;
leaveButton.disabled = !on;
+ // A browser with no getDisplayMedia is a browser where Share screen
+ // is a control that could only ever do nothing, so it is never
+ // enabled — the same rule the two device toggles follow when there
+ // is no track for them to move.
+ if (shareButton) shareButton.disabled = !on || !HAS_DISPLAY_MEDIA;
}
// ---------------------------------------------------------------
@@ -2502,7 +3021,14 @@
function renderTiles() {
const wanted = [];
if (local) wanted.push('self');
- for (const id of pcs.keys()) wanted.push(id);
+ for (const [id, entry] of pcs) {
+ wanted.push(id);
+ // §5.4: a peer sharing a screen holds two tiles, and the second
+ // one exists from the `share` message rather than from the track
+ // — the message beats the media by a renegotiation, and a tile
+ // that is about to fill is better than a grid that jumps.
+ if (hasShare(entry)) wanted.push(shareKey(id));
+ }
for (const key of Array.from(tiles.keys())) {
if (!wanted.includes(key)) {
tiles.get(key).figure.remove();
@@ -2511,7 +3037,14 @@
}
if (local) tile('self', selfName, local, true, '');
for (const [id, entry] of pcs) {
- tile(id, names.get(id) || 'Someone', entry.stream, false, tileNote(id, entry));
+ const name = names.get(id) || 'Someone';
+ tile(id, name, entry.stream, false, tileNote(id, entry));
+ // The screen's own tile carries no note: §5.3's and §5.5's
+ // sentences are about a connection, and this is the same
+ // connection as the camera tile above, which is already saying it.
+ if (hasShare(entry)) {
+ tile(shareKey(id), shareCaption(name), entry.share && entry.share.stream, false, '');
+ }
}
renderStatus();
}
@@ -2552,6 +3085,12 @@
video.muted = true;
figure.classList.add('meet-tile-self');
}
+ // §5.4: a screen is not a face. The class is the one thing tile()
+ // knows about sharing, and it exists so meet.css can fit the whole
+ // screen into the tile instead of cropping it to fill.
+ if (key.slice(-SHARE_SUFFIX.length) === SHARE_SUFFIX) {
+ figure.classList.add('meet-tile-share');
+ }
const label = document.createElement('figcaption');
label.className = 'meet-tile-name';
const nameEl = document.createElement('span');
@@ -2751,6 +3290,22 @@
applyToggles();
});
+ // §5.4's gesture. The handler is not async: a rejection escaping one
+ // goes where neither a person nor a gate could see it, so the work is
+ // a named function with a catch of its own — the same shape as the
+ // pageshow handler's.
+ if (shareButton) {
+ shareButton.addEventListener('click', () => {
+ if (share) {
+ stopSharing();
+ return;
+ }
+ startSharing().catch((err) => {
+ console.error('meet: share', err);
+ });
+ });
+ }
+
leaveButton.addEventListener('click', () => {
leave();
go('/m/' + encodeURIComponent(sqid) + '/join');
@@ -2778,6 +3333,8 @@
closeStream();
closeAllPCs();
stopLocal();
+ stopSharing();
+ chatEnabled(false);
}
// A page going into the back/forward cache is not a person leaving:
@@ -2926,6 +3483,10 @@
// no reset is asked for until a roster settles it.
stale: Array.from(stale),
peers: Array.from(pcs.keys()),
+ // Keyed by tile, so a peer sharing a screen has two entries: its
+ // own id and `shareKey(id)`. A share tile's count is left behind
+ // when the tile goes, which is how a gate can tell "the screen
+ // was arriving and then stopped" from "the screen never came".
frames: new Map(frames),
signaling,
connection,
@@ -2959,6 +3520,15 @@
// fragment the proof found — a generation name, not a
// credential.
renewals: renewalState(),
+ // §6: lines the hub accepted from this tab, and lines this tab
+ // put in the log. Neither is readable from the panel's markup
+ // without knowing it, and the panel is not this file's to know.
+ chat: { sent: chatSent, received: chatReceived },
+ // §5.4: whether this tab is sharing, and which peers are showing
+ // a screen here — the label counts, not just the media, because
+ // the message is what draws the tile.
+ sharing: !!share,
+ shares: sharingPeers(),
};
},
};
@@ -2969,6 +3539,7 @@
async function start() {
controlsEnabled(true);
+ renderShare();
// The relay warning's live region is built now, empty and hidden,
// rather than at the moment it has something to say: a live region
// inserted along with its own text is a region the reader was not
diff --git a/internal/meet/templates/room.html b/internal/meet/templates/room.html
index 44bcd0f..7a45699 100644
--- a/internal/meet/templates/room.html
+++ b/internal/meet/templates/room.html
@@ -16,6 +16,7 @@
<div id="controls" class="meet-controls">
<button type="button" id="mute" rst-btn aria-label="Microphone" aria-pressed="false">Microphone<span id="mute-state" class="meet-toggle-state" aria-hidden="true">On</span></button>
<button type="button" id="camera" rst-btn aria-label="Camera" aria-pressed="false">Camera<span id="camera-state" class="meet-toggle-state" aria-hidden="true">On</span></button>
+ <button type="button" id="share" rst-btn aria-pressed="false">Share screen<span id="share-state" class="meet-toggle-state" aria-hidden="true">Off</span></button>
<button type="button" id="leave" rst-btn="danger">Leave</button>
</div>
{{/* The chat panel (spec §6): one partial, one script, one
diff --git a/test/browser/negotiation.test.mjs b/test/browser/negotiation.test.mjs
index 637b67f..945b2dc 100644
--- a/test/browser/negotiation.test.mjs
+++ b/test/browser/negotiation.test.mjs
@@ -604,22 +604,37 @@ test('the roster applied is the newest one, not the one the fetch started with',
const reconciler = new Function(
'state', 'stale', 'names', 'pcs', 'gen', 'seqOut', 'lastIn', 'queue', 'frames',
'renewals', 'ensurePC', 'renderTiles', 'renderQueue', 'renderStatus',
- 'rosters', 'lastRoster', 'waiting',
- [lift(src, 'reconcile'), lift(src, 'closePC')].join('\n') +
- '\nreturn { reconcile };',
+ 'rosters', 'lastRoster', 'waiting', 'mountChat', 'chatEnabled', 'SHARE_SUFFIX',
+ // shareKey comes in as source rather than as a stub: closePC forgets a
+ // peer's share tile through it (§5.4), and a stub would be testing the
+ // stub.
+ [lift(src, 'reconcile'), lift(src, 'closePC'), lift(src, 'shareKey')].join('\n') +
+ '\nreturn { reconcile, closePC };',
);
-function reconcileOver(pcs, seqOut, lastIn) {
+function reconcileOver(pcs, seqOut, lastIn, frames) {
const built = [];
- const { reconcile } = reconciler(
+ const chat = [];
+ const { reconcile, closePC } = reconciler(
{ stopped: false, peer: 'me', kind: 'member' },
- new Set(), new Map(), pcs, new Map(), seqOut, lastIn, new Map(), new Map(),
+ new Set(), new Map(), pcs, new Map(), seqOut, lastIn, new Map(),
+ frames || new Map(),
new Map(), (id, g) => built.push([id, g]), () => {}, () => {}, () => {},
- 0, null, [],
+ 0, null, [], () => chat.push('mount'), (on) => chat.push(on), '#share',
);
- return { reconcile, built };
+ return { reconcile, closePC, built, chat };
}
+test('a roster is admission, so the chat panel is mounted and enabled', () => {
+ // §6: the panel appears when the hub says this tab is in the room, and
+ // not before. Mounting is idempotent, so a second roster is one more
+ // setEnabled(true) and nothing else.
+ const { reconcile, chat } = reconcileOver(new Map(), new Map(), new Map());
+ reconcile({ peers: [{ peer: 'me' }] }, 1);
+ reconcile({ peers: [{ peer: 'me' }] }, 2);
+ assert.deepEqual(chat, ['mount', true, 'mount', true]);
+});
+
// A connection, as reconcile sees one: what tick it was built on, and a
// close it can report.
function connection(bornAt, closed, id) {
@@ -641,6 +656,38 @@ test('a peer the roster stops naming loses its connection and keeps its counters
assert.equal(lastIn.get('peer1'), 3, 'and so does the replay cursor');
});
+test('forgetting a peer forgets its screen\u2019s frame count too', () => {
+ // §5.4: a peer sharing a screen counts frames under two keys — its own
+ // and shareKey(its own) — and the participation that ends takes both
+ // with it. A share count left behind would be inherited by whatever
+ // next drew a tile under that key.
+ const closed = [];
+ const pcs = new Map([['peer1', connection(1, closed, 'peer1')]]);
+ const frames = new Map([['peer1', 30], ['peer1#share', 12], ['peer2', 5]]);
+ const { closePC } = reconcileOver(pcs, new Map(), new Map(), frames);
+
+ closePC('peer1', true);
+
+ assert.deepEqual(Array.from(frames.keys()), ['peer2'],
+ 'both of the peer\u2019s tiles are forgotten, and nobody else\u2019s');
+});
+
+test('a close that is not a forgetting keeps both frame counts', () => {
+ // The other half of the same rule: a roster's silence closes a
+ // connection without ending a participation (§4.6), and the counts
+ // stand — including the screen's, which is how a gate can still see
+ // that the screen was arriving.
+ const closed = [];
+ const pcs = new Map([['peer1', connection(1, closed, 'peer1')]]);
+ const frames = new Map([['peer1', 30], ['peer1#share', 12]]);
+ const { closePC } = reconcileOver(pcs, new Map(), new Map(), frames);
+
+ closePC('peer1', false);
+
+ assert.equal(frames.get('peer1'), 30);
+ assert.equal(frames.get('peer1#share'), 12);
+});
+
test('a peer the roster still names keeps its connection and its counters', () => {
const closed = [];
const pcs = new Map([['peer1', connection(1, closed, 'peer1')]]);
diff --git a/test/browser/room-share.test.mjs b/test/browser/room-share.test.mjs
new file mode 100644
index 0000000..665352b
--- /dev/null
+++ b/test/browser/room-share.test.mjs
@@ -0,0 +1,221 @@
+// room-share.test.mjs — the pure parts of room.js's chat and screen
+// share, gated without a browser.
+//
+// node --test test/browser/room-share.test.mjs
+//
+// Same trick as negotiation.test.mjs: room.js is an IIFE that wants a
+// document, so it cannot be imported, but a pure function inside it can
+// be lifted out by name and evaluated here. What is under test is the
+// shipped source and not a copy of it, so renaming or reshaping one of
+// these functions fails loudly rather than passing against a stale
+// duplicate.
+//
+// Four judgements are gated here, and they are the four that decide
+// whether §5.4 and §6 work at all:
+//
+// classifyTrack a peer's screen told from its camera
+// shareKey the tile key a screen gets, which must never
+// collide with a peer id
+// 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
+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');
+
+// lift returns the source of one `function name(...) {...}` by matching
+// braces from its declaration.
+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');
+}
+
+// readConst reads a top-level string constant out of the shipped source,
+// so a test cannot go on asserting a value someone has since changed.
+function readConst(source, name) {
+ const m = new RegExp("const " + name + " = '([^']*)'").exec(source);
+ assert.notEqual(m, null, name + ' is not a string constant in room.js any more');
+ return m[1];
+}
+
+const SHARE_SUFFIX = readConst(src, 'SHARE_SUFFIX');
+const BROADCAST = readConst(src, 'BROADCAST');
+
+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 classifyTrack = build('classifyTrack');
+const broadcastBody = build('broadcastBody');
+const broadcastVerdict = build('broadcastVerdict');
+const accepted = build('accepted');
+
+// ---------------------------------------------------------------
+// The two sentinels
+// ---------------------------------------------------------------
+
+test('the share suffix and the broadcast key are what the contract names', () => {
+ // Both are pinned rather than retyped: everything below is driven by
+ // the values read out of the file, so changing one is a deliberate act
+ // with a failing test in front of it.
+ assert.equal(SHARE_SUFFIX, '#share');
+ assert.equal(BROADCAST, '*');
+});
+
+test('neither sentinel could ever be mistaken for a peer id', () => {
+ const PEER_ID = /^[0-9a-f]{32}$/;
+ const peer = 'a'.repeat(32);
+ assert.equal(PEER_ID.test(peer), true);
+ assert.equal(PEER_ID.test(shareKey(peer)), false);
+ assert.equal(PEER_ID.test(BROADCAST), false);
+});
+
+// ---------------------------------------------------------------
+// shareKey
+// ---------------------------------------------------------------
+
+test('a peer holds two tiles, and they are not the same key', () => {
+ const peer = '0123456789abcdef0123456789abcdef';
+ assert.equal(shareKey(peer), peer + '#share');
+ assert.notEqual(shareKey(peer), peer);
+});
+
+test('two peers sharing at once keep their screens apart', () => {
+ const a = 'a'.repeat(32);
+ const b = 'b'.repeat(32);
+ assert.notEqual(shareKey(a), shareKey(b));
+});
+
+// ---------------------------------------------------------------
+// classifyTrack (§5.4, receiving)
+// ---------------------------------------------------------------
+
+const nothing = { cameraMid: '', cameraStream: '' };
+const known = { cameraMid: '0', cameraStream: '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');
+});
+
+test('the first video track a peer sends is the camera', () => {
+ assert.equal(classifyTrack(nothing, 'video', 'cam', '0'), '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');
+});
+
+test('a second video track on another transceiver is the screen', () => {
+ assert.equal(classifyTrack(known, 'video', 'screen', '2'), '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');
+ // 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');
+});
+
+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');
+ // 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');
+});
+
+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');
+});
+
+// ---------------------------------------------------------------
+// broadcastBody (§4.3's fan-out wire)
+// ---------------------------------------------------------------
+
+test('a broadcast body carries the peer, the type and the payload — and nothing else', () => {
+ const body = broadcastBody('a'.repeat(32), 'chat', { text: 'hi' });
+ assert.deepEqual(Object.keys(body).sort(), ['payload', 'peer', 'type']);
+ assert.equal(body.peer, 'a'.repeat(32));
+ assert.equal(body.type, 'chat');
+ assert.deepEqual(body.payload, { text: 'hi' });
+});
+
+test('no to, no seq and no gen reach the wire', () => {
+ // The server answers 400 to any of the three on a chat or a share, so
+ // this is the difference between a working panel and a bad request.
+ const sent = JSON.parse(JSON.stringify(broadcastBody('b'.repeat(32), 'share', { on: true })));
+ assert.equal('to' in sent, false);
+ assert.equal('seq' in sent, false);
+ assert.equal('gen' in sent, false);
+ assert.deepEqual(sent, { peer: 'b'.repeat(32), type: 'share', payload: { on: true } });
+});
+
+// ---------------------------------------------------------------
+// broadcastVerdict and accepted (the lane's status table)
+// ---------------------------------------------------------------
+
+const answer = (status, extra) => Object.assign({ status, data: {}, signedOut: false, retryAfter: null }, extra);
+
+test('a 200 is the only answer that sends', () => {
+ assert.equal(broadcastVerdict(answer(200)), 'sent');
+ assert.equal(accepted('sent'), true);
+});
+
+test('only a 200 answers the sender true', () => {
+ // chat.js echoes a line into the log on exactly `true`, so every other
+ // ending has to be false: a message nobody received must not appear on
+ // the screen of the person who wrote it.
+ for (const status of [0, 400, 404, 409, 413, 429, 500, 503]) {
+ assert.equal(accepted(broadcastVerdict(answer(status))), false, 'status ' + status);
+ }
+ assert.equal(accepted(broadcastVerdict(answer(200, { signedOut: true }))), false);
+});
+
+test('400, 413 and 409 are dropped and never retried', () => {
+ // A bug in this file, a message the server will never take, and a
+ // conflict that belongs to the targeted lane.
+ for (const status of [400, 409, 413]) {
+ assert.equal(broadcastVerdict(answer(status)), 'drop', 'status ' + status);
+ }
+});
+
+test('a 429 waits and a 404 goes to the probe', () => {
+ assert.equal(broadcastVerdict(answer(429, { retryAfter: '1' })), 'wait');
+ assert.equal(broadcastVerdict(answer(404, { data: { error: 'not-found', boot: 'b1' } })), 'recover');
+});
+
+test('a 5xx, a network failure and an unnamed status are retried', () => {
+ for (const status of [0, 418, 500, 502, 503]) {
+ assert.equal(broadcastVerdict(answer(status)), 'retry', 'status ' + status);
+ }
+});
+
+test('signed out is read before the status, because it can be true of a 200', () => {
+ // An expired session answers 303 to /signin, fetch follows it, and the
+ // sign-in page comes back 200 — which read as a status alone is
+ // indistinguishable from a message that was delivered.
+ assert.equal(broadcastVerdict(answer(200, { signedOut: true })), 'signedout');
+ assert.equal(broadcastVerdict(answer(401, { signedOut: true })), 'signedout');
+ assert.equal(accepted('signedout'), false);
+});