oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1import { chromium } from 'playwright';
2import { spawn } from 'node:child_process';
3import { createInterface } from 'node:readline';
4import assert from 'node:assert/strict';
5import { fileURLToPath } from 'node:url';
6
7const [origin, room, binary, mode] = process.argv.slice(2);
8const browser = await chromium.launch({ headless: true, args: [
9 '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream',
10 '--autoplay-policy=no-user-gesture-required', '--disable-features=WebRtcHideLocalIpsWithMdns',
11 '--mute-audio',
12 // §5.4: answers getDisplayMedia with a fake monitor that really
13 // produces frames, as call.mjs does for the browser-only share gate.
14 '--auto-accept-this-tab-capture',
15] });
16const context = await browser.newContext({ permissions: ['camera', 'microphone'] });
17await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]);
18const page = await context.newPage();
19await page.addInitScript(() => {
20 const Native = window.RTCPeerConnection;
21 window.__nativeInteropPCs = new Set();
22 window.RTCPeerConnection = class extends Native {
23 constructor(...args) { super(...args); window.__nativeInteropPCs.add(this); }
24 };
25});
26let native;
27try {
28 await page.goto(`${origin}/m/${room}`);
29 await page.waitForFunction(() => !!window.meet);
30 console.log('browser before native', await page.evaluate(() => ({status: document.querySelector('#status')?.textContent, tiles: document.querySelectorAll('#tiles video').length})));
31 // Spec §15.24, both ways: the browser's offers, answers and candidates
32 // are held back for the first few seconds, so the app and the browser
33 // each have someone who cannot hear them yet, and each says so.
34 const HOLD = 5000;
35 let releaseAt = 0;
36 const holdSignals = async (route) => {
37 const body = route.request().postData() || '';
38 if (/"type":"(offer|answer|candidate)"/.test(body)) {
39 if (!releaseAt) releaseAt = Date.now() + HOLD;
40 await new Promise(r => setTimeout(r, Math.max(0, releaseAt - Date.now())));
41 }
42 await route.continue();
43 };
44 if (mode !== 'guest') await page.route('**/signal', holdSignals);
45 native = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'] });
46 native.stdin.write(process.env.CALLS_NATIVE_INPUT + '\n');
47 let sample = null;
48 let chatCommandHandled = false;
49 let chatSnapshotSaved = false;
50 let invitationReady = false;
51 let invitationRefused = false;
52 let invitationSnapshotSaved = false;
53 let audioSwitched = null;
54 let camera = null;
55 let cameraRefused = false;
56 let exited = false;
57 native.on('exit', () => { exited = true; });
58 // Native logs must never print credentials, SDP or candidates. Keep only
59 // the gate's explicit numeric observations and suppress library diagnostics.
60 createInterface({ input: native.stdout }).on('line', line => {
61 try {
62 const value = JSON.parse(line);
63 if (value.chatSnapshotSaved) chatSnapshotSaved = true;
64 if (value.chatCommandHandled) chatCommandHandled = true;
65 if (value.invitationReady) invitationReady = true;
66 if (value.invitationRefused) invitationRefused = true;
67 if (value.invitationSnapshotSaved) invitationSnapshotSaved = true;
68 if (value.audioSwitched !== undefined) audioSwitched = value.audioSwitched;
69 if (value.camera !== undefined) { camera = value.camera; cameraRefused = value.cameraRefused; }
70 if (value.stats) { sample = value; console.log(JSON.stringify(value)); }
71 } catch {}
72 });
73 native.stderr.resume();
74 const wait = async (predicate, label, seconds = 35) => {
75 const until = Date.now() + seconds * 1000;
76 while (Date.now() < until) {
77 if (exited) throw new Error('native executable exited');
78 if (await predicate()) return;
79 await new Promise(r => setTimeout(r, 250));
80 }
81 console.log('browser at failure', await page.evaluate(() => ({status: document.querySelector('#status')?.textContent, tiles: document.querySelectorAll('#tiles video').length, body: document.body.innerText})));
82 throw new Error(label + ': ' + JSON.stringify(sample));
83 };
84 if (mode === 'guest') {
85 await page.getByRole('button', { name: 'Admit', exact: true }).waitFor();
86 await wait(() => sample !== null, "native waiting observation");
87 await new Promise(r => setTimeout(r, 1500));
88 assert.equal(sample?.stats.framesDecoded ?? 0, 0, 'waiting guest receives no video');
89 assert.equal(sample?.stats.audioBytesReceived ?? 0, 0, 'waiting guest receives no audio');
90 await page.getByRole('button', { name: 'Admit', exact: true }).click();
91 console.log('PASS guest media waits for member admission');
92 }
93 if (mode !== 'guest') {
94 await wait(() => sample?.peer, 'the app is in the call');
95 const app = sample.peer;
96 const connecting = () => page.evaluate((id) => {
97 const s = window.meet.state();
98 return { c: s.connecting[id] || null, h: s.hearing[id] || null, media: s.remoteMedia[id] || null,
99 me: s.peer, hearingList: s.hearingList };
100 }, app);
101 await wait(async () => { const c = await connecting(); return c.h && c.h.noticeKind === 'connecting'; },
102 'the browser says the app cannot hear it yet', 15);
103 const words = (await connecting()).h.notice;
104 assert.match(words, / is connecting and can’t hear you yet$/);
105 console.log(`PASS the browser says "${words}" while the connection is held`);
106 await wait(() => sample?.stats.connectingShown === 1, 'the app says the browser cannot hear it yet', 15);
107 console.log('PASS the app says the browser cannot hear it yet');
108 await wait(async () => { const c = await connecting(); return c.c && !c.c.joining && c.h.notice === ''; },
109 'the browser stops saying it once the app hears it', 30);
110 const heard = await connecting();
111 assert.ok((heard.media.hearing || []).includes(heard.me), 'because the app reports it hears the browser');
112 assert.ok(Date.now() >= releaseAt, 'and not before the hold let go');
113 console.log('PASS the app reports hearing the browser, and the browser\u2019s notice goes');
114 await wait(() => sample?.stats.connectingShown === 0 && sample?.stats.connectingJoining === 0 && sample?.stats.hearing === 1,
115 'the app stops saying it once the browser hears it', 30);
116 assert.ok(heard.hearingList.includes(app) || (await connecting()).hearingList.includes(app), 'the browser reports it hears the app');
117 console.log('PASS the browser reports hearing the app, and the app\u2019s notice goes');
118 await page.unroute('**/signal', holdSignals);
119 }
120 await wait(() => sample?.stats.framesDecoded > 15 && sample?.stats.audioBytesReceived > 0 && sample?.stats.videoBytesSent > 0 && sample?.stats.audioBytesSent > 0, 'native receives browser audio/video');
121 console.log('PASS native decoded browser video and received audio');
122 // The synthetic microphone runs through WebRTC's capture processing, so
123 // the noise suppressor in its post-processing hook must have seen frames.
124 await wait(() => sample?.stats.noiseFramesProcessed > 100, 'native noise suppressor processes captured audio');
125 console.log(`PASS native noise suppressor ran on captured audio (${sample.stats.noiseSuppressor === 2 ? 'DeepFilterNet 3' : 'RNNoise'})`);
126 // And it runs instead of WebRTC's own suppressor and gain, beside its echo canceller.
127 assert.equal(sample.stats.webrtcNoiseSuppression, 0, "WebRTC's noise suppressor is off");
128 assert.equal(sample.stats.webrtcGainControl, 0, "WebRTC's gain control is off");
129 assert.equal(sample.stats.webrtcEchoCancellation, 1, "echo cancellation stays on");
130 console.log('PASS WebRTC noise suppression and gain off, echo cancellation on');
131 // With audio flowing, the readout reports what is running rather than
132 // "The microphone has not started yet".
133 assert.equal(sample.stats.audioReadoutStarted, 1, 'the audio readout knows the microphone has started');
134 console.log('PASS the audio readout reports a started microphone');
135 // Each remote video has its own decoded frame count; local preview alone
136 // cannot satisfy this check. Read the actual room test observation contract.
137 // Remote videos exclude the muted local preview. getVideoPlaybackQuality
138 // measures decoded frames in the actual browser renderer.
139 const remoteFrames = () => page.evaluate(() => [...document.querySelectorAll('#tiles video')]
140 .filter(v => !v.muted).reduce((n,v) => n + (v.getVideoPlaybackQuality?.().totalVideoFrames ?? 0), 0));
141 await wait(async () => await remoteFrames() > 15, 'browser decodes native video');
142 const browserAudio = () => page.evaluate(async () => {
143 let bytes = 0;
144 for (const pc of window.__nativeInteropPCs) {
145 if (pc.connectionState === 'closed') continue;
146 const stats = await pc.getStats();
147 for (const report of stats.values()) {
148 if (report.type === 'inbound-rtp' && report.kind === 'audio') bytes += report.bytesReceived || 0;
149 }
150 }
151 return bytes;
152 });
153 await wait(async () => await browserAudio() > 0, 'browser receives native audio');
154 console.log('PASS browser decoded native video and received native audio');
155 // Spec §15.20: bytes are not sound. On 2026-09-25 a call carried audio
156 // packets both ways while one side sent silence, and every counter said
157 // all was well. So the browser has to measure *energy* in what the app
158 // sends (inbound-rtp totalAudioEnergy and audioLevel), and the app's own
159 // receive meter has to see the browser's fake microphone.
160 {
161 const browserHearing = () => page.evaluate(async () => {
162 let energy = 0, level = 0;
163 for (const pc of window.__nativeInteropPCs) {
164 if (pc.connectionState === 'closed') continue;
165 for (const report of (await pc.getStats()).values()) {
166 if (report.type === 'inbound-rtp' && report.kind === 'audio') {
167 energy += report.totalAudioEnergy || 0;
168 level = Math.max(level, report.audioLevel || 0);
169 }
170 }
171 }
172 return { energy, level };
173 });
174 const before = await browserHearing();
175 let loudest = 0;
176 await wait(async () => {
177 const now = await browserHearing();
178 loudest = Math.max(loudest, now.level);
179 return now.energy > before.energy + 0.001 && loudest > 0.01;
180 }, 'the browser hears non-silent audio from the app');
181 console.log(`PASS the browser measures sound, not only bytes, from the app (audioLevel up to ${loudest.toFixed(3)})`);
182 await wait(() => sample?.stats.remoteAudioPeakPermille > 10, "the app's receive meter hears the browser");
183 console.log(`PASS the app's receive meter hears the browser (peak ${sample.stats.remoteAudioPeakPermille}\u2030)`);
184 await wait(() => sample?.stats.micPeakPermille > 10, "the app's own meter sees what it sends");
185 assert.equal(sample.stats.micSilent, 0, "a live synthetic microphone is not reported as picking nothing up");
186 console.log(`PASS the app's microphone meter reads what it sends, with no silence warning (noSoundFrom ${sample.stats.noSoundFrom})`);
187 await wait(() => sample?.stats.micGlow === 1, "the app's own name glows while its synthetic voice speaks");
188 console.log('PASS the speaking glow behind the app\'s own name lights for its voice');
189 }
190 // Choose the system default on the audio device module during the live
191 // call, then refuse an unavailable microphone. Both must leave media and
192 // the peer connection intact. (The harness's module is in manual
193 // rendering, so this exercises the routing code, not a device change.)
194 const audioBeforeSwitch = await browserAudio();
195 native.stdin.write('audio-default\n');
196 await wait(() => audioSwitched === true, 'native applies per-call audio selection');
197 await wait(async () => await browserAudio() > audioBeforeSwitch + 1000, 'audio continues after switching');
198 native.stdin.write('audio-missing\n');
199 await wait(() => audioSwitched === false, 'native refuses missing microphone');
200 const audioAfterRefusal = await browserAudio();
201 await wait(async () => await browserAudio() > audioAfterRefusal + 1000, 'audio survives refused selection');
202 assert.equal(sample.stats.peerConnections, 1, 'audio switching keeps the same peer connection');
203 console.log('PASS per-call audio switching and refused selection preserve media');
204 // Changing camera mid-call. The switch rests on the capturer feeding the
205 // same RTCVideoSource, so the RTCVideoTrack every connection is already
206 // sending does not change and nothing renegotiates — the native
207 // counterpart of the browser's replaceTrack. What proves it is the far
208 // side: frames must keep arriving across the switch, on the same
209 // connection, with no new m-line.
210 {
211 const beforeFrames = await remoteFrames();
212 const beforeLines = await page.evaluate(() => window.meet.state().peers.length);
213 native.stdin.write('camera synthetic-480\n');
214 await wait(() => camera === 'synthetic-480', 'native reports the camera it switched to');
215 assert.equal(cameraRefused, false, 'camera switch was refused');
216 await wait(async () => await remoteFrames() > beforeFrames + 15, 'browser keeps decoding across a camera switch');
217 assert.equal(
218 await page.evaluate(() => window.meet.state().peers.length), beforeLines,
219 'a camera switch must not change the roster or rebuild the pair');
220 // Two switches at once are two capture restarts on one device. Queued,
221 // the last one wins; run at once, the survivor is whichever session
222 // opened last, which is not the one a person chose.
223 native.stdin.write('camera synthetic-320\n');
224 native.stdin.write('camera synthetic-480\n');
225 await wait(() => camera === 'synthetic-480', 'the last of two racing switches is the one that holds');
226 const racedFrames = await remoteFrames();
227 await wait(async () => await remoteFrames() > racedFrames + 15, 'media survives racing camera switches');
228 // And a camera that is not there is refused without costing the call
229 // its picture.
230 native.stdin.write('camera no-such-camera\n');
231 await wait(() => cameraRefused === true, 'an unknown camera is refused');
232 const refusedFrames = await remoteFrames();
233 await wait(async () => await remoteFrames() > refusedFrames + 15, 'media survives a refused camera switch');
234 console.log('PASS camera switched mid-call without disturbing the connection');
235 }
236 // A camera that stops sending — an ATEM Mini whose input changed format
237 // — is noticed by the watchdog and restarted, on the same track, and the
238 // far side's picture comes back without anyone touching anything.
239 {
240 await wait(() => sample?.cameraStatus === 'live', 'the camera reports live before the stall');
241 const opensBefore = sample.cameraOpens;
242 native.stdin.write('camera-stall\n');
243 await wait(() => sample?.cameraOpens > opensBefore, 'the watchdog restarts a stalled camera', 20);
244 await wait(() => sample?.cameraStatus === 'live', 'the restarted camera reports live again', 20);
245 const recoveredFrames = await remoteFrames();
246 await wait(async () => await remoteFrames() > recoveredFrames + 15, 'browser decodes again after the camera recovers');
247 assert.equal(sample.stats.peerConnections, 1, 'recovering the camera keeps the same peer connection');
248 console.log('PASS a stalled camera was restarted and the far side kept its picture');
249 }
250 // Chat is not replayed to a recipient whose stream is renewing (§6), and
251 // in this mode the stream renews every ten seconds, so a line that lands
252 // in a renewal's gap is lost by design. Since the camera steps above went
253 // in, chat runs well after the first renewal, and under load the gap is
254 // wide enough to catch it (2026-09-25: both subtests failed here on an
255 // unchanged tree, then passed alone and in a full run). Each direction
256 // gets three tries; a line that never arrives still fails.
257 for (let attempt = 1; ; attempt++) {
258 await openWebChat(page);
259 await page.locator('[data-chat-text]').fill('browser chat probe');
260 await page.locator('[data-chat-send]').click();
261 try {
262 await wait(() => sample?.receivedChatProbe === true, 'native displays browser chat', 8);
263 break;
264 } catch (err) {
265 if (attempt === 3) throw err;
266 console.log('RETRY browser chat probe, attempt', attempt + 1);
267 }
268 }
269 for (let attempt = 1; ; attempt++) {
270 native.stdin.write('chat\n');
271 await wait(() => chatCommandHandled, 'native handles send-chat command');
272 try {
273 await page.locator('[data-chat-log]').getByText('native chat probe', { exact: true }).first()
274 .waitFor({ timeout: 8000 });
275 break;
276 } catch (err) {
277 if (attempt === 3) throw err;
278 console.log('RETRY native chat probe, attempt', attempt + 1);
279 }
280 }
281 await wait(() => sample?.sentChatProbe === true, 'native displays accepted outgoing chat');
282 console.log('PASS chat delivered from browser to native and native to browser');
283 // Spec §15.19 across the two clients: the app's mute reaches the
284 // browser tile as the badge, the browser's reaches the app's snapshot,
285 // and both clear again.
286 {
287 const nativePeer = sample.peer;
288 const nativeMarked = () => page.evaluate((id) => {
289 const m = document.querySelector('#tiles figure[data-tile="' + id + '"] .calls-tile-muted');
290 return !!(m && !m.hidden);
291 }, nativePeer);
292 assert.equal(await nativeMarked(), false, 'the native peer starts unmuted on the browser tile');
293 native.stdin.write('mute\n');
294 await wait(nativeMarked, 'browser shows the native peer muted');
295 await page.click('#mute');
296 await wait(() => sample?.remoteMuted === 1, 'native sees the browser muted');
297 await page.click('#mute');
298 native.stdin.write('mute\n');
299 await wait(async () => !(await nativeMarked()) && sample?.remoteMuted === 0, 'both unmuted again');
300 console.log('PASS mute state crosses between native and browser both ways');
301 }
302 // The screen scenarios run after chat and not before: they take the
303 // better part of a minute, and chat is not replayed to a recipient
304 // whose stream is renewing, which in this mode is every ten seconds.
305 // Screen share does not depend on its message arriving — the m-line
306 // is classified on its own, and a stopped transceiver ends the track —
307 // so it can share the call with renewals where chat cannot.
308 // §5.4, receiving: the browser shares its screen and the app shows it
309 // as a tile of its own, captioned with the sharer's name, while the
310 // browser's camera keeps arriving on the tile it always had. Then the
311 // browser stops, and the tile goes — by the `share {on:false}` or the
312 // stopped transceiver, whichever lands first — with the camera still
313 // arriving afterwards.
314 if (mode === 'member') {
315 const name = await page.evaluate(() => document.getElementById('room')?.dataset.selfName || '');
316 assert.notEqual(name, '', 'the browser has a name for its screen to be captioned with');
317 const caption = name + '\u2019s screen';
318 await page.click('#share');
319 await wait(() => sample?.screenCaptions?.length === 1 && sample.screenCaptions[0] === caption,
320 'native captions the browser\u2019s screen with its name');
321 await wait(() => sample?.stats.remoteScreens === 1, 'the browser\u2019s screen reaches the app as a track');
322 const screenBase = sample.stats.screenFramesDecoded;
323 await wait(() => sample.stats.screenFramesDecoded > screenBase + 15, 'native decodes the browser\u2019s screen');
324 const cameraBase = sample.stats.framesDecoded;
325 await wait(() => sample.stats.framesDecoded > cameraBase + 15, 'the browser\u2019s camera keeps arriving beside its screen');
326 assert.equal(sample.stats.remoteTracks, 1, 'the screen is not taken for a second camera');
327 console.log('PASS native shows the browser\u2019s screen as its own captioned tile');
328 await page.click('#share');
329 await wait(() => sample?.screenCaptions?.length === 0 && sample.stats.remoteScreens === 0 && sample.stats.screens === 0,
330 'native removes the browser\u2019s screen when it stops');
331 const afterStop = sample.stats.framesDecoded;
332 await wait(() => sample.stats.framesDecoded > afterStop + 15, 'the browser\u2019s camera survives its screen stopping');
333 console.log('PASS native removes a stopped screen and keeps the camera');
334 }
335 // §5.4, sending: the app shares a (synthetic) screen and the browser
336 // shows it exactly as it shows another browser's — a tile of its own
337 // under shareKey, captioned with the app's name, decoding real frames
338 // while the app's camera keeps arriving. Stopping must *end* the
339 // browser's received track, which only a stopped transceiver and a
340 // renegotiation do; a message alone would leave a frozen frame behind
341 // on a browser that never got it. Then the capture ending on its own,
342 // the path the system's stop control takes, ends the share the same
343 // way.
344 if (mode === 'member') {
345 const nativePeer = sample.peer;
346 const key = nativePeer + '#share';
347 const browserState = () => page.evaluate(() => {
348 const s = window.meet.state();
349 return { shares: s.shares, frames: Object.fromEntries(s.frames) };
350 });
351 const tiles = () => page.evaluate(() => ({
352 camera: [...document.querySelectorAll('#tiles figure.calls-tile:not(.calls-tile-self):not(.calls-tile-share) figcaption span')]
353 .map(e => e.textContent.trim()).filter(Boolean)[0] || '',
354 screens: [...document.querySelectorAll('#tiles figure.calls-tile-share figcaption span')]
355 .map(e => e.textContent.trim()).filter(Boolean),
356 }));
357 for (const ending of ['screen-off', 'screen-end']) {
358 native.stdin.write('screen-on\n');
359 await wait(() => sample?.sharingScreen === true, 'native says it is sharing');
360 await wait(async () => (await browserState()).shares.includes(nativePeer), 'the browser is told whose screen is coming');
361 const { camera } = await tiles();
362 assert.notEqual(camera, '', 'the browser captions the app\u2019s camera');
363 await wait(async () => { const t = await tiles(); return t.screens.length === 1 && t.screens[0] === camera + '\u2019s screen'; },
364 'the browser captions the app\u2019s screen with its name');
365 const screenBase = (await browserState()).frames[key] || 0;
366 await wait(async () => ((await browserState()).frames[key] || 0) > screenBase + 15, 'the browser decodes the app\u2019s screen');
367 const cameraBase = (await browserState()).frames[nativePeer] || 0;
368 await wait(async () => ((await browserState()).frames[nativePeer] || 0) > cameraBase + 15,
369 'the app\u2019s camera keeps arriving beside its screen');
370 await wait(() => sample.stats.screenBytesSent > 0, 'the app counts its screen apart from its camera');
371 console.log('PASS the browser shows the app\u2019s screen as its own captioned tile');
372 const armed = await page.evaluate(() => {
373 const v = document.querySelector('#tiles figure.calls-tile-share video');
374 const track = v && v.srcObject ? v.srcObject.getVideoTracks()[0] : null;
375 if (!track) return null;
376 window.__nativeShareEnded = false;
377 track.addEventListener('ended', () => { window.__nativeShareEnded = true; });
378 return track.readyState;
379 });
380 assert.equal(armed, 'live', 'the received screen track is live and watched for its ending');
381 native.stdin.write(ending + '\n');
382 await wait(() => sample?.sharingScreen === false, 'native says it has stopped');
383 await wait(async () => (await browserState()).shares.length === 0 && (await tiles()).screens.length === 0,
384 'the browser removes the app\u2019s screen');
385 await wait(() => page.evaluate(() => window.__nativeShareEnded === true),
386 'the browser\u2019s received screen track ended rather than froze');
387 const afterStop = (await browserState()).frames[nativePeer] || 0;
388 await wait(async () => ((await browserState()).frames[nativePeer] || 0) > afterStop + 15,
389 'the app\u2019s camera survives its screen stopping');
390 console.log('PASS the app\u2019s screen ended in the browser (' + ending + ') and its camera continued');
391 }
392 }
393 if (mode === 'chat') {
394 native.stdin.write('share\n');
395 await wait(() => invitationReady, 'native member retrieves the original invitation');
396 const expectedInvitation = JSON.parse(process.env.CALLS_NATIVE_INPUT).invitation;
397 const popupPromise = context.waitForEvent('page');
398 await page.getByRole('button', { name: 'Share meeting link', exact: true }).click();
399 const sharePage = await popupPromise;
400 await sharePage.waitForLoadState();
401 assert.equal(await sharePage.locator('#invitation-link').getAttribute('href'), expectedInvitation);
402 // Exercise Copy without changing the operator's system clipboard.
403 await sharePage.evaluate(() => Object.defineProperty(navigator, 'clipboard', {
404 value: { writeText: async value => { window.copiedInvitation = value; } },
405 }));
406 await sharePage.getByRole('button', { name: 'Copy link', exact: true }).click();
407 assert.equal(await sharePage.evaluate(() => window.copiedInvitation), expectedInvitation);
408 await sharePage.screenshot({ path: fileURLToPath(new URL('../../native/build/share-web.png', import.meta.url)) });
409 await sharePage.close();
410 const plain = await browser.newContext({ javaScriptEnabled: false });
411 await plain.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]);
412 const plainPage = await plain.newPage();
413 await plainPage.goto(`${origin}/m/${room}`);
414 const plainPopup = plain.waitForEvent('page');
415 await plainPage.getByRole('button', { name: 'Share meeting link', exact: true }).click();
416 const plainShare = await plainPopup;
417 await plainShare.waitForLoadState();
418 assert.equal(await plainShare.locator('#invitation-link').getAttribute('href'), expectedInvitation);
419 await plain.close();
420 console.log('PASS native sharing, web Share and Copy, and scriptless sharing preserve the invitation');
421 for (let n = 1; n <= 12; n++) {
422 await openWebChat(page);
423 await page.locator('[data-chat-text]').fill(`Incoming browser message ${n}`);
424 await page.locator('[data-chat-send]').click();
425 await wait(() => sample?.receivedChatCount === n + 1, `native receives browser message ${n}`);
426 }
427 native.stdin.write('snapshot\n');
428 await wait(() => chatSnapshotSaved, 'native chat screenshot saved');
429 native.stdin.write('share-snapshot\n');
430 await wait(() => invitationSnapshotSaved, 'native invitation screenshot saved');
431 console.log('PASS all 12 browser messages reached native chat; rendered screenshot saved');
432 } else {
433 native.stdin.write('share\n');
434 await wait(() => mode === 'guest' ? invitationRefused : invitationReady, 'native sharing follows current membership');
435 const peer = sample.peer;
436 const beforeNative = sample.stats.framesDecoded;
437 const beforeBrowser = await remoteFrames();
438 const beforeAudio = await browserAudio();
439 const beforeNativeAudio = sample.stats.audioBytesReceived;
440 // The test server renews streams every ten seconds. Observe fresh frames
441 // well after two renewals, and require the same native participation.
442 await new Promise(r => setTimeout(r, 22000));
443 await wait(async () => sample.stats.framesDecoded > beforeNative + 20 && await remoteFrames() > beforeBrowser + 20 && await browserAudio() > beforeAudio && sample.stats.audioBytesReceived > beforeNativeAudio, 'media survives stream renewal');
444 assert.equal(sample.peer, peer, 'resume must preserve the native participation');
445 console.log('PASS native/browser media continues across SSE renewals');
446 }
447 const left = new Promise((resolve, reject) => {
448 native.once('exit', code => code === 0 ? resolve() : reject(new Error('native leave failed')));
449 });
450 native.stdin.write('leave\n');
451 await Promise.race([left, new Promise((_,reject) => { setTimeout(() => reject(new Error('native leave timed out')), 5000).unref(); })]);
452 await page.waitForFunction(() => [...document.querySelectorAll('#tiles video')].filter(v => !v.muted).length === 0);
453 console.log('PASS leaving native call removes its browser tile');
454} finally {
455 native?.kill('SIGTERM');
456 await browser.close();
457}
458
459// The web call's chat is a sidebar over the picture that starts closed
460// (the call window, 2026-09-28): open it, as a person would, before
461// typing into it.
462async function openWebChat(page) {
463 await page.locator('#toggle-chat:not([hidden])').waitFor({ timeout: 10000 });
464 if ((await page.locator('#toggle-chat').getAttribute('aria-expanded')) !== 'true') {
465 await page.locator('#toggle-chat').click();
466 }
467 await page.locator('[data-chat-text]').waitFor({ state: 'visible', timeout: 5000 });
468}
469