oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

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>
21import { webkit } from 'playwright';
22import { spawn } from 'node:child_process';
23import { createInterface } from 'node:readline';
24import assert from 'node:assert/strict';
25
26const [origin, room, binary] = process.argv.slice(2);
27const browser = await webkit.launch({ headless: true });
28const context = await browser.newContext({ permissions: ['camera', 'microphone'] });
29await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]);
30const page = await context.newPage();
31await 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
48let native;
49let sample = null;
50let exited = false;
51const 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.
63const 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
95try {
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