| 1 | import { chromium } from 'playwright'; |
| 2 | import { spawn } from 'node:child_process'; |
| 3 | import { createInterface } from 'node:readline'; |
| 4 | import assert from 'node:assert/strict'; |
| 5 | import { fileURLToPath } from 'node:url'; |
| 6 | |
| 7 | const [origin, room, binary, mode] = process.argv.slice(2); |
| 8 | const 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 | ] }); |
| 16 | const context = await browser.newContext({ permissions: ['camera', 'microphone'] }); |
| 17 | await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]); |
| 18 | const page = await context.newPage(); |
| 19 | await 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 | }); |
| 26 | let native; |
| 27 | try { |
| 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. |
| 462 | async 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 | |