import { syntheticMedia } from './synthetic-media.mjs'; import { chromium } from 'playwright'; import { spawn } from 'node:child_process'; import { createInterface } from 'node:readline'; import assert from 'node:assert/strict'; import { fileURLToPath } from 'node:url'; const [origin, room, binary, mode] = process.argv.slice(2); const browser = await chromium.launch({ headless: true, args: [ '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', '--disable-features=WebRtcHideLocalIpsWithMdns', '--mute-audio', // §5.4: answers getDisplayMedia with a fake monitor that really // produces frames, as call.mjs does for the browser-only share gate. '--auto-accept-this-tab-capture', ] }); const context = await browser.newContext({ permissions: ['camera', 'microphone'] }); if (process.env.CALLS_SYNTHETIC_MEDIA) await context.addInitScript(syntheticMedia); await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]); const page = await context.newPage(); await page.addInitScript(() => { const Native = window.RTCPeerConnection; window.__nativeInteropPCs = new Set(); window.RTCPeerConnection = class extends Native { constructor(...args) { super(...args); window.__nativeInteropPCs.add(this); } }; }); let native; try { await page.goto(`${origin}/m/${room}`); await page.waitForFunction(() => !!window.meet); console.log('browser before native', await page.evaluate(() => ({status: document.querySelector('#status')?.textContent, tiles: document.querySelectorAll('#tiles video').length}))); // Spec §15.24, both ways: the browser's offers, answers and candidates // are held back for the first few seconds, so the app and the browser // each have someone who cannot hear them yet, and each says so. const HOLD = 5000; let releaseAt = 0; const holdSignals = async (route) => { const body = route.request().postData() || ''; if (/"type":"(offer|answer|candidate)"/.test(body)) { if (!releaseAt) releaseAt = Date.now() + HOLD; await new Promise(r => setTimeout(r, Math.max(0, releaseAt - Date.now()))); } await route.continue(); }; if (mode !== 'guest') await page.route('**/signal', holdSignals); native = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'] }); native.stdin.write(process.env.CALLS_NATIVE_INPUT + '\n'); let sample = null; let chatCommandHandled = false; let chatSnapshotSaved = false; let invitationReady = false; let invitationRefused = false; let invitationSnapshotSaved = false; let audioSwitched = null; let camera = null; let cameraRefused = false; let exited = false; native.on('exit', () => { exited = true; }); // Native logs must never print credentials, SDP or candidates. Keep only // the gate's explicit numeric observations and suppress library diagnostics. createInterface({ input: native.stdout }).on('line', line => { try { const value = JSON.parse(line); if (value.chatSnapshotSaved) chatSnapshotSaved = true; if (value.chatCommandHandled) chatCommandHandled = true; if (value.invitationReady) invitationReady = true; if (value.invitationRefused) invitationRefused = true; if (value.invitationSnapshotSaved) invitationSnapshotSaved = true; if (value.audioSwitched !== undefined) audioSwitched = value.audioSwitched; if (value.camera !== undefined) { camera = value.camera; cameraRefused = value.cameraRefused; } if (value.stats) { sample = value; console.log(JSON.stringify(value)); } } catch {} }); native.stderr.resume(); const wait = async (predicate, label, seconds = 35) => { const until = Date.now() + seconds * 1000; while (Date.now() < until) { if (exited) throw new Error('native executable exited'); if (await predicate()) return; await new Promise(r => setTimeout(r, 250)); } console.log('browser at failure', await page.evaluate(() => ({status: document.querySelector('#status')?.textContent, tiles: document.querySelectorAll('#tiles video').length, body: document.body.innerText}))); throw new Error(label + ': ' + JSON.stringify(sample)); }; if (mode === 'guest') { await page.getByRole('button', { name: 'Admit', exact: true }).waitFor(); await wait(() => sample !== null, "native waiting observation"); await new Promise(r => setTimeout(r, 1500)); assert.equal(sample?.stats.framesDecoded ?? 0, 0, 'waiting guest receives no video'); assert.equal(sample?.stats.audioBytesReceived ?? 0, 0, 'waiting guest receives no audio'); await page.getByRole('button', { name: 'Admit', exact: true }).click(); console.log('PASS guest media waits for member admission'); } if (mode !== 'guest') { await wait(() => sample?.peer, 'the app is in the call'); const app = sample.peer; const connecting = () => page.evaluate((id) => { const s = window.meet.state(); return { c: s.connecting[id] || null, h: s.hearing[id] || null, media: s.remoteMedia[id] || null, me: s.peer, hearingList: s.hearingList }; }, app); await wait(async () => { const c = await connecting(); return c.h && c.h.noticeKind === 'connecting'; }, 'the browser says the app cannot hear it yet', 15); const words = (await connecting()).h.notice; assert.match(words, / is connecting and can’t hear you yet$/); console.log(`PASS the browser says "${words}" while the connection is held`); await wait(() => sample?.stats.connectingShown === 1, 'the app says the browser cannot hear it yet', 15); console.log('PASS the app says the browser cannot hear it yet'); await wait(async () => { const c = await connecting(); return c.c && !c.c.joining && c.h.notice === ''; }, 'the browser stops saying it once the app hears it', 30); const heard = await connecting(); assert.ok((heard.media.hearing || []).includes(heard.me), 'because the app reports it hears the browser'); assert.ok(Date.now() >= releaseAt, 'and not before the hold let go'); console.log('PASS the app reports hearing the browser, and the browser\u2019s notice goes'); await wait(() => sample?.stats.connectingShown === 0 && sample?.stats.connectingJoining === 0 && sample?.stats.hearing === 1, 'the app stops saying it once the browser hears it', 30); assert.ok(heard.hearingList.includes(app) || (await connecting()).hearingList.includes(app), 'the browser reports it hears the app'); console.log('PASS the browser reports hearing the app, and the app\u2019s notice goes'); await page.unroute('**/signal', holdSignals); } await wait(() => sample?.stats.framesDecoded > 15 && sample?.stats.audioBytesReceived > 0 && sample?.stats.videoBytesSent > 0 && sample?.stats.audioBytesSent > 0, 'native receives browser audio/video'); console.log('PASS native decoded browser video and received audio'); // The synthetic microphone runs through WebRTC's capture processing, so // the noise suppressor in its post-processing hook must have seen frames. await wait(() => sample?.stats.noiseFramesProcessed > 100, 'native noise suppressor processes captured audio'); console.log(`PASS native noise suppressor ran on captured audio (${sample.stats.noiseSuppressor === 2 ? 'DeepFilterNet 3' : 'RNNoise'})`); // And it runs instead of WebRTC's own suppressor and gain, beside its echo canceller. assert.equal(sample.stats.webrtcNoiseSuppression, 0, "WebRTC's noise suppressor is off"); assert.equal(sample.stats.webrtcGainControl, 0, "WebRTC's gain control is off"); assert.equal(sample.stats.webrtcEchoCancellation, 1, "echo cancellation stays on"); console.log('PASS WebRTC noise suppression and gain off, echo cancellation on'); // With audio flowing, the readout reports what is running rather than // "The microphone has not started yet". assert.equal(sample.stats.audioReadoutStarted, 1, 'the audio readout knows the microphone has started'); console.log('PASS the audio readout reports a started microphone'); // Each remote video has its own decoded frame count; local preview alone // cannot satisfy this check. Read the actual room test observation contract. // Remote videos exclude the muted local preview. getVideoPlaybackQuality // measures decoded frames in the actual browser renderer. const remoteFrames = () => page.evaluate(() => [...document.querySelectorAll('#tiles video')] .filter(v => !v.muted).reduce((n,v) => n + (v.getVideoPlaybackQuality?.().totalVideoFrames ?? 0), 0)); await wait(async () => await remoteFrames() > 15, 'browser decodes native video'); const browserAudio = () => page.evaluate(async () => { let bytes = 0; for (const pc of window.__nativeInteropPCs) { if (pc.connectionState === 'closed') continue; const stats = await pc.getStats(); for (const report of stats.values()) { if (report.type === 'inbound-rtp' && report.kind === 'audio') bytes += report.bytesReceived || 0; } } return bytes; }); await wait(async () => await browserAudio() > 0, 'browser receives native audio'); console.log('PASS browser decoded native video and received native audio'); // Spec §15.20: bytes are not sound. On 2026-09-25 a call carried audio // packets both ways while one side sent silence, and every counter said // all was well. So the browser has to measure *energy* in what the app // sends (inbound-rtp totalAudioEnergy and audioLevel), and the app's own // receive meter has to see the browser's fake microphone. { const browserHearing = () => page.evaluate(async () => { let energy = 0, level = 0; for (const pc of window.__nativeInteropPCs) { if (pc.connectionState === 'closed') continue; for (const report of (await pc.getStats()).values()) { if (report.type === 'inbound-rtp' && report.kind === 'audio') { energy += report.totalAudioEnergy || 0; level = Math.max(level, report.audioLevel || 0); } } } return { energy, level }; }); const before = await browserHearing(); let loudest = 0; await wait(async () => { const now = await browserHearing(); loudest = Math.max(loudest, now.level); return now.energy > before.energy + 0.001 && loudest > 0.01; }, 'the browser hears non-silent audio from the app'); console.log(`PASS the browser measures sound, not only bytes, from the app (audioLevel up to ${loudest.toFixed(3)})`); await wait(() => sample?.stats.remoteAudioPeakPermille > 10, "the app's receive meter hears the browser"); console.log(`PASS the app's receive meter hears the browser (peak ${sample.stats.remoteAudioPeakPermille}\u2030)`); await wait(() => sample?.stats.micPeakPermille > 10, "the app's own meter sees what it sends"); assert.equal(sample.stats.micSilent, 0, "a live synthetic microphone is not reported as picking nothing up"); console.log(`PASS the app's microphone meter reads what it sends, with no silence warning (noSoundFrom ${sample.stats.noSoundFrom})`); await wait(() => sample?.stats.micGlow === 1, "the app's own name glows while its synthetic voice speaks"); console.log('PASS the speaking glow behind the app\'s own name lights for its voice'); } // Choose the system default on the audio device module during the live // call, then refuse an unavailable microphone. Both must leave media and // the peer connection intact. (The harness's module is in manual // rendering, so this exercises the routing code, not a device change.) const audioBeforeSwitch = await browserAudio(); native.stdin.write('audio-default\n'); await wait(() => audioSwitched === true, 'native applies per-call audio selection'); await wait(async () => await browserAudio() > audioBeforeSwitch + 1000, 'audio continues after switching'); native.stdin.write('audio-missing\n'); await wait(() => audioSwitched === false, 'native refuses missing microphone'); const audioAfterRefusal = await browserAudio(); await wait(async () => await browserAudio() > audioAfterRefusal + 1000, 'audio survives refused selection'); assert.equal(sample.stats.peerConnections, 1, 'audio switching keeps the same peer connection'); console.log('PASS per-call audio switching and refused selection preserve media'); // Changing camera mid-call. The switch rests on the capturer feeding the // same RTCVideoSource, so the RTCVideoTrack every connection is already // sending does not change and nothing renegotiates — the native // counterpart of the browser's replaceTrack. What proves it is the far // side: frames must keep arriving across the switch, on the same // connection, with no new m-line. { const beforeFrames = await remoteFrames(); const beforeLines = await page.evaluate(() => window.meet.state().peers.length); native.stdin.write('camera synthetic-480\n'); await wait(() => camera === 'synthetic-480', 'native reports the camera it switched to'); assert.equal(cameraRefused, false, 'camera switch was refused'); await wait(async () => await remoteFrames() > beforeFrames + 15, 'browser keeps decoding across a camera switch'); assert.equal( await page.evaluate(() => window.meet.state().peers.length), beforeLines, 'a camera switch must not change the roster or rebuild the pair'); // Two switches at once are two capture restarts on one device. Queued, // the last one wins; run at once, the survivor is whichever session // opened last, which is not the one a person chose. native.stdin.write('camera synthetic-320\n'); native.stdin.write('camera synthetic-480\n'); await wait(() => camera === 'synthetic-480', 'the last of two racing switches is the one that holds'); const racedFrames = await remoteFrames(); await wait(async () => await remoteFrames() > racedFrames + 15, 'media survives racing camera switches'); // And a camera that is not there is refused without costing the call // its picture. native.stdin.write('camera no-such-camera\n'); await wait(() => cameraRefused === true, 'an unknown camera is refused'); const refusedFrames = await remoteFrames(); await wait(async () => await remoteFrames() > refusedFrames + 15, 'media survives a refused camera switch'); console.log('PASS camera switched mid-call without disturbing the connection'); } // A camera that stops sending — an ATEM Mini whose input changed format // — is noticed by the watchdog and restarted, on the same track, and the // far side's picture comes back without anyone touching anything. { await wait(() => sample?.cameraStatus === 'live', 'the camera reports live before the stall'); const opensBefore = sample.cameraOpens; native.stdin.write('camera-stall\n'); await wait(() => sample?.cameraOpens > opensBefore, 'the watchdog restarts a stalled camera', 20); await wait(() => sample?.cameraStatus === 'live', 'the restarted camera reports live again', 20); const recoveredFrames = await remoteFrames(); await wait(async () => await remoteFrames() > recoveredFrames + 15, 'browser decodes again after the camera recovers'); assert.equal(sample.stats.peerConnections, 1, 'recovering the camera keeps the same peer connection'); console.log('PASS a stalled camera was restarted and the far side kept its picture'); } // Chat is not replayed to a recipient whose stream is renewing (§6), and // in this mode the stream renews every ten seconds, so a line that lands // in a renewal's gap is lost by design. Since the camera steps above went // in, chat runs well after the first renewal, and under load the gap is // wide enough to catch it (2026-09-25: both subtests failed here on an // unchanged tree, then passed alone and in a full run). Each direction // gets three tries; a line that never arrives still fails. for (let attempt = 1; ; attempt++) { await openWebChat(page); await page.locator('[data-chat-text]').fill('browser chat probe'); await page.locator('[data-chat-send]').click(); try { await wait(() => sample?.receivedChatProbe === true, 'native displays browser chat', 8); break; } catch (err) { if (attempt === 3) throw err; console.log('RETRY browser chat probe, attempt', attempt + 1); } } for (let attempt = 1; ; attempt++) { native.stdin.write('chat\n'); await wait(() => chatCommandHandled, 'native handles send-chat command'); try { await page.locator('[data-chat-log]').getByText('native chat probe', { exact: true }).first() .waitFor({ timeout: 8000 }); break; } catch (err) { if (attempt === 3) throw err; console.log('RETRY native chat probe, attempt', attempt + 1); } } await wait(() => sample?.sentChatProbe === true, 'native displays accepted outgoing chat'); console.log('PASS chat delivered from browser to native and native to browser'); // Spec §15.19 across the two clients: the app's mute reaches the // browser tile as the badge, the browser's reaches the app's snapshot, // and both clear again. { const nativePeer = sample.peer; const nativeMarked = () => page.evaluate((id) => { const m = document.querySelector('#tiles figure[data-tile="' + id + '"] .calls-tile-muted'); return !!(m && !m.hidden); }, nativePeer); assert.equal(await nativeMarked(), false, 'the native peer starts unmuted on the browser tile'); native.stdin.write('mute\n'); await wait(nativeMarked, 'browser shows the native peer muted'); await page.click('#mute'); await wait(() => sample?.remoteMuted === 1, 'native sees the browser muted'); await page.click('#mute'); native.stdin.write('mute\n'); await wait(async () => !(await nativeMarked()) && sample?.remoteMuted === 0, 'both unmuted again'); console.log('PASS mute state crosses between native and browser both ways'); } // The screen scenarios run after chat and not before: they take the // better part of a minute, and chat is not replayed to a recipient // whose stream is renewing, which in this mode is every ten seconds. // Screen share does not depend on its message arriving — the m-line // is classified on its own, and a stopped transceiver ends the track — // so it can share the call with renewals where chat cannot. // §5.4, receiving: the browser shares its screen and the app shows it // as a tile of its own, captioned with the sharer's name, while the // browser's camera keeps arriving on the tile it always had. Then the // browser stops, and the tile goes — by the `share {on:false}` or the // stopped transceiver, whichever lands first — with the camera still // arriving afterwards. if (mode === 'member') { const name = await page.evaluate(() => document.getElementById('room')?.dataset.selfName || ''); assert.notEqual(name, '', 'the browser has a name for its screen to be captioned with'); const caption = name + '\u2019s screen'; await page.click('#share'); await wait(() => sample?.screenCaptions?.length === 1 && sample.screenCaptions[0] === caption, 'native captions the browser\u2019s screen with its name'); await wait(() => sample?.stats.remoteScreens === 1, 'the browser\u2019s screen reaches the app as a track'); const screenBase = sample.stats.screenFramesDecoded; await wait(() => sample.stats.screenFramesDecoded > screenBase + 15, 'native decodes the browser\u2019s screen'); const cameraBase = sample.stats.framesDecoded; await wait(() => sample.stats.framesDecoded > cameraBase + 15, 'the browser\u2019s camera keeps arriving beside its screen'); assert.equal(sample.stats.remoteTracks, 1, 'the screen is not taken for a second camera'); console.log('PASS native shows the browser\u2019s screen as its own captioned tile'); await page.click('#share'); await wait(() => sample?.screenCaptions?.length === 0 && sample.stats.remoteScreens === 0 && sample.stats.screens === 0, 'native removes the browser\u2019s screen when it stops'); const afterStop = sample.stats.framesDecoded; await wait(() => sample.stats.framesDecoded > afterStop + 15, 'the browser\u2019s camera survives its screen stopping'); console.log('PASS native removes a stopped screen and keeps the camera'); } // §5.4, sending: the app shares a (synthetic) screen and the browser // shows it exactly as it shows another browser's — a tile of its own // under shareKey, captioned with the app's name, decoding real frames // while the app's camera keeps arriving. Stopping must *end* the // browser's received track, which only a stopped transceiver and a // renegotiation do; a message alone would leave a frozen frame behind // on a browser that never got it. Then the capture ending on its own, // the path the system's stop control takes, ends the share the same // way. if (mode === 'member') { const nativePeer = sample.peer; const key = nativePeer + '#share'; const browserState = () => page.evaluate(() => { const s = window.meet.state(); return { shares: s.shares, frames: Object.fromEntries(s.frames) }; }); const tiles = () => page.evaluate(() => ({ camera: [...document.querySelectorAll('#tiles figure.calls-tile:not(.calls-tile-self):not(.calls-tile-share) figcaption span')] .map(e => e.textContent.trim()).filter(Boolean)[0] || '', screens: [...document.querySelectorAll('#tiles figure.calls-tile-share figcaption span')] .map(e => e.textContent.trim()).filter(Boolean), })); for (const ending of ['screen-off', 'screen-end']) { native.stdin.write('screen-on\n'); await wait(() => sample?.sharingScreen === true, 'native says it is sharing'); await wait(async () => (await browserState()).shares.includes(nativePeer), 'the browser is told whose screen is coming'); const { camera } = await tiles(); assert.notEqual(camera, '', 'the browser captions the app\u2019s camera'); await wait(async () => { const t = await tiles(); return t.screens.length === 1 && t.screens[0] === camera + '\u2019s screen'; }, 'the browser captions the app\u2019s screen with its name'); const screenBase = (await browserState()).frames[key] || 0; await wait(async () => ((await browserState()).frames[key] || 0) > screenBase + 15, 'the browser decodes the app\u2019s screen'); const cameraBase = (await browserState()).frames[nativePeer] || 0; await wait(async () => ((await browserState()).frames[nativePeer] || 0) > cameraBase + 15, 'the app\u2019s camera keeps arriving beside its screen'); await wait(() => sample.stats.screenBytesSent > 0, 'the app counts its screen apart from its camera'); console.log('PASS the browser shows the app\u2019s screen as its own captioned tile'); const armed = await page.evaluate(() => { const v = document.querySelector('#tiles figure.calls-tile-share video'); const track = v && v.srcObject ? v.srcObject.getVideoTracks()[0] : null; if (!track) return null; window.__nativeShareEnded = false; track.addEventListener('ended', () => { window.__nativeShareEnded = true; }); return track.readyState; }); assert.equal(armed, 'live', 'the received screen track is live and watched for its ending'); native.stdin.write(ending + '\n'); await wait(() => sample?.sharingScreen === false, 'native says it has stopped'); await wait(async () => (await browserState()).shares.length === 0 && (await tiles()).screens.length === 0, 'the browser removes the app\u2019s screen'); await wait(() => page.evaluate(() => window.__nativeShareEnded === true), 'the browser\u2019s received screen track ended rather than froze'); const afterStop = (await browserState()).frames[nativePeer] || 0; await wait(async () => ((await browserState()).frames[nativePeer] || 0) > afterStop + 15, 'the app\u2019s camera survives its screen stopping'); console.log('PASS the app\u2019s screen ended in the browser (' + ending + ') and its camera continued'); } } if (mode === 'chat') { native.stdin.write('share\n'); await wait(() => invitationReady, 'native member retrieves the original invitation'); const expectedInvitation = JSON.parse(process.env.CALLS_NATIVE_INPUT).invitation; const popupPromise = context.waitForEvent('page'); await page.getByRole('button', { name: 'Share meeting link', exact: true }).click(); const sharePage = await popupPromise; await sharePage.waitForLoadState(); assert.equal(await sharePage.locator('#invitation-link').getAttribute('href'), expectedInvitation); // Exercise Copy without changing the operator's system clipboard. await sharePage.evaluate(() => Object.defineProperty(navigator, 'clipboard', { value: { writeText: async value => { window.copiedInvitation = value; } }, })); await sharePage.getByRole('button', { name: 'Copy link', exact: true }).click(); assert.equal(await sharePage.evaluate(() => window.copiedInvitation), expectedInvitation); await sharePage.screenshot({ path: fileURLToPath(new URL('../../native/build/share-web.png', import.meta.url)) }); await sharePage.close(); const plain = await browser.newContext({ javaScriptEnabled: false }); await plain.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]); const plainPage = await plain.newPage(); await plainPage.goto(`${origin}/m/${room}`); const plainPopup = plain.waitForEvent('page'); await plainPage.getByRole('button', { name: 'Share meeting link', exact: true }).click(); const plainShare = await plainPopup; await plainShare.waitForLoadState(); assert.equal(await plainShare.locator('#invitation-link').getAttribute('href'), expectedInvitation); await plain.close(); console.log('PASS native sharing, web Share and Copy, and scriptless sharing preserve the invitation'); for (let n = 1; n <= 12; n++) { await openWebChat(page); await page.locator('[data-chat-text]').fill(`Incoming browser message ${n}`); await page.locator('[data-chat-send]').click(); await wait(() => sample?.receivedChatCount === n + 1, `native receives browser message ${n}`); } native.stdin.write('snapshot\n'); await wait(() => chatSnapshotSaved, 'native chat screenshot saved'); native.stdin.write('share-snapshot\n'); await wait(() => invitationSnapshotSaved, 'native invitation screenshot saved'); console.log('PASS all 12 browser messages reached native chat; rendered screenshot saved'); } else { native.stdin.write('share\n'); await wait(() => mode === 'guest' ? invitationRefused : invitationReady, 'native sharing follows current membership'); const peer = sample.peer; const beforeNative = sample.stats.framesDecoded; const beforeBrowser = await remoteFrames(); const beforeAudio = await browserAudio(); const beforeNativeAudio = sample.stats.audioBytesReceived; // The test server renews streams every ten seconds. Observe fresh frames // well after two renewals, and require the same native participation. await new Promise(r => setTimeout(r, 22000)); await wait(async () => sample.stats.framesDecoded > beforeNative + 20 && await remoteFrames() > beforeBrowser + 20 && await browserAudio() > beforeAudio && sample.stats.audioBytesReceived > beforeNativeAudio, 'media survives stream renewal'); assert.equal(sample.peer, peer, 'resume must preserve the native participation'); console.log('PASS native/browser media continues across SSE renewals'); } const left = new Promise((resolve, reject) => { native.once('exit', code => code === 0 ? resolve() : reject(new Error('native leave failed'))); }); native.stdin.write('leave\n'); await Promise.race([left, new Promise((_,reject) => { setTimeout(() => reject(new Error('native leave timed out')), 5000).unref(); })]); await page.waitForFunction(() => [...document.querySelectorAll('#tiles video')].filter(v => !v.muted).length === 0); console.log('PASS leaving native call removes its browser tile'); } finally { native?.kill('SIGTERM'); await browser.close(); } // The web call's chat is a sidebar over the picture that starts closed // (the call window, 2026-09-28): open it, as a person would, before // typing into it. async function openWebChat(page) { await page.locator('#toggle-chat:not([hidden])').waitFor({ timeout: 10000 }); if ((await page.locator('#toggle-chat').getAttribute('aria-expanded')) !== 'true') { await page.locator('#toggle-chat').click(); } await page.locator('[data-chat-text]').waitFor({ state: 'visible', timeout: 5000 }); }