"""End-to-end: the audio leg is negotiated and composed, not overwritten. The bug this phase fixes: the viewer replaced ``srcObject`` on every ``ontrack``, so the second track to arrive erased the first — the audio track's one-track ``MediaStream`` replaced the video's and the picture kept playing (from the still-attached transceiver) while the sound never came. The assertions here are therefore on the *viewer*: the composed ``MediaStream`` behind ``video.srcObject`` holds one video track *and* one audio track, the negotiated SDP agrees about audio in both directions (``m=audio`` iff the sharer published it), and the picture keeps decoding distinct frames once the audio leg exists. The fake-device Chromium (conftest.py) starts with ``--use-fake-device-for-audio-capture`` and on the reference host its ``getDisplayMedia`` does deliver a live "Fake audio" track, but a headless container has no audio hardware, so a *sound* assertion belongs to the realdisplay-class run. This suite asserts the SDP and the composed ``MediaStream`` only — and skips, naming the reason, when this browser/device combination offers no display audio at all. The no-audio direction simulates a source *without* an audio track (the common case for a window on Linux) by stripping the track from the captured stream; the WebRTC offer, the m-lines and the transport stay real. Task 04 adds the hard arrival assertion, still on the *viewer*: after the staged tile is unmuted, the peer connection's ``getStats()`` must show an ``inbound-rtp`` audio entry whose ``bytesReceived`` strictly increases across two samples — Opus frames actually arriving over SRTP — and where the browser reports ``totalAudioEnergy``/``audioLevel``, a live tone means at least one of the two is non-zero (the fake capture device produces a tone, so energy exists when the track exists). The picture is asserted in the same test: audio arriving while the picture froze would be a different bug, and this phase must not create it. A skip here is honest and loud: it names the platform and the exact Chromium flags, never a stubbed peer. """ from __future__ import annotations import re import sys import time import httpx import pytest from playwright.sync_api import expect from .conftest import CHROMIUM_ARGS, frames_matching, start_sharing pytestmark = [pytest.mark.e2e, pytest.mark.timeout(180)] def _no_audio_skip() -> str: """The one honest exit when the environment has no display audio. It names the platform and the exact flags the browser was launched with, so a skip reads as a statement about *this* environment — never an xfailed-into-green failure, never an assertion on an absent track. """ return ( "no display audio track in this environment " f"(platform={sys.platform}; chromium flags: {'; '.join(CHROMIUM_ARGS)})" ) @pytest.fixture def device(base_url, browser, request): """Open a page as its own device (isolated context, fresh peer id). The same fixture `test_live_mirror.py` defines (the project's e2e convention is one copy per suite file, so fixtures are not imported across test modules). """ opened = [] def _open(path: str = "/", errors: list | None = None): context = browser.new_context(viewport={"width": 1100, "height": 720}) opened.append(context) request.addfinalizer(context.close) page = context.new_page() if errors is not None: page.on("pageerror", lambda error: errors.append(str(error))) page.goto(f"{base_url}{path}", wait_until="domcontentloaded") expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000) return page return _open # Test scaffolding (AGENTS.md §6): collect the RTCPeerConnection instances # watch.js creates so the test can read the viewer's negotiated SDP through # the one allowed global, ``window.__probe``. PROBE_INIT = """ window.__probe = {pcs: []}; const RealPC = window.RTCPeerConnection; window.RTCPeerConnection = function (config) { const pc = new RealPC(config); window.__probe.pcs.push(pc); return pc; }; window.RTCPeerConnection.prototype = RealPC.prototype; window.RTCPeerConnection.static = RealPC.static; """ # Source-level simulation (the no-audio direction): a surface with no audio # track is what most windows on Linux deliver. The audio tracks are stopped # and the stream rebuilt from the video tracks only, *before* the app reads # it — the WebRTC path (offer, m-lines, SRTP) stays real. SILENT_SOURCE_INIT = """ const realGetDisplayMedia = navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices); navigator.mediaDevices.getDisplayMedia = async (constraints) => { const stream = await realGetDisplayMedia(constraints); stream.getAudioTracks().forEach((t) => t.stop()); return new MediaStream(stream.getVideoTracks()); }; """ # Source-level simulation (the `not offered` direction): an engine that cannot # honour the audio constraint at all (a display stack with no audio backend) # fails the `audio: true` request with a named constraint error. The app must # then retry with the constraint dropped and say `not offered` — distinct # from a source that offered none (`silent`). The wrapper counts which # constraints were asked for on the function object itself (AGENTS.md §6: no # new test globals), and the rest of the capture path stays real. NO_AUDIO_SUPPORT_INIT = """ const realGetDisplayMedia = navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices); const calls = []; const ask = async (constraints) => { calls.push(!!constraints.audio); if (constraints.audio === true) { throw new DOMException("audio is not supported by this engine", "NotSupportedError"); } return realGetDisplayMedia(constraints); }; ask.calls = calls; navigator.mediaDevices.getDisplayMedia = ask; """ # Source-level simulation (the refusal direction): a cancelled picker, or a # user who unticked "Share tab audio". The app must rethrow — start() toasts # the cancellation and the share never begins — instead of silently re-opening # the picker and sharing a surface the person meant not to. CANCEL_PICKER_INIT = """ const realGetDisplayMedia = navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices); const calls = []; const ask = async (constraints) => { calls.push(!!constraints.audio); throw new DOMException("The user cancelled the picker", "NotAllowedError"); }; ask.calls = calls; navigator.mediaDevices.getDisplayMedia = ask; """ def open_viewer( browser, base_url: str, request, init: str | None = None, errors: list | None = None ): """A viewer page with the RTCPeerConnection probe installed.""" context = browser.new_context(viewport={"width": 1100, "height": 720}) request.addfinalizer(context.close) if init: context.add_init_script(init) page = context.new_page() if errors is not None: page.on("pageerror", lambda error: errors.append(str(error))) page.goto(f"{base_url}/watch", wait_until="domcontentloaded") expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000) return page def wait_for_connected(page, timeout: float = 30.0) -> None: """Block until one probed leg is `connected` — the SDP is settled then.""" deadline = time.time() + timeout states: list = [] while time.time() < deadline: states = page.evaluate("() => (window.__probe.pcs || []).map(pc => pc.connectionState)") if "connected" in states: return time.sleep(0.3) raise AssertionError(f"no viewer leg reached `connected`: {states}") def sdp_audio(page) -> dict: """The negotiated SDP's audio section on the viewer's connected leg. ``local`` is the viewer's answer, ``remote`` the sharer's offer: the two must agree, which is the "iff" of the SDP assertion. """ return page.evaluate( """() => { const pc = (window.__probe.pcs || []).find(p => p.connectionState === 'connected'); if (!pc) return {found: false}; const sdp = (d) => (d && d.sdp) || ''; return { found: true, local: sdp(pc.localDescription).includes('m=audio'), remote: sdp(pc.remoteDescription).includes('m=audio'), }; }""" ) def composed_stream(page) -> dict: """The track mix of the MediaStream the viewer's