"""Real Chromium, fake media/network: startup, cancellation and mic privacy. Run with Playwright installed, e.g.: uv run --no-project --with playwright python browser_lifecycle_checks.py No GPU, hosted API, physical microphone, camera or real WebSocket is used. """ from functools import partial import http.server import json import os from pathlib import Path import threading from playwright.sync_api import sync_playwright class QuietHandler(http.server.SimpleHTTPRequestHandler): def log_message(self, *_args): pass CHECKS = r"""async () => { const {RoboClient} = await import('/client.js'); const realWS = window.WebSocket; const realGUM = navigator.mediaDevices.getUserMedia; const sockets = []; const results = []; const tick = () => new Promise(r => setTimeout(r, 0)); const assert = (yes, text) => { if (!yes) throw Error(text); }; function context(addModule = async () => {}) { return {audioWorklet: {addModule}, close: async () => {}, createMediaStreamSource: () => ({connect() {}, disconnect() {}})}; } function client(addModule) { const c = new RoboClient(); c.ctx = context(addModule); c.clock = {stop() {}, inputLatency: null}; return c; } function media() { let stopped = 0; const track = {stop() { stopped++; }, getSettings() { return {}; }}; return {stream: {getTracks: () => [track], getAudioTracks: () => [track]}, get stopped() { return stopped; }}; } class FakeWS { constructor() { this.readyState = 0; sockets.push(this); setTimeout(() => { if (this.readyState === 0) { this.readyState = 1; this.onopen?.(); }}, 15); } close() { this.readyState = 3; this.onclose?.(); } send() {} } try { window.WebSocket = FakeWS; const c = client(); const before = sockets.length; await Promise.all([c.start(false), c.start(false), c.start(false)]); assert(sockets.length-before === 1, 'Concurrent start opened multiple sockets'); c.stop(); results.push('concurrent_start_uses_one_websocket'); const failedMedia = media(); navigator.mediaDevices.getUserMedia = async () => failedMedia.stream; const f = client(async () => { throw Error('Injected worklet loading failure'); }); let failed = false; try { await f.start(true); } catch { failed = true; } assert(failed, 'Injected worklet failure did not reject'); assert(failedMedia.stopped >= 1 && f.stream === null && f.ctx === null, 'Microphone leaked after worklet failure'); results.push('worklet_failure_stops_microphone'); let resolveLate; const late = media(); navigator.mediaDevices.getUserMedia = () => new Promise(r => { resolveLate = r; }); const l = client(); const pending = l.start(true).then(() => false, () => true); await tick(); l.stop(); resolveLate(late.stream); assert(await pending, 'Stopped startup unexpectedly succeeded'); assert(late.stopped >= 1 && l.stream === null && l.ctx === null, 'Late media permission left microphone running'); results.push('late_get_user_media_after_stop_is_released'); let resolveOld; const oldMedia = media(); navigator.mediaDevices.getUserMedia = () => new Promise(r => { resolveOld = r; }); const r = client(); const oldStart = r.start(true).then(() => false, () => true); await tick(); r.stop(); const newContext = context(); r.ctx = newContext; r.clock = {stop() {}}; await r.start(false); const newSocket = r.ws; resolveOld(oldMedia.stream); await oldStart; assert(oldMedia.stopped >= 1 && r.ws === newSocket && r.ctx === newContext, 'Cancelled old media startup damaged the new conversation'); r.stop(); results.push('old_media_result_cannot_close_new_session'); const s = client(); await s.start(false); const oldSocket = s.ws; s.stop(); s.ctx = context(); s.clock = {stop() {}}; await s.start(false); const currentSocket = s.ws; oldSocket.onclose(); assert(s.ws === currentSocket && currentSocket.readyState === 1, 'Stale close callback stopped a new WebSocket'); s.stop(); results.push('stale_websocket_close_is_ignored'); const resumed = client();let resumes=0; resumed.ctx.state='suspended';resumed.ctx.resume=async()=>{resumes++;resumed.ctx.state='running';}; await resumed.start(false); assert(resumes===1 && resumed.ctx.state==='running','Existing suspended context did not resume'); resumed.stop();results.push('existing_suspended_audio_context_resumes'); let resolveModule; const workletMedia = media(); navigator.mediaDevices.getUserMedia = async () => workletMedia.stream; const w = client(() => new Promise(r => { resolveModule = r; })); const workletStart = w.start(true).then(() => false, () => true); await tick(); w.stop(); resolveModule(); await workletStart; assert(workletMedia.stopped >= 1 && w.stream === null && w.ctx === null, 'Stopped pending worklet reattached a microphone'); results.push('late_worklet_cannot_reattach_microphone'); return {passed: results.length, checks: results, physical_microphone_used: false, hosted_api_calls: 0, real_websockets: 0}; } finally { window.WebSocket = realWS; navigator.mediaDevices.getUserMedia = realGUM; } }""" def main(): static = Path(__file__).resolve().parent / 'static' server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), partial(QuietHandler, directory=str(static))) threading.Thread(target=server.serve_forever, daemon=True).start() try: with sync_playwright() as p: browser = p.chromium.launch(executable_path=os.environ.get( 'ROBO_CHROME', '/usr/bin/google-chrome'), headless=True, args=['--no-sandbox']) try: page = browser.new_page() # A directory index is unnecessary: only establish a secure-local origin. page.goto(f'http://127.0.0.1:{server.server_port}/clock.js') errors = [] page.on('pageerror', lambda e: errors.append(str(e))) report = page.evaluate(CHECKS) assert not errors, errors target=Path(__file__).resolve().parent/'logs/browser-lifecycle-checks.json' target.parent.mkdir(exist_ok=True) target.write_text(json.dumps(report,indent=2)) print(json.dumps(report, indent=2)) finally: browser.close() finally: server.shutdown() server.server_close() if __name__ == '__main__': main()