phase: 03_audio_share
**Phase 03 final verification pass — complete, 100% green.** - Verified all 4 tasks done in `complete/03_audio_share/`; confirmed implementation in `watch.js` (one composed `MediaStream` per tile, staged-only `setMuted()` policy, `data-audio` state), `share.js` (precise `NotAllowedError` rethrow, 3 honest `#stat-audio` states, mid-share audio republish), conftest flags, and the new `tests/e2e/test_audio_share.py` (11 tests, all **passed** — none skipped). - **Criteria:** (1) viewer inbound audio `bytesReceived` strictly increases + composed stream 1 video/1 audio track while frames still decode — **passed**; (2) staged-only audio via keyboard `m`, mouse button, and `unstage()` re-mute — **passed**; (3) `shared`/`silent`/`not offered` + mid-share audio loss updates panel, roster and tiles — **passed**; (4) cancelled picker reports cancellation, single attempt — **passed**; (5) SECURITY.md §4 Audio, README platform notes, kiosk doc all describe shipped behaviour incl. Linux/PipeWire reality — **verified**; (6) full gate — **passed** (below). - **Gates:** `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → 245 passed, 4 skipped (only the `MIRROR_REALDISPLAY`-gated X11 tests); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 3 passed, 1 skipped (real-capture audio: documented skip naming the exact host/flag reason, per task 04). - **Docs:** README.md reviewed in full against code (env table, CLI flags, routes, kiosk key table, quickstart, audio notes) — consistent, no changes needed. AGENTS.md — all commands/paths/coverage claims verified accurate, no stale statements, no changes needed. - **Defect fixed (pre-existing):** `docs/kiosk-silverblue.md` + `install-kiosk.sh`, `kiosk.conf`, `gnome-kiosk-script` used `mirror.reeselink.com` while README/SECURITY.md use `mirror.reeseapps.com` (the deployment domain, consistent with the fleet); corrected 7 occurrences in 4 files. - No live-infrastructure changes; realdisplay suite ran on private Xvfb only. - **Next pending phase:** `04_reachable_share_cta`.
This commit is contained in:
@@ -54,6 +54,24 @@ def wait_for(socket, kind):
|
||||
assert len(seen) < 25, f"never saw {kind!r}; got {seen}"
|
||||
|
||||
|
||||
def wait_for_update_with(socket, **stream_fields):
|
||||
"""`wait_for` for `stream_updated` frames, matched on the stream payload.
|
||||
|
||||
`stream_updated` is pushed for audience changes too, so the test must
|
||||
keep reading until the payload itself says what is being awaited (e.g.
|
||||
the audio flag actually flipped)."""
|
||||
seen = []
|
||||
while True:
|
||||
message = socket.receive_json()
|
||||
if message.get("type") == "stream_updated" and all(
|
||||
message["stream"].get(key) == value for key, value in stream_fields.items()
|
||||
):
|
||||
return message["stream"]
|
||||
assert message.get("type") != "error", f"unexpected error: {message}"
|
||||
seen.append(message)
|
||||
assert len(seen) < 25, f"never saw stream_updated with {stream_fields}; got {seen}"
|
||||
|
||||
|
||||
class TestPages:
|
||||
@pytest.mark.parametrize(
|
||||
("path", "marker"),
|
||||
@@ -191,6 +209,53 @@ class TestClientScripts:
|
||||
r'connectionState\s*===\s*"connected".*applySenderQuality', connected, re.S
|
||||
), "the cap is not re-applied when the leg becomes connected"
|
||||
|
||||
def test_pick_surface_rethrows_cancellation_and_retries_only_no_audio_errors(self):
|
||||
"""The audio-off retry must be precise. A cancelled picker
|
||||
(NotAllowedError) is a refusal — in Chrome it is also what unticking
|
||||
"Share tab audio" produces — and must be *rethrown*: retrying would
|
||||
silently re-open the picker and share a surface the person meant not
|
||||
to. Only the errors that genuinely mean "this source has no audio"
|
||||
(a failed audio *constraint*, not a failed capture) may drop the
|
||||
audio requirement; a bare retry in a catch-all would hide real
|
||||
capture failures and turn refusals into silent shares."""
|
||||
source = (STATIC / "share.js").read_text()
|
||||
# pickSurface and its named-error set live between these markers; the
|
||||
# first `catch` in that span is pickSurface's own.
|
||||
begin = source.index("async function pickSurface")
|
||||
section = source[begin : source.index("function publish", begin)]
|
||||
catch = section[section.index("catch") :]
|
||||
assert "NotAllowedError" in catch, "the catch block must name the refusal error"
|
||||
assert re.search(r"NotAllowedError[\s\S]*?throw\s+err", catch), (
|
||||
"a cancelled picker must be rethrown, not retried"
|
||||
)
|
||||
for name in (
|
||||
"NotSupportedError",
|
||||
"OverconstrainedError",
|
||||
"NotFoundError",
|
||||
"TypeError",
|
||||
):
|
||||
assert name in section, f"the audio-off retry must name {name}"
|
||||
assert "return await navigator.mediaDevices.getDisplayMedia" not in catch, (
|
||||
"the audio-off retry must be conditional on the named errors, "
|
||||
"not a bare retry in a catch-all"
|
||||
)
|
||||
assert re.search(r"audiolessError\(err\)\s*\)\s*throw\s+err", section), (
|
||||
"an error the named set does not cover must surface, not be retried"
|
||||
)
|
||||
|
||||
def test_no_bare_publish_frame_wipes_the_roster(self):
|
||||
"""The wire's `publish` is a full-metadata frame. A bare
|
||||
`{ type: "publish" }` frame parses with the model's defaults and
|
||||
would wipe width/height/audio off the roster on every surface switch
|
||||
(the mute/unmute republish path) — the panel and the wall would
|
||||
disagree from a single event. The full frame is the only publish
|
||||
share.js may send."""
|
||||
source = (STATIC / "share.js").read_text()
|
||||
assert source.count('type: "publish"') == 1, (
|
||||
"share.js must send exactly one publish frame (the full one); "
|
||||
"a bare { type: 'publish' } frame would reset the metadata"
|
||||
)
|
||||
|
||||
def test_the_two_pages_share_no_element_ids_but_the_shared_chrome(self):
|
||||
"""Both pages consume one stylesheet, so a duplicated id means a colliding rule.
|
||||
|
||||
@@ -477,6 +542,62 @@ class TestSignalingSocket:
|
||||
assert (stream["width"], stream["height"]) == (1920, 1080)
|
||||
assert client.get("/api/streams").json()["mirrors"][0]["displaySurface"] == "window"
|
||||
|
||||
def test_the_audio_flag_survives_the_wire_and_a_republish_flips_it(self, client):
|
||||
"""Regression for the audio work: `audio` is the one bit that tells a
|
||||
viewer whether to expect sound. A publish with audio on must arrive
|
||||
with the flag set (and the REST roster must agree); a republish that
|
||||
drops it must flip it via `stream_updated` for **every** subscribed
|
||||
viewer; and an `unpublish` must leave the wall empty — a viewer that
|
||||
joins afterwards sees an empty roster, so the flip cannot leak into a
|
||||
stream that no longer exists. The policy half is the same one the
|
||||
capture-metadata test pins; this records it for the audio phase so it
|
||||
cannot regress."""
|
||||
with (
|
||||
client.websocket_connect("/ws") as sharer,
|
||||
client.websocket_connect("/ws") as one,
|
||||
client.websocket_connect("/ws") as two,
|
||||
):
|
||||
sharer_id = handshake(sharer, "sharer", "A")
|
||||
handshake(one, "viewer", "one")
|
||||
handshake(two, "viewer", "two")
|
||||
sharer.send_json({"type": "publish", "label": "Screen", "audio": True})
|
||||
assert wait_for(one, "stream_added")["stream"]["audio"] is True
|
||||
assert wait_for(two, "stream_added")["stream"]["audio"] is True
|
||||
assert client.get("/api/streams").json()["mirrors"][0]["audio"] is True
|
||||
|
||||
one.send_json({"type": "subscribe", "streamId": sharer_id})
|
||||
two.send_json({"type": "subscribe", "streamId": sharer_id})
|
||||
wait_for(sharer, "peer_joined")
|
||||
wait_for(sharer, "peer_joined")
|
||||
|
||||
# A shape change (audio off) is a republish: each subscribed
|
||||
# viewer is re-offered and the flag arrives flipped in
|
||||
# `stream_updated`. The subscribes already pushed one (audience
|
||||
# change), so keep reading until the republish's own update
|
||||
# lands on *each* viewer.
|
||||
sharer.send_json({"type": "publish", "label": "Screen", "audio": False})
|
||||
for viewer in (one, two):
|
||||
assert wait_for_update_with(viewer, audio=False)["audio"] is False
|
||||
assert client.get("/api/streams").json()["mirrors"][0]["audio"] is False
|
||||
|
||||
# Teardown: every viewer is told, and the wall is empty
|
||||
# afterwards — no stray update may keep advertising the gone
|
||||
# stream's audio.
|
||||
sharer.send_json({"type": "unpublish"})
|
||||
wait_for(one, "stream_removed")
|
||||
wait_for(two, "stream_removed")
|
||||
body = client.get("/api/streams").json()
|
||||
assert body["mirrors"] == []
|
||||
assert body["stats"]["mirrors"] == 0
|
||||
|
||||
# The empty wall is unaffected: a viewer that joins after the
|
||||
# teardown sees an empty roster, with no audio bit on anything.
|
||||
with client.websocket_connect("/ws") as late:
|
||||
late.send_json({"type": "hello", "role": "viewer", "name": "late"})
|
||||
welcome = late.receive_json()
|
||||
assert welcome["type"] == "welcome", welcome
|
||||
assert welcome["streams"] == []
|
||||
|
||||
def test_both_viewers_of_one_mirror_see_their_own_leg(self, client):
|
||||
with (
|
||||
client.websocket_connect("/ws") as sharer,
|
||||
|
||||
Reference in New Issue
Block a user