| 1 | // native-webkit.mjs — the Mac app in a call with Safari's engine. |
| 2 | // |
| 3 | // Driven by TestNativeMacWebKit (make native-webkit). 2026-10-03: Till, |
| 4 | // on an iPhone in Safari, said a call with Paul on the Mac app "kept |
| 5 | // cutting in and out", with nothing recorded. This is the nearest this |
| 6 | // machine gets: Playwright's WebKit with its mock camera and microphone, |
| 7 | // on macOS, against the shipping app core (CallsInterop). It is NOT iOS |
| 8 | // Safari — no cellular radio, no background suspension, no audio session |
| 9 | // interruptions, no Low Power Mode — and it cannot shape packet loss or |
| 10 | // jitter (that needs root for dnctl/pfctl). What it can hold: |
| 11 | // |
| 12 | // - media both ways, and what was negotiated: the codecs each way and |
| 13 | // Opus's in-band FEC; |
| 14 | // - the app's sender giving way on a thin path (its bandwidth estimate |
| 15 | // capped, as `uplink` does) while WebKit keeps decoding and hearing; |
| 16 | // - the tab coming back after the system ended its camera and |
| 17 | // microphone (iOS does, in the background): the room opens them again |
| 18 | // and the app hears and sees it again (room.js comeBack). |
| 19 | // |
| 20 | // node test/browser/native-webkit.mjs <origin> <room> <binary> |
| 21 | import { webkit } from 'playwright'; |
| 22 | import { spawn } from 'node:child_process'; |
| 23 | import { createInterface } from 'node:readline'; |
| 24 | import assert from 'node:assert/strict'; |
| 25 | |
| 26 | const [origin, room, binary] = process.argv.slice(2); |
| 27 | const browser = await webkit.launch({ headless: true }); |
| 28 | const context = await browser.newContext({ permissions: ['camera', 'microphone'] }); |
| 29 | await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]); |
| 30 | const page = await context.newPage(); |
| 31 | await page.addInitScript(() => { |
| 32 | const Native = window.RTCPeerConnection; |
| 33 | window.__pcs = new Set(); |
| 34 | window.RTCPeerConnection = class extends Native { |
| 35 | constructor(...args) { super(...args); window.__pcs.add(this); } |
| 36 | }; |
| 37 | // The streams the room opened, so the test can end their tracks as the |
| 38 | // system would. |
| 39 | window.__gum = []; |
| 40 | const gum = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); |
| 41 | navigator.mediaDevices.getUserMedia = async (c) => { |
| 42 | const s = await gum(c); |
| 43 | window.__gum.push(s); |
| 44 | return s; |
| 45 | }; |
| 46 | }); |
| 47 | |
| 48 | let native; |
| 49 | let sample = null; |
| 50 | let exited = false; |
| 51 | const wait = async (predicate, label, seconds = 40) => { |
| 52 | const until = Date.now() + seconds * 1000; |
| 53 | while (Date.now() < until) { |
| 54 | if (exited) throw new Error('native executable exited'); |
| 55 | if (await predicate()) return; |
| 56 | await new Promise((r) => setTimeout(r, 250)); |
| 57 | } |
| 58 | throw new Error(label + ': ' + JSON.stringify(sample && sample.stats)); |
| 59 | }; |
| 60 | |
| 61 | // What WebKit is receiving from the app and sending to it, off its own |
| 62 | // connection's stats. |
| 63 | const webkitStats = () => page.evaluate(async () => { |
| 64 | const out = { videoIn: null, audioIn: null, videoOut: null, audioOut: null, fec: null, pair: null }; |
| 65 | for (const pc of window.__pcs) { |
| 66 | if (pc.connectionState !== 'connected') continue; |
| 67 | const stats = await pc.getStats(); |
| 68 | const codecs = {}; |
| 69 | stats.forEach((r) => { if (r.type === 'codec') codecs[r.id] = r; }); |
| 70 | stats.forEach((r) => { |
| 71 | const codec = r.codecId && codecs[r.codecId] ? codecs[r.codecId].mimeType + (codecs[r.codecId].sdpFmtpLine ? ' ' + codecs[r.codecId].sdpFmtpLine : '') : ''; |
| 72 | if (r.type === 'inbound-rtp' && r.kind === 'video') { |
| 73 | out.videoIn = { codec, framesDecoded: r.framesDecoded || 0, width: r.frameWidth || 0, height: r.frameHeight || 0, |
| 74 | fps: r.framesPerSecond || 0, bytes: r.bytesReceived || 0, lost: r.packetsLost || 0, freezes: r.freezeCount || 0 }; |
| 75 | } |
| 76 | if (r.type === 'inbound-rtp' && r.kind === 'audio') { |
| 77 | out.audioIn = { codec, bytes: r.bytesReceived || 0, energy: r.totalAudioEnergy || 0, concealed: r.concealedSamples || 0, |
| 78 | samples: r.totalSamplesReceived || 0, lost: r.packetsLost || 0, fecRecovered: r.fecPacketsReceived || 0 }; |
| 79 | } |
| 80 | if (r.type === 'outbound-rtp' && r.kind === 'video') out.videoOut = { codec, frames: r.framesEncoded || 0 }; |
| 81 | if (r.type === 'outbound-rtp' && r.kind === 'audio') out.audioOut = { codec, bytes: r.bytesSent || 0 }; |
| 82 | if (r.type === 'candidate-pair' && r.nominated && r.state === 'succeeded') out.pair = { rtt: r.currentRoundTripTime }; |
| 83 | }); |
| 84 | const sdp = (pc.remoteDescription && pc.remoteDescription.sdp) || ''; |
| 85 | const opus = /a=rtpmap:(\d+) opus\/48000/i.exec(sdp); |
| 86 | if (opus) { |
| 87 | const fmtp = new RegExp('a=fmtp:' + opus[1] + ' ([^\\r\\n]*)').exec(sdp); |
| 88 | out.fec = fmtp ? fmtp[1] : ''; |
| 89 | } |
| 90 | break; |
| 91 | } |
| 92 | return out; |
| 93 | }); |
| 94 | |
| 95 | try { |
| 96 | page.on('pageerror', (e) => console.log('page error', String(e))); |
| 97 | await page.goto(`${origin}/m/${room}`); |
| 98 | await page.waitForFunction(() => !!window.meet); |
| 99 | native = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'] }); |
| 100 | native.on('exit', () => { exited = true; }); |
| 101 | native.stdin.write(process.env.CALLS_NATIVE_INPUT + '\n'); |
| 102 | createInterface({ input: native.stdout }).on('line', (text) => { |
| 103 | try { |
| 104 | const value = JSON.parse(text); |
| 105 | if (value.stats) sample = value; |
| 106 | } catch {} |
| 107 | }); |
| 108 | native.stderr.resume(); |
| 109 | // WebKit wants a gesture before a remote <video> may play with sound: |
| 110 | // the room's "click anywhere" is that gesture. |
| 111 | const nudge = setInterval(() => { page.mouse.click(5, 300).catch(() => {}); }, 2000); |
| 112 | |
| 113 | await wait(() => sample && sample.stats.framesDecoded > 15 && sample.stats.audioBytesReceived > 0, |
| 114 | 'the app decodes WebKit video and receives its audio'); |
| 115 | console.log('PASS the app decodes WebKit’s video and receives its audio'); |
| 116 | await wait(async () => { |
| 117 | const s = await webkitStats(); |
| 118 | return s.videoIn && s.videoIn.framesDecoded > 15 && s.audioIn && s.audioIn.bytes > 0; |
| 119 | }, 'WebKit decodes the app’s video and receives its audio'); |
| 120 | let s = await webkitStats(); |
| 121 | console.log('PASS WebKit decodes the app’s video and receives its audio'); |
| 122 | console.log('negotiated ' + JSON.stringify({ |
| 123 | appToWebKitVideo: s.videoIn.codec, appToWebKitAudio: s.audioIn.codec, |
| 124 | webKitToAppVideo: s.videoOut && s.videoOut.codec, webKitToAppAudio: s.audioOut && s.audioOut.codec, |
| 125 | opusFmtpFromApp: s.fec, |
| 126 | received: { width: s.videoIn.width, height: s.videoIn.height, fps: s.videoIn.fps }, |
| 127 | })); |
| 128 | assert.match(s.fec || '', /useinbandfec=1/, 'the app offers or answers Opus with in-band FEC'); |
| 129 | console.log('PASS Opus in-band FEC is negotiated from the app'); |
| 130 | |
| 131 | // A thin path from the app: its bandwidth estimate capped at 300 kbit/s, |
| 132 | // as a phone on a poor cellular link would hold it. The sender should |
| 133 | // give way (resolution or rate) and the sound should carry on. |
| 134 | native.stdin.write('uplink 300000\n'); |
| 135 | await new Promise((r) => setTimeout(r, 12000)); |
| 136 | const before = await webkitStats(); |
| 137 | await new Promise((r) => setTimeout(r, 5000)); |
| 138 | const after = await webkitStats(); |
| 139 | const kbps = ((after.videoIn.bytes - before.videoIn.bytes) * 8) / 5000; |
| 140 | const decoded = after.videoIn.framesDecoded - before.videoIn.framesDecoded; |
| 141 | const audioGrew = after.audioIn.bytes - before.audioIn.bytes; |
| 142 | console.log('thin path ' + JSON.stringify({ videoKbps: Math.round(kbps), framesIn5s: decoded, |
| 143 | width: after.videoIn.width, height: after.videoIn.height, audioBytesIn5s: audioGrew, |
| 144 | concealed: after.audioIn.concealed - before.audioIn.concealed, freezes: after.videoIn.freezes })); |
| 145 | assert.ok(kbps < 400, 'the app’s video gave way to the 300 kbit/s cap (' + Math.round(kbps) + ' kbit/s)'); |
| 146 | assert.ok(decoded > 10, 'WebKit kept decoding video on the thin path (' + decoded + ' frames in 5 s)'); |
| 147 | assert.ok(audioGrew > 5000, 'and kept receiving sound (' + audioGrew + ' bytes in 5 s)'); |
| 148 | console.log('PASS on a thin path the app gives way and WebKit keeps the picture and the sound'); |
| 149 | native.stdin.write('uplink 0\n'); |
| 150 | |
| 151 | // The system ending the tab's camera and microphone, as iOS does with |
| 152 | // Safari in the background, and the tab coming back to the front. |
| 153 | const appBefore = { audio: sample.stats.audioBytesReceived, frames: sample.stats.framesDecoded }; |
| 154 | await page.evaluate(() => { |
| 155 | for (const s of window.__gum) for (const t of s.getTracks()) t.stop(); |
| 156 | let shown = 'hidden'; |
| 157 | Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => shown }); |
| 158 | Object.defineProperty(document, 'hidden', { configurable: true, get: () => shown === 'hidden' }); |
| 159 | document.dispatchEvent(new Event('visibilitychange')); |
| 160 | window.__show = () => { shown = 'visible'; document.dispatchEvent(new Event('visibilitychange')); }; |
| 161 | }); |
| 162 | await new Promise((r) => setTimeout(r, 3000)); |
| 163 | const stalled = { audio: sample.stats.audioBytesReceived, frames: sample.stats.framesDecoded }; |
| 164 | await page.evaluate(() => window.__show()); |
| 165 | await wait(async () => { |
| 166 | const local = await page.evaluate(() => window.meet.local()); |
| 167 | return local.length >= 2 && local.every((t) => t.state === 'live'); |
| 168 | }, 'the room opens the camera and microphone again when the tab is back', 15); |
| 169 | console.log('PASS the room opens the camera and microphone again when the tab is back'); |
| 170 | const reopened = { audio: sample.stats.audioBytesReceived, frames: sample.stats.framesDecoded }; |
| 171 | await wait(() => sample.stats.audioBytesReceived > reopened.audio + 4000 && sample.stats.framesDecoded > reopened.frames + 15, |
| 172 | 'the app hears and sees WebKit again', 20); |
| 173 | console.log('PASS the app hears and sees WebKit again ' + JSON.stringify({ appBefore, stalled, reopened, |
| 174 | after: { audio: sample.stats.audioBytesReceived, frames: sample.stats.framesDecoded } })); |
| 175 | |
| 176 | clearInterval(nudge); |
| 177 | native.stdin.write('leave\n'); |
| 178 | await new Promise((r) => setTimeout(r, 1000)); |
| 179 | } catch (err) { |
| 180 | console.log('FAIL ' + (err && err.message ? err.message : String(err))); |
| 181 | process.exitCode = 1; |
| 182 | } finally { |
| 183 | if (native && !exited) native.kill(); |
| 184 | await browser.close(); |
| 185 | } |
| 186 | |