diff --git a/internal/e2e-js/tests/v2Webrtc/v2WebrtcFromRest.spec.ts b/internal/e2e-js/tests/v2Webrtc/v2WebrtcFromRest.spec.ts
new file mode 100644
index 000000000..ada017890
--- /dev/null
+++ b/internal/e2e-js/tests/v2Webrtc/v2WebrtcFromRest.spec.ts
@@ -0,0 +1,814 @@
+import { expect, Page, test } from '../../fixtures'
+
+import {
+ SERVER_URL,
+ createCallWithCompatibilityApi,
+ createTestJWTToken,
+ expectInjectIceTransportPolicy,
+ expectedMinPackets,
+ expectInjectRelayHost,
+ expectRelayConnected,
+ expectv2HasReceivedAudio,
+ expectv2HasReceivedSilence,
+ getDialConferenceLaml,
+ randomizeResourceName,
+ MockWebhookServer,
+} from '../../utils'
+
+const silenceDescription = 'should handle a call from REST API to v2 client, playing silence at answer'
+test.describe('v2WebrtcFromRestSilence', () => {
+ test(silenceDescription, async ({
+ createCustomVanillaPage,
+ }) => {
+ console.info('START: ', silenceDescription)
+
+ const expectCallActive = async (page: Page) => {
+ // Hangup call button locator
+ const hangupCall = page.locator('#hangupCall')
+ expect(hangupCall).not.toBe(null)
+
+ // Expect the Hangup button to be enabled (call active)
+ await expect(hangupCall).toBeEnabled()
+ }
+
+ const expectCallHangup = async (page: Page) => {
+ // Hangup call button locator
+ const hangupCall = page.locator('#hangupCall')
+ expect(hangupCall).not.toBe(null)
+
+ // Wait for call to be hung up
+ await expect(hangupCall).toBeDisabled()
+ }
+
+ const pageCallee = await createCustomVanillaPage({ name: '[callee]' })
+ await pageCallee.goto(SERVER_URL + '/v2vanilla.html')
+
+ const relayHost = process.env.RELAY_HOST ?? ''
+ await expectInjectRelayHost(pageCallee, relayHost)
+
+ const envRelayProject = process.env.RELAY_PROJECT ?? ''
+ expect(envRelayProject).not.toBe(null)
+
+ const resource = randomizeResourceName()
+ const jwtCallee = await createTestJWTToken({ resource: resource })
+ expect(jwtCallee).not.toBe(null)
+
+ await expectRelayConnected(pageCallee, envRelayProject, jwtCallee)
+
+ // Set to 30 seconds to keep it running during the 20 seconds call
+ const inlineLaml = `
+
+
+ `
+
+ console.log('inline Laml: ', inlineLaml)
+ const createResult = await createCallWithCompatibilityApi(
+ resource,
+ inlineLaml
+ )
+ expect(createResult).toBe(201)
+ console.log('REST API returned 201 at ', new Date())
+
+ const callStatusCallee = pageCallee.locator('#callStatus')
+ expect(callStatusCallee).not.toBe(null)
+ await expect(callStatusCallee).toContainText('-> active')
+
+ console.log('The call is active at ', new Date())
+
+ const callDurationMs = 20000
+ await pageCallee.waitForTimeout(callDurationMs)
+
+ // We want to ensure at this point the call hasn't timed out
+ await expectCallActive(pageCallee)
+
+ console.log('Time to check the audio energy at ', new Date())
+
+ // We expect silence...
+ const maxAudioEnergy = 0.01
+
+ // Check the audio energy level is above threshold
+ console.log('Expected max audio energy: ', maxAudioEnergy)
+
+ // Expect at least 70 % packets at 50 pps
+ const minPackets = expectedMinPackets(50, callDurationMs, 0.3)
+ await expectv2HasReceivedSilence(pageCallee, maxAudioEnergy, minPackets)
+
+ await expectCallActive(pageCallee)
+ console.log('Hanging up the call at ', new Date())
+ await pageCallee.click('#hangupCall')
+ await expectCallHangup(pageCallee)
+
+ console.info('END: ', silenceDescription)
+ })
+})
+
+const conferenceDescription = 'should handle a call from REST API to v2 client, dialing into a Conference at answer'
+test.describe('v2WebrtcFromRest', () => {
+ test(conferenceDescription, async ({
+ createCustomVanillaPage,
+ }) => {
+ console.info('START: ', conferenceDescription)
+
+ const expectCallActive = async (page: Page) => {
+ // Hangup call button locator
+ const hangupCall = page.locator('#hangupCall')
+ expect(hangupCall).not.toBe(null)
+
+ // Expect the Hangup button to be enabled (call active)
+ await expect(hangupCall).toBeEnabled()
+ }
+
+ const expectCallHangup = async (page: Page) => {
+ // Hangup call button locator
+ const hangupCall = page.locator('#hangupCall')
+ expect(hangupCall).not.toBe(null)
+
+ // Wait for call to be hung up
+ await expect(hangupCall).toBeDisabled()
+ }
+
+ const pageCallee = await createCustomVanillaPage({ name: '[callee]' })
+ await pageCallee.goto(SERVER_URL + '/v2vanilla.html')
+
+ const relayHost = process.env.RELAY_HOST ?? ''
+ await expectInjectRelayHost(pageCallee, relayHost)
+
+ const envRelayProject = process.env.RELAY_PROJECT ?? ''
+ expect(envRelayProject).not.toBe(null)
+
+ const resource = randomizeResourceName()
+ const jwtCallee = await createTestJWTToken({ resource: resource })
+ expect(jwtCallee).not.toBe(null)
+
+ await expectRelayConnected(pageCallee, envRelayProject, jwtCallee)
+
+ const inlineLaml = getDialConferenceLaml('v2rest0')
+
+ console.log('inline Laml: ', inlineLaml)
+ const createResult = await createCallWithCompatibilityApi(
+ resource,
+ inlineLaml,
+ 'PCMU,PCMA'
+ )
+ expect(createResult).toBe(201)
+ console.log('REST API returned 201 at ', new Date())
+
+ const callStatusCallee = pageCallee.locator('#callStatus')
+ expect(callStatusCallee).not.toBe(null)
+ await expect(callStatusCallee).toContainText('-> active')
+
+ console.log('The call is active at ', new Date())
+
+ // With 40 seconds we can catch a media timeout
+ const callDurationMs = 40000
+ await pageCallee.waitForTimeout(callDurationMs)
+
+ // Ensure the call hasn't been hang up, e.g. by a media timeout
+ await expectCallActive(pageCallee)
+
+ console.log('Time to check the audio energy at ', new Date())
+
+ // Empirical value; it depends on the call scenario
+ const minAudioEnergy = callDurationMs / 50000
+
+ // Check the audio energy level is above threshold
+ console.log('Expected min audio energy: ', minAudioEnergy)
+
+ // Expect at least 70 % packets at 50 pps
+ const minPackets = expectedMinPackets(50, callDurationMs, 0.3)
+
+ await expectv2HasReceivedAudio(pageCallee, minAudioEnergy, minPackets)
+
+ await expectCallActive(pageCallee)
+ console.log('Hanging up the call at ', new Date())
+ await pageCallee.click('#hangupCall')
+ await expectCallHangup(pageCallee)
+
+ console.info('END: ', conferenceDescription)
+ })
+})
+
+const twoJoinAudioVideoDescription = 'should handle a call from REST API to v2 clients, dialing both into a Conference at answer, audio/video'
+test.describe('v2WebrtcFromRestTwoJoinAudioVideo', () => {
+ test(twoJoinAudioVideoDescription, async ({
+ createCustomVanillaPage,
+ }) => {
+ console.info('START: ', twoJoinAudioVideoDescription)
+
+ const expectCallActive = async (page: Page) => {
+ // Hangup call button locator
+ const hangupCall = page.locator('#hangupCall')
+ expect(hangupCall).not.toBe(null)
+
+ // Expect the Hangup button to be enabled (call active)
+ await expect(hangupCall).toBeEnabled()
+ }
+
+ const expectCallHangup = async (page: Page) => {
+ // Hangup call button locator
+ const hangupCall = page.locator('#hangupCall')
+ expect(hangupCall).not.toBe(null)
+
+ // Wait for call to be hung up
+ await expect(hangupCall).toBeDisabled()
+ }
+
+ const pageCallee = await createCustomVanillaPage({ name: '[callee]' })
+ await pageCallee.goto(SERVER_URL + '/v2vanilla.html')
+
+ const pageCallee2 = await createCustomVanillaPage({ name: '[callee2]' })
+ await pageCallee2.goto(SERVER_URL + '/v2vanilla.html')
+
+ const relayHost = process.env.RELAY_HOST ?? ''
+ await expectInjectRelayHost(pageCallee, relayHost)
+ await expectInjectRelayHost(pageCallee2, relayHost)
+
+ const envRelayProject = process.env.RELAY_PROJECT ?? ''
+ expect(envRelayProject).not.toBe(null)
+
+ const resource = randomizeResourceName()
+ const jwtCallee = await createTestJWTToken({ resource: resource })
+ expect(jwtCallee).not.toBe(null)
+
+ const resource2 = randomizeResourceName()
+ const jwtCallee2 = await createTestJWTToken({ resource: resource2 })
+ expect(jwtCallee2).not.toBe(null)
+
+ await expectRelayConnected(pageCallee, envRelayProject, jwtCallee)
+ await expectRelayConnected(pageCallee2, envRelayProject, jwtCallee2)
+
+ const inlineLaml = getDialConferenceLaml('v2rest1')
+
+ console.log('inline Laml: ', inlineLaml)
+ const createResult = await createCallWithCompatibilityApi(
+ resource,
+ inlineLaml
+ )
+ expect(createResult).toBe(201)
+ console.log('REST API returned 201 at ', new Date())
+
+ const callStatusCallee = pageCallee.locator('#callStatus')
+ expect(callStatusCallee).not.toBe(null)
+ await expect(callStatusCallee).toContainText('-> active')
+
+ console.log('The call is active at ', new Date())
+
+ const createResult2 = await createCallWithCompatibilityApi(
+ resource2,
+ inlineLaml
+ )
+ expect(createResult2).toBe(201)
+ console.log('REST API returned 201 at ', new Date())
+
+ const callStatusCallee2 = pageCallee2.locator('#callStatus')
+ expect(callStatusCallee2).not.toBe(null)
+ await expect(callStatusCallee2).toContainText('-> active')
+
+ console.log('The call is active at ', new Date())
+
+ // With 40 seconds we can catch a media timeout
+ const callDurationMs = 40000
+ await pageCallee.waitForTimeout(callDurationMs)
+
+ await Promise.all([
+ expectCallActive(pageCallee),
+ expectCallActive(pageCallee2)
+ ])
+
+ console.log('Time to check the audio energy at ', new Date())
+
+ // An empirical value that depends on the call duration
+ // Nothing to do with sample rates
+ const minAudioEnergy = callDurationMs / 16000
+
+ // Check the audio energy level is above threshold
+ console.log('Expected min audio energy: ', minAudioEnergy)
+
+ // Expect at least 70 % packets at 50 pps
+ const minPackets = expectedMinPackets(50, callDurationMs, 0.3)
+
+ await expectv2HasReceivedAudio(pageCallee, minAudioEnergy, minPackets)
+ await expectv2HasReceivedAudio(pageCallee2, minAudioEnergy, minPackets)
+
+ await expectCallActive(pageCallee)
+ await expectCallActive(pageCallee2)
+
+ console.log('Hanging up the calls at ', new Date())
+
+ await pageCallee.click('#hangupCall')
+ await expectCallHangup(pageCallee)
+
+ await pageCallee2.click('#hangupCall')
+ await expectCallHangup(pageCallee2)
+
+ console.info('END: ', twoJoinAudioVideoDescription)
+ })
+})
+
+const twoJoinAudioTURNDescription = 'should handle a call from REST API to 2 v2 clients, dialing both into a Conference at answer, audio G711, TURN only'
+test.describe('v2WebrtcFromRestTwoJoinAudioTURN', () => {
+ test(twoJoinAudioTURNDescription, async ({
+ createCustomVanillaPage,
+ }) => {
+ console.info('START: ', twoJoinAudioTURNDescription)
+
+ const expectCallActive = async (page: Page) => {
+ // Hangup call button locator
+ const hangupCall = page.locator('#hangupCall')
+ expect(hangupCall).not.toBe(null)
+
+ // Expect the Hangup button to be enabled (call active)
+ await expect(hangupCall).toBeEnabled()
+ }
+
+ const expectCallHangup = async (page: Page) => {
+ // Hangup call button locator
+ const hangupCall = page.locator('#hangupCall')
+ expect(hangupCall).not.toBe(null)
+
+ // Wait for call to be hung up
+ await expect(hangupCall).toBeDisabled()
+ }
+
+ // Helper to detect MEDIA_TIMEOUT and unexpected hangups
+ const expectCallNotHungUp = async (page: Page, callName: string) => {
+ const callStatus = page.locator('#callStatus')
+ const statusText = await callStatus.textContent()
+
+ if (statusText?.includes('MEDIA_TIMEOUT')) {
+ throw new Error(
+ `${callName} failed with MEDIA_TIMEOUT (Code 804). ` +
+ `This usually indicates:\n` +
+ `- TURN server not available or misconfigured\n` +
+ `- Firewall blocking UDP/TURN traffic (port 3478)\n` +
+ `- Network instability or high latency\n` +
+ `- An old bug in FS - check version and environment\n` +
+ `- ICE gathering failed\n` +
+ `Status: ${statusText}`
+ )
+ }
+
+ if (statusText?.includes('hangup') || statusText?.includes('destroy')) {
+ throw new Error(
+ `${callName} unexpectedly hung up during test. Status: ${statusText}`
+ )
+ }
+
+ await expect(callStatus).toContainText('-> active')
+ }
+
+ const pageCallee1 = await createCustomVanillaPage({ name: '[callee1]' })
+ await pageCallee1.goto(SERVER_URL + '/v2vanilla.html')
+
+ const pageCallee2 = await createCustomVanillaPage({ name: '[callee2]' })
+ await pageCallee2.goto(SERVER_URL + '/v2vanilla.html')
+
+ const relayHost = process.env.RELAY_HOST ?? ''
+ await expectInjectRelayHost(pageCallee1, relayHost)
+ await expectInjectRelayHost(pageCallee2, relayHost)
+
+ const envRelayProject = process.env.RELAY_PROJECT ?? ''
+ expect(envRelayProject).not.toBe(null)
+
+ const resource1 = randomizeResourceName()
+ const jwtCallee1 = await createTestJWTToken({ resource: resource1 })
+ expect(jwtCallee1).not.toBe(null)
+
+ const resource2 = randomizeResourceName()
+ const jwtCallee2 = await createTestJWTToken({ resource: resource2 })
+ expect(jwtCallee2).not.toBe(null)
+
+ await expectRelayConnected(pageCallee1, envRelayProject, jwtCallee1)
+ await expectRelayConnected(pageCallee2, envRelayProject, jwtCallee2)
+
+ const inlineLaml = getDialConferenceLaml('v2rest2turn')
+ console.log('inline Laml: ', inlineLaml)
+
+ // Call to first callee
+
+ // Force TURN only
+ await expectInjectIceTransportPolicy(pageCallee1, 'relay')
+
+ const createResult = await createCallWithCompatibilityApi(
+ resource1,
+ inlineLaml,
+ 'PCMU,PCMA'
+ )
+ expect(createResult).toBe(201)
+ console.log('callee1 REST API returned 201 at ', new Date())
+
+ const callStatusCallee1 = pageCallee1.locator('#callStatus')
+ expect(callStatusCallee1).not.toBe(null)
+ await expect(callStatusCallee1).toContainText('-> active')
+
+ console.log('call1 is active at ', new Date())
+
+ // Call to second callee
+
+ // Force TURN only
+ await expectInjectIceTransportPolicy(pageCallee2, 'relay')
+
+ const createResult2 = await createCallWithCompatibilityApi(
+ resource2,
+ inlineLaml,
+ 'PCMU,PCMA'
+ )
+ expect(createResult2).toBe(201)
+ console.log('REST API returned 201 at ', new Date())
+
+ const callStatusCallee2 = pageCallee2.locator('#callStatus')
+ expect(callStatusCallee2).not.toBe(null)
+ await expect(callStatusCallee2).toContainText('-> active')
+
+ console.log('call2 is active at ', new Date())
+
+ // With 40 seconds we can catch a media timeout
+ // Check call state periodically to detect early failures
+ const callDurationMs = 40000
+ const checkInterval = 5000
+ const numChecks = Math.floor(callDurationMs / checkInterval)
+
+ console.log(`Monitoring calls for ${callDurationMs}ms (checking every ${checkInterval}ms)...`)
+
+ for (let i = 0; i < numChecks; i++) {
+ await pageCallee1.waitForTimeout(checkInterval)
+
+ // Verify both calls are still active (detect MEDIA_TIMEOUT early)
+ await Promise.all([
+ expectCallNotHungUp(pageCallee1, 'Callee1'),
+ expectCallNotHungUp(pageCallee2, 'Callee2')
+ ])
+
+ console.log(`✓ Calls still active after ${(i + 1) * checkInterval} ms`)
+ }
+
+ console.log('Time to check the audio energy at ', new Date())
+
+ // An empirical value that depends on the call duration
+ // Nothing to do with sample rates
+ const minAudioEnergy = callDurationMs / 16000
+
+ // Check the audio energy level is above threshold
+ console.log('Expected min audio energy: ', minAudioEnergy)
+
+ // Expect at least 70 % packets at 50 pps
+ const minPackets = expectedMinPackets(50, callDurationMs, 0.3)
+
+ await Promise.all([
+ expectv2HasReceivedAudio(pageCallee1, minAudioEnergy, minPackets),
+ expectv2HasReceivedAudio(pageCallee2, minAudioEnergy, minPackets)
+ ])
+
+ await Promise.all([
+ expectCallActive(pageCallee1),
+ expectCallActive(pageCallee2)
+ ])
+
+ console.log('Hanging up the calls at ', new Date())
+
+ await Promise.all([
+ pageCallee1.click('#hangupCall'),
+ pageCallee2.click('#hangupCall')
+ ])
+
+ await Promise.all([
+ expectCallHangup(pageCallee1),
+ expectCallHangup(pageCallee2)
+ ])
+
+ console.info('END: ', twoJoinAudioTURNDescription)
+ })
+})
+
+const get422Description = 'should handle a call from REST API to v2 client, receiving a 422 from REST API'
+test.describe('v2WebrtcFromRest422', () => {
+ test(get422Description, async ({
+ createCustomVanillaPage,
+ }) => {
+ console.info('START: ', get422Description)
+
+ const pageCallee = await createCustomVanillaPage({ name: '[callee]' })
+ await pageCallee.goto(SERVER_URL + '/v2vanilla.html')
+
+ const relayHost = process.env.RELAY_HOST ?? ''
+ await expectInjectRelayHost(pageCallee, relayHost)
+
+ const envRelayProject = process.env.RELAY_PROJECT ?? ''
+ expect(envRelayProject).not.toBe(null)
+
+ const resource = randomizeResourceName()
+ const jwtCallee = await createTestJWTToken({ resource: resource })
+ expect(jwtCallee).not.toBe(null)
+
+ await expectRelayConnected(pageCallee, envRelayProject, jwtCallee)
+
+ // This won't be used anyway
+ const inlineLaml = `
+
+
+ Words to speak
+
+ `
+
+ const invalidResource = "e2etest422"
+ const createResult = await createCallWithCompatibilityApi(
+ invalidResource,
+ inlineLaml
+ )
+ expect(createResult).toBe(422)
+ console.info('END: ', get422Description)
+ })
+})
+
+const callStatusWebhookDescription = 'should receive call status webhook callback'
+
+test.describe('v2WebRTCFromRestCallStatusWebhook', () => {
+ test(callStatusWebhookDescription, async ({
+ createCustomVanillaPage,
+ }) => {
+ console.info('START: ', callStatusWebhookDescription)
+ const mockWebhookServer = new MockWebhookServer()
+ const tunnelLink = await mockWebhookServer.listen(19898, true)
+ expect(tunnelLink).toMatch(/^http:\/\/.*\.zrok.swire.io/)
+
+ const expectCallActive = async (page: Page) => {
+ // Hangup call button locator
+ const hangupCall = page.locator('#hangupCall')
+ expect(hangupCall).not.toBe(null)
+
+ // Expect the Hangup button to be enabled (call active)
+ await expect(hangupCall).toBeEnabled()
+ }
+
+ const expectCallHangup = async (page: Page) => {
+ // Hangup call button locator
+ const hangupCall = page.locator('#hangupCall')
+ expect(hangupCall).not.toBe(null)
+
+ // Wait for call to be hung up
+ await expect(hangupCall).toBeDisabled()
+ }
+
+ const pageCallee = await createCustomVanillaPage({ name: '[callee]' })
+ await pageCallee.goto(SERVER_URL + '/v2vanilla.html')
+
+ const relayHost = process.env.RELAY_HOST ?? ''
+ await expectInjectRelayHost(pageCallee, relayHost)
+
+ const envRelayProject = process.env.RELAY_PROJECT ?? ''
+ expect(envRelayProject).not.toBe(null)
+
+ const resource = randomizeResourceName()
+ const jwtCallee = await createTestJWTToken({ resource: resource })
+ expect(jwtCallee).not.toBe(null)
+
+ await expectRelayConnected(pageCallee, envRelayProject, jwtCallee)
+
+ // Set to 30 seconds to keep it running during the 20 seconds call
+ const inlineLaml = `
+
+
+ `
+
+ console.log('inline Laml: ', inlineLaml)
+ const initiatedCallbackRequest = mockWebhookServer.waitFor('initiated')
+ const ringingCallbackRequest = mockWebhookServer.waitFor('ringing')
+ const answeredCallbackRequest = mockWebhookServer.waitFor('answered')
+ const createResult = await createCallWithCompatibilityApi(
+ resource,
+ inlineLaml,
+ undefined,
+ tunnelLink,
+ ['initiated', 'ringing', 'answered', 'completed']
+ )
+ expect(createResult).toBe(201)
+ console.log('REST API returned 201 at ', new Date())
+ const initiatedStatus = await initiatedCallbackRequest
+
+ expect(initiatedStatus).toMatchObject({
+ CallSid: expect.any(String),
+ AccountSid: expect.any(String),
+ From: expect.stringContaining(`sip:${process.env.VOICE_DIAL_FROM_NUMBER}`),
+ To: `verto:${resource}@${process.env.VERTO_DOMAIN}`,
+ CallStatus: 'initiated',
+ ApiVersion: '2010-04-01',
+ Timestamp: expect.any(String),
+ CallbackSource: 'call-progress-events',
+ })
+
+ const callStatusCallee = pageCallee.locator('#callStatus')
+ expect(callStatusCallee).not.toBe(null)
+ await expect(callStatusCallee).toContainText('-> active')
+
+ const ringingStatus = await ringingCallbackRequest
+ const answeredStatus = await answeredCallbackRequest
+
+ expect(ringingStatus).toMatchObject({
+ CallSid: expect.any(String),
+ AccountSid: expect.any(String),
+ From: expect.stringContaining(`sip:${process.env.VOICE_DIAL_FROM_NUMBER}`),
+ To: `verto:${resource}@${process.env.VERTO_DOMAIN}`,
+ CallStatus: 'ringing',
+ ApiVersion: '2010-04-01',
+ Timestamp: expect.any(String),
+ CallbackSource: 'call-progress-events',
+ })
+
+ expect(answeredStatus).toMatchObject({
+ CallSid: expect.any(String),
+ AccountSid: expect.any(String),
+ From: expect.stringContaining(`sip:${process.env.VOICE_DIAL_FROM_NUMBER}`),
+ To: `verto:${resource}@${process.env.VERTO_DOMAIN}`,
+ CallStatus: 'answered',
+ ApiVersion: '2010-04-01',
+ Timestamp: expect.any(String),
+ CallbackSource: 'call-progress-events',
+ })
+ console.log('The call is active at ', new Date())
+
+ const callDurationMs = 20000
+ await pageCallee.waitForTimeout(callDurationMs)
+
+ // We want to ensure at this point the call hasn't timed out
+ await expectCallActive(pageCallee)
+
+ console.log('Time to check the audio energy at ', new Date())
+
+ // We expect silence...
+ const maxAudioEnergy = 0.01
+
+ // Check the audio energy level is above threshold
+ console.log('Expected max audio energy: ', maxAudioEnergy)
+
+ // Expect at least 70 % packets at 50 pps
+ const minPackets = expectedMinPackets(50, callDurationMs, 0.3)
+ await expectv2HasReceivedSilence(pageCallee, maxAudioEnergy, minPackets)
+
+ await expectCallActive(pageCallee)
+ console.log('Hanging up the call at ', new Date())
+ const completedCallbackRequest = mockWebhookServer.waitFor('completed')
+ await pageCallee.click('#hangupCall')
+ await expectCallHangup(pageCallee)
+
+ const completedStatus = await completedCallbackRequest
+ await mockWebhookServer.close()
+
+ expect(completedStatus).toMatchObject({
+ CallSid: expect.any(String),
+ AccountSid: expect.any(String),
+ From: expect.stringContaining(`sip:${process.env.VOICE_DIAL_FROM_NUMBER}`),
+ To: `verto:${resource}@${process.env.VERTO_DOMAIN}`,
+ CallStatus: 'completed',
+ ApiVersion: '2010-04-01',
+ Timestamp: expect.any(String),
+ CallbackSource: 'call-progress-events',
+ })
+ console.info('END: ', callStatusWebhookDescription)
+ })
+})
+
+
+const conferenceWebhookDescription = 'should receive conference status webhook callbacks'
+test.describe('v2WebrtcFromRest', () => {
+ test(conferenceWebhookDescription, async ({
+ createCustomVanillaPage,
+ }) => {
+ console.info('START: ', conferenceWebhookDescription)
+ const mockWebhookServer = new MockWebhookServer()
+ const tunnelLink = await mockWebhookServer.listen(19898, true)
+ expect(tunnelLink).toMatch(/^http:\/\/.*\.zrok.swire.io/)
+ const expectCallActive = async (page: Page) => {
+ // Hangup call button locator
+ const hangupCall = page.locator('#hangupCall')
+ expect(hangupCall).not.toBe(null)
+
+ // Expect the Hangup button to be enabled (call active)
+ await expect(hangupCall).toBeEnabled()
+ }
+
+ const expectCallHangup = async (page: Page) => {
+ // Hangup call button locator
+ const hangupCall = page.locator('#hangupCall')
+ expect(hangupCall).not.toBe(null)
+
+ // Wait for call to be hung up
+ await expect(hangupCall).toBeDisabled()
+ }
+
+ const pageCallee = await createCustomVanillaPage({ name: '[callee]' })
+ await pageCallee.goto(SERVER_URL + '/v2vanilla.html')
+
+ const relayHost = process.env.RELAY_HOST ?? ''
+ await expectInjectRelayHost(pageCallee, relayHost)
+
+ const envRelayProject = process.env.RELAY_PROJECT ?? ''
+ expect(envRelayProject).not.toBe(null)
+
+ const resource = randomizeResourceName()
+ const jwtCallee = await createTestJWTToken({ resource: resource })
+ expect(jwtCallee).not.toBe(null)
+
+ await expectRelayConnected(pageCallee, envRelayProject, jwtCallee)
+
+ const inlineLaml = getDialConferenceLaml('v2rest0')
+
+ console.log('inline Laml: ', inlineLaml)
+ const initiatedCallbackRequest = mockWebhookServer.waitFor('initiated')
+ const ringingCallbackRequest = mockWebhookServer.waitFor('ringing')
+ const answeredCallbackRequest = mockWebhookServer.waitFor('answered')
+ const createResult = await createCallWithCompatibilityApi(
+ resource,
+ inlineLaml,
+ 'PCMU,PCMA',
+ tunnelLink,
+ ['initiated', 'ringing', 'answered', 'completed']
+ )
+ expect(createResult).toBe(201)
+ console.log('REST API returned 201 at ', new Date())
+
+ const initiatedStatus = await initiatedCallbackRequest
+
+ expect(initiatedStatus).toMatchObject({
+ CallSid: expect.any(String),
+ AccountSid: expect.any(String),
+ From: expect.stringContaining(`sip:${process.env.VOICE_DIAL_FROM_NUMBER}`),
+ To: `verto:${resource}@${process.env.VERTO_DOMAIN};codecs=PCMU,PCMA`,
+ CallStatus: 'initiated',
+ ApiVersion: '2010-04-01',
+ Timestamp: expect.any(String),
+ CallbackSource: 'call-progress-events',
+ })
+
+ const callStatusCallee = pageCallee.locator('#callStatus')
+ expect(callStatusCallee).not.toBe(null)
+ await expect(callStatusCallee).toContainText('-> active')
+
+ const ringingStatus = await ringingCallbackRequest
+ const answeredStatus = await answeredCallbackRequest
+
+ expect(ringingStatus).toMatchObject({
+ CallSid: expect.any(String),
+ AccountSid: expect.any(String),
+ From: expect.stringContaining(`sip:${process.env.VOICE_DIAL_FROM_NUMBER}`),
+ To: `verto:${resource}@${process.env.VERTO_DOMAIN};codecs=PCMU,PCMA`,
+ CallStatus: 'ringing',
+ ApiVersion: '2010-04-01',
+ Timestamp: expect.any(String),
+ CallbackSource: 'call-progress-events',
+ })
+
+ expect(answeredStatus).toMatchObject({
+ CallSid: expect.any(String),
+ AccountSid: expect.any(String),
+ From: expect.stringContaining(`sip:${process.env.VOICE_DIAL_FROM_NUMBER}`),
+ To: `verto:${resource}@${process.env.VERTO_DOMAIN};codecs=PCMU,PCMA`,
+ CallStatus: 'answered',
+ ApiVersion: '2010-04-01',
+ Timestamp: expect.any(String),
+ CallbackSource: 'call-progress-events',
+ })
+ console.log('The call is active at ', new Date())
+
+ // With 40 seconds we can catch a media timeout
+ const callDurationMs = 40000
+ await pageCallee.waitForTimeout(callDurationMs)
+
+ // Ensure the call hasn't been hang up, e.g. by a media timeout
+ await expectCallActive(pageCallee)
+
+ console.log('Time to check the audio energy at ', new Date())
+
+ // Empirical value; it depends on the call scenario
+ const minAudioEnergy = callDurationMs / 50000
+
+ // Check the audio energy level is above threshold
+ console.log('Expected min audio energy: ', minAudioEnergy)
+
+ // Expect at least 70 % packets at 50 pps
+ const minPackets = expectedMinPackets(50, callDurationMs, 0.3)
+
+ await expectv2HasReceivedAudio(pageCallee, minAudioEnergy, minPackets)
+
+ await expectCallActive(pageCallee)
+ console.log('Hanging up the call at ', new Date())
+ const completedCallbackRequest = mockWebhookServer.waitFor('completed')
+ await pageCallee.click('#hangupCall')
+ await expectCallHangup(pageCallee)
+
+
+ const completedStatus = await completedCallbackRequest
+ await mockWebhookServer.close()
+
+ expect(completedStatus).toMatchObject({
+ CallSid: expect.any(String),
+ AccountSid: expect.any(String),
+ From: expect.stringContaining(`sip:${process.env.VOICE_DIAL_FROM_NUMBER}`),
+ To: `verto:${resource}@${process.env.VERTO_DOMAIN};codecs=PCMU,PCMA`,
+ CallStatus: 'completed',
+ ApiVersion: '2010-04-01',
+ Timestamp: expect.any(String),
+ CallbackSource: 'call-progress-events',
+ })
+ console.info('END: ', conferenceWebhookDescription)
+ })
+})
diff --git a/internal/e2e-js/utils.ts b/internal/e2e-js/utils.ts
new file mode 100644
index 000000000..729e06456
--- /dev/null
+++ b/internal/e2e-js/utils.ts
@@ -0,0 +1,1910 @@
+import type {
+ DialParams,
+ FabricRoomSession,
+ SignalWire,
+ SignalWireClient,
+ SignalWireContract,
+ Video,
+} from '@signalwire/js'
+import type { MediaEventNames } from '@signalwire/webrtc'
+import { createServer } from 'vite'
+import path from 'path'
+import { expect } from './fixtures'
+import { Page } from '@playwright/test'
+import { v4 as uuid } from 'uuid'
+import { clearInterval } from 'timers'
+import express, { Express, Request, Response } from 'express'
+import { Server } from 'http'
+import { spawn, ChildProcessWithoutNullStreams } from 'child_process'
+import { EventEmitter } from 'events'
+declare global {
+ interface Window {
+ _SWJS: {
+ SignalWire: typeof SignalWire
+ }
+ _client?: SignalWireClient
+ }
+}
+
+// #region Utilities for Playwright test server & fixture
+
+type CreateTestServerOptions = {
+ target: 'video' | 'blank'
+}
+
+const TARGET_ROOT_PATH: Record<
+ CreateTestServerOptions['target'],
+ {
+ path: string
+ port: number
+ }
+> = {
+ blank: { path: './templates/blank', port: 1337 },
+ video: {
+ path: path.dirname(
+ require.resolve('@sw-internal/playground-js/src/video/index.html')
+ ),
+ port: 1336,
+ },
+}
+
+export const SERVER_URL = 'http://localhost:1337'
+export const BASIC_TOKEN = Buffer.from(
+ `${process.env.RELAY_PROJECT}:${process.env.RELAY_TOKEN}`
+).toString('base64')
+
+export const createTestServer = async (
+ options: CreateTestServerOptions = { target: 'blank' }
+) => {
+ const targetOptions = TARGET_ROOT_PATH[options.target]
+ const server = await createServer({
+ configFile: false,
+ root: targetOptions.path,
+ server: {
+ port: targetOptions.port,
+ },
+ logLevel: 'silent',
+ })
+
+ return {
+ start: async () => {
+ await server.listen()
+ },
+ close: async () => {
+ await server.close()
+ },
+ url: `http://localhost:${targetOptions.port}`,
+ }
+}
+
+export const enablePageLogs = (page: Page, customMsg: string = '[page]') => {
+ page.on('console', (log) => console.log(customMsg, log))
+}
+
+// #endregion
+
+// #region Utilities for Token Creation
+
+interface CreateTestVRTOptions {
+ room_name: string
+ user_name: string
+ room_display_name?: string
+ permissions?: string[]
+ join_from?: number | string
+ join_until?: number | string
+ remove_at?: number | string
+ remove_after_seconds_elapsed?: number
+ auto_create_room?: boolean
+ join_as?: 'member' | 'audience'
+ media_allowed?: 'audio-only' | 'video-only' | 'all'
+ join_audio_muted?: boolean
+ join_video_muted?: boolean
+ end_room_session_on_leave?: boolean
+}
+
+export const createTestVRTToken = async (body: CreateTestVRTOptions) => {
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/video/room_tokens`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ body: JSON.stringify(body),
+ }
+ )
+ const data = await response.json()
+ return data.token
+}
+
+interface CreateTestJWTOptions {
+ resource?: string
+ refresh_token?: string
+}
+
+export const createTestJWTToken = async (body: CreateTestJWTOptions) => {
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/relay/rest/jwt`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ body: JSON.stringify(body),
+ }
+ )
+ const data = await response.json()
+ return data.jwt_token
+}
+
+export const createTestSATToken = async () => {
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/fabric/subscribers/tokens`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ body: JSON.stringify({
+ reference: process.env.SAT_REFERENCE,
+ }),
+ }
+ )
+ const data = await response.json()
+ return data.token
+}
+
+interface GuestSATTokenRequest {
+ allowed_addresses: string[]
+}
+export const createGuestSATToken = async (bodyData: GuestSATTokenRequest) => {
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/fabric/guests/tokens`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ body: JSON.stringify(bodyData),
+ }
+ )
+ const data = await response.json()
+ return data.token
+}
+
+export const getResourceAddresses = async (resource_id: string) => {
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/fabric/resources/${resource_id}/addresses`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ }
+ )
+ const data = await response.json()
+ return data
+}
+
+interface CreateTestCRTOptions {
+ ttl: number
+ member_id: string
+ state: Record
+ channels: Record
+}
+
+export const createTestCRTToken = async (body: CreateTestCRTOptions) => {
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/chat/tokens`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ body: JSON.stringify(body),
+ }
+ )
+ const data = await response.json()
+ return data.token
+}
+
+// #endregion
+
+// #region Utilities for RoomSession
+
+export const createTestRoomSession = async (
+ page: Page,
+ options: {
+ vrt: CreateTestVRTOptions
+ /** set of events to automatically subscribe before room.join() */
+ initialEvents?: string[]
+ roomSessionOptions?: Record
+ shouldPassRootElement?: boolean
+ attachSagaMonitor?: boolean
+ }
+) => {
+ const vrt = await createTestVRTToken(options.vrt)
+ if (!vrt) {
+ console.error('Invalid VRT. Exiting..')
+ process.exit(4)
+ }
+ const roomSession: Video.RoomSession = await page.evaluate(
+ (options) => {
+ const _runningWorkers: any[] = []
+ // @ts-expect-error
+ window._runningWorkers = _runningWorkers
+ const addTask = (task: any) => {
+ if (!_runningWorkers.includes(task)) {
+ _runningWorkers.push(task)
+ }
+ }
+ const removeTask = (task: any) => {
+ const index = _runningWorkers.indexOf(task)
+ if (index > -1) {
+ _runningWorkers.splice(index, 1)
+ }
+ }
+
+ const sagaMonitor = {
+ effectResolved: (_effectId: number, result: any) => {
+ if (result?.toPromise) {
+ addTask(result)
+ // Remove the task when it completes or is cancelled
+ result.toPromise().finally(() => {
+ removeTask(result)
+ })
+ }
+ },
+ }
+
+ // @ts-expect-error
+ const Video = window._SWJS.Video
+ const roomSession = new Video.RoomSession({
+ host: options.RELAY_HOST,
+ token: options.API_TOKEN,
+ ...(options.shouldPassRootElement && {
+ rootElement: document.getElementById('rootElement'),
+ }),
+ logLevel: 'debug',
+ debug: {
+ logWsTraffic: true,
+ },
+ ...(options.attachSagaMonitor && { sagaMonitor }),
+ ...options.roomSessionOptions,
+ })
+
+ options.initialEvents?.forEach((event) => {
+ roomSession.once(event, () => {})
+ })
+
+ // @ts-expect-error
+ window.jwt_token = options.API_TOKEN
+
+ // @ts-expect-error
+ window._roomObj = roomSession
+
+ return Promise.resolve(roomSession)
+ },
+ {
+ RELAY_HOST:
+ options.vrt.join_as === 'audience'
+ ? process.env.RELAY_AUDIENCE_HOST
+ : process.env.RELAY_HOST,
+ API_TOKEN: vrt,
+ initialEvents: options.initialEvents,
+ CI: process.env.CI,
+ roomSessionOptions: options.roomSessionOptions,
+ shouldPassRootElement: options.shouldPassRootElement ?? true,
+ attachSagaMonitor: options.attachSagaMonitor ?? false,
+ }
+ )
+
+ return roomSession
+}
+
+export const createTestRoomSessionWithJWT = async (
+ page: Page,
+ options: {
+ vrt: CreateTestVRTOptions
+ /** set of events to automatically subscribe before room.join() */
+ initialEvents?: string[]
+ roomSessionOptions?: Record
+ },
+ jwt: string
+) => {
+ if (!jwt) {
+ console.error('Invalid JWT. Exiting..')
+ process.exit(4)
+ }
+ return page.evaluate(
+ (options) => {
+ // @ts-expect-error
+ const Video = window._SWJS.Video
+ const roomSession = new Video.RoomSession({
+ host: options.RELAY_HOST,
+ token: options.API_TOKEN,
+ rootElement: document.getElementById('rootElement'),
+ audio: true,
+ video: true,
+ logLevel: options.CI ? 'warn' : 'debug',
+ debug: {
+ logWsTraffic: !options.CI,
+ },
+ ...options.roomSessionOptions,
+ })
+
+ options.initialEvents?.forEach((event) => {
+ roomSession.once(event, () => {})
+ })
+
+ // @ts-expect-error
+ window.jwt_token = options.API_TOKEN
+
+ // @ts-expect-error
+ window._roomObj = roomSession
+
+ return Promise.resolve(roomSession)
+ },
+ {
+ RELAY_HOST:
+ options.vrt.join_as === 'audience'
+ ? process.env.RELAY_AUDIENCE_HOST
+ : process.env.RELAY_HOST,
+ API_TOKEN: jwt,
+ initialEvents: options.initialEvents,
+ CI: process.env.CI,
+ roomSessionOptions: options.roomSessionOptions,
+ }
+ )
+}
+
+const getRoomByName = async (roomName: string) => {
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/video/rooms/${roomName}`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ }
+ )
+ if (response.status === 200) {
+ return await response.json()
+ }
+ return undefined
+}
+
+export interface CreateOrUpdateRoomOptions {
+ name: string
+ display_name?: string
+ max_members?: number
+ quality?: '720p' | '1080p'
+ join_from?: string
+ join_until?: string
+ remove_at?: string
+ remove_after_seconds_elapsed?: number
+ layout?: string
+ record_on_start?: boolean
+ enable_room_previews?: boolean
+}
+
+export const createOrUpdateRoom = async (body: CreateOrUpdateRoomOptions) => {
+ const room = await getRoomByName(body.name)
+ if (!room) {
+ return createRoom(body)
+ }
+
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/video/rooms/${room.id}`,
+ {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ body: JSON.stringify(body),
+ }
+ )
+ return response.json()
+}
+
+export const createRoom = async (body: CreateOrUpdateRoomOptions) => {
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/video/rooms`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ body: JSON.stringify(body),
+ }
+ )
+ return response.json()
+}
+
+export const createStreamForRoom = async (name: string, url: string) => {
+ const room = await getRoomByName(name)
+ if (!room) {
+ throw new Error('Room not found')
+ }
+
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/video/rooms/${room.id}/streams`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ body: JSON.stringify({ url }),
+ }
+ )
+ const data = await response.json()
+ if (response.status !== 201) {
+ throw data
+ }
+
+ return data
+}
+
+export const deleteRoom = async (id: string) => {
+ return await fetch(`https://${process.env.API_HOST}/api/video/rooms/${id}`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ })
+}
+
+export const leaveRoom = async (page: Page) => {
+ return page.evaluate(async () => {
+ const roomObj: Video.RoomSession | FabricRoomSession =
+ // @ts-expect-error
+ window._roomObj
+ console.log('Fixture roomObj', roomObj)
+ if (roomObj && roomObj?.roomSessionId) {
+ console.log('Fixture has room', roomObj.roomSessionId)
+ await roomObj.leave()
+ }
+
+ return {
+ videos: Array.from(document.querySelectorAll('video')).length,
+ rootEl: document.getElementById('rootElement')?.childElementCount ?? 0,
+ }
+ })
+}
+
+// #endregion
+
+// #region Utilities for Call Fabric client
+
+interface CreateCFClientParams {
+ attachSagaMonitor?: boolean
+}
+
+export const createCFClient = async (
+ page: Page,
+ params?: CreateCFClientParams
+) => {
+ const sat = await createTestSATToken()
+ return createCFClientWithToken(page, sat, params)
+}
+
+export const createGuestCFClient = async (
+ page: Page,
+ bodyData: GuestSATTokenRequest,
+ params?: CreateCFClientParams
+) => {
+ const sat = await createGuestSATToken(bodyData)
+ return createCFClientWithToken(page, sat, params)
+}
+
+const createCFClientWithToken = async (
+ page: Page,
+ sat: string | null,
+ params?: CreateCFClientParams
+) => {
+ if (!sat) {
+ console.error('Invalid SAT. Exiting..')
+ process.exit(4)
+ }
+
+ const { attachSagaMonitor = false } = params || {}
+
+ const swClient = await page.evaluate(
+ async (options) => {
+ const _runningWorkers: any[] = []
+ // @ts-expect-error
+ window._runningWorkers = _runningWorkers
+ const addTask = (task: any) => {
+ if (!_runningWorkers.includes(task)) {
+ _runningWorkers.push(task)
+ }
+ }
+ const removeTask = (task: any) => {
+ const index = _runningWorkers.indexOf(task)
+ if (index > -1) {
+ _runningWorkers.splice(index, 1)
+ }
+ }
+
+ const sagaMonitor = {
+ effectResolved: (_effectId: number, result: any) => {
+ if (result?.toPromise) {
+ addTask(result)
+ // Remove the task when it completes or is cancelled
+ result.toPromise().finally(() => {
+ removeTask(result)
+ })
+ }
+ },
+ }
+
+ const SignalWire = window._SWJS.SignalWire
+ const client: SignalWireContract = await SignalWire({
+ host: options.RELAY_HOST,
+ token: options.API_TOKEN,
+ debug: { logWsTraffic: true },
+ ...(options.attachSagaMonitor && { sagaMonitor }),
+ })
+
+ window._client = client
+ return client
+ },
+ {
+ RELAY_HOST: process.env.RELAY_HOST,
+ API_TOKEN: sat,
+ attachSagaMonitor,
+ }
+ )
+
+ return swClient
+}
+
+interface DialAddressParams {
+ address: string
+ dialOptions?: Partial
+ reattach?: boolean
+ shouldWaitForJoin?: boolean
+ shouldStartCall?: boolean
+ shouldPassRootElement?: boolean
+}
+export const dialAddress = (page: Page, params: DialAddressParams) => {
+ const {
+ address,
+ dialOptions = {},
+ reattach = false,
+ shouldPassRootElement = true,
+ shouldStartCall = true,
+ shouldWaitForJoin = true,
+ } = params
+ return page.evaluate(
+ async ({
+ address,
+ dialOptions,
+ reattach,
+ shouldPassRootElement,
+ shouldStartCall,
+ shouldWaitForJoin,
+ }) => {
+ return new Promise(async (resolve, _reject) => {
+ const client = window._client
+
+ if (!client) {
+ console.error('Client not defined!')
+ return
+ }
+
+ const dialer = reattach ? client.reattach : client.dial
+
+ const call = await dialer({
+ to: address,
+ ...(shouldPassRootElement && {
+ rootElement: document.getElementById('rootElement')!,
+ }),
+ ...JSON.parse(dialOptions),
+ })
+
+ if (shouldWaitForJoin) {
+ call.on('room.joined', resolve)
+ }
+
+ // @ts-expect-error
+ window._roomObj = call
+
+ if (shouldStartCall) {
+ await call.start()
+ }
+
+ if (!shouldWaitForJoin) {
+ resolve(call)
+ }
+ })
+ },
+ {
+ address,
+ dialOptions: JSON.stringify(dialOptions),
+ reattach,
+ shouldPassRootElement,
+ shouldStartCall,
+ shouldWaitForJoin,
+ }
+ )
+}
+
+export const reloadAndReattachAddress = async (
+ page: Page,
+ params: Omit
+) => {
+ await page.reload({ waitUntil: 'domcontentloaded' })
+ await createCFClient(page)
+
+ return dialAddress(page, { ...params, reattach: true })
+}
+
+export const disconnectClient = (page: Page) => {
+ return page.evaluate(async () => {
+ // @ts-expect-error
+ const client: SignalWireContract = window._client
+
+ if (client) {
+ await client.disconnect()
+ console.log('Client disconnected')
+ }
+ })
+}
+
+// #endregion
+
+// #region Utilities for the MCU
+
+export const expectMCUVisible = async (page: Page) => {
+ await page.waitForSelector('div[id^="sw-sdk-"] > video')
+}
+
+export const expectMCUNotVisible = async (page: Page) => {
+ const mcuVideo = await page.$('div[id^="sw-sdk-"] > video')
+ expect(mcuVideo).toBeNull()
+}
+
+export const expectMCUVisibleForAudience = async (page: Page) => {
+ await page.waitForSelector('#rootElement video')
+}
+
+// #endregion
+
+// #region Utilities for RTP Media stats and SDP
+
+interface RTPInboundMediaStats {
+ packetsReceived: number
+ packetsLost: number
+ packetsDiscarded?: number
+}
+
+interface RTPOutboundMediaStats {
+ active: boolean
+ packetsSent: number
+ targetBitrate: number
+ totalPacketSendDelay: number
+}
+
+interface GetStatsResult {
+ inboundRTP: {
+ audio: RTPInboundMediaStats
+ video: RTPInboundMediaStats
+ }
+ outboundRTP: {
+ audio: RTPOutboundMediaStats
+ video: RTPOutboundMediaStats
+ }
+}
+
+export const getStats = async (page: Page): Promise => {
+ return await page.evaluate(async () => {
+ // @ts-expect-error
+ const roomObj: Video.RoomSession = window._roomObj
+ // @ts-expect-error
+ const rtcPeer = roomObj.peer
+
+ // Get the currently active inbound and outbound tracks.
+ const inboundAudioTrackId = rtcPeer._getReceiverByKind('audio')?.track.id
+ const inboundVideoTrackId = rtcPeer._getReceiverByKind('video')?.track.id
+ const outboundAudioTrackId = rtcPeer._getSenderByKind('audio')?.track.id
+ const outboundVideoTrackId = rtcPeer._getSenderByKind('video')?.track.id
+
+ // Default return value
+ const result: GetStatsResult = {
+ inboundRTP: {
+ audio: {
+ packetsReceived: 0,
+ packetsLost: 0,
+ packetsDiscarded: 0,
+ },
+ video: {
+ packetsReceived: 0,
+ packetsLost: 0,
+ packetsDiscarded: 0,
+ },
+ },
+ outboundRTP: {
+ audio: {
+ active: false,
+ packetsSent: 0,
+ targetBitrate: 0,
+ totalPacketSendDelay: 0,
+ },
+ video: {
+ active: false,
+ packetsSent: 0,
+ targetBitrate: 0,
+ totalPacketSendDelay: 0,
+ },
+ },
+ }
+
+ const inboundRTPFilters = {
+ audio: ['packetsReceived', 'packetsLost', 'packetsDiscarded'] as const,
+ video: ['packetsReceived', 'packetsLost', 'packetsDiscarded'] as const,
+ }
+
+ const outboundRTPFilters = {
+ audio: [
+ 'active',
+ 'packetsSent',
+ 'targetBitrate',
+ 'totalPacketSendDelay',
+ ] as const,
+ video: [
+ 'active',
+ 'packetsSent',
+ 'targetBitrate',
+ 'totalPacketSendDelay',
+ ] as const,
+ }
+
+ const handleInboundRTP = (report: any) => {
+ const media = report.mediaType as 'audio' | 'video'
+ if (!media) return
+
+ // Check if trackIdentifier matches the currently active inbound track
+ const expectedTrackId =
+ media === 'audio' ? inboundAudioTrackId : inboundVideoTrackId
+
+ if (
+ report.trackIdentifier &&
+ report.trackIdentifier !== expectedTrackId
+ ) {
+ console.log(
+ `inbound-rtp trackIdentifier "${report.trackIdentifier}" and trackId "${expectedTrackId}" are different for "${media}"`
+ )
+ return
+ }
+
+ inboundRTPFilters[media].forEach((key) => {
+ result.inboundRTP[media][key] = report[key]
+ })
+ }
+
+ const handleOutboundRTP = (report: any) => {
+ const media = report.mediaType as 'audio' | 'video'
+ if (!media) return
+
+ // Check if trackIdentifier matches the currently active outbound track
+ const expectedTrackId =
+ media === 'audio' ? outboundAudioTrackId : outboundVideoTrackId
+ if (
+ report.trackIdentifier &&
+ report.trackIdentifier !== expectedTrackId
+ ) {
+ console.log(
+ `outbound-rtp trackIdentifier "${report.trackIdentifier}" and trackId "${expectedTrackId}" are different for "${media}"`
+ )
+ return
+ }
+
+ outboundRTPFilters[media].forEach((key) => {
+ ;(result.outboundRTP[media] as any)[key] = report[key]
+ })
+ }
+
+ // Iterate over all RTCStats entries
+ const pc: RTCPeerConnection = rtcPeer.instance
+ const stats = await pc.getStats()
+ stats.forEach((report) => {
+ switch (report.type) {
+ case 'inbound-rtp':
+ handleInboundRTP(report)
+ break
+ case 'outbound-rtp':
+ handleOutboundRTP(report)
+ break
+ }
+ })
+
+ return result
+ })
+}
+
+export const expectPageReceiveMedia = async (page: Page, delay = 5_000) => {
+ const first = await getStats(page)
+ await page.waitForTimeout(delay)
+ const last = await getStats(page)
+
+ const seconds = delay / 1000
+ const minAudioPacketsExpected = 40 * seconds
+ const minVideoPacketsExpected = 25 * seconds
+
+ expect(last.inboundRTP.video?.packetsReceived).toBeGreaterThan(
+ (first.inboundRTP.video?.packetsReceived || 0) + minVideoPacketsExpected
+ )
+ expect(last.inboundRTP.audio?.packetsReceived).toBeGreaterThan(
+ (first.inboundRTP.audio?.packetsReceived || 0) + minAudioPacketsExpected
+ )
+}
+
+export const getAudioStats = async (page: Page) => {
+ const audioStats = await page.evaluate(async () => {
+ // @ts-expect-error
+ const roomObj: Video.RoomSession = window._roomObj
+
+ // @ts-expect-error
+ const audioTrackId = roomObj.peer._getReceiverByKind('audio').track.id
+
+ // @ts-expect-error
+ const stats = await roomObj.peer.instance.getStats(null)
+ const filter = {
+ 'inbound-rtp': [
+ 'audioLevel',
+ 'totalAudioEnergy',
+ 'totalSamplesDuration',
+ 'totalSamplesReceived',
+ 'packetsDiscarded',
+ 'lastPacketReceivedTimestamp',
+ 'bytesReceived',
+ 'packetsReceived',
+ 'packetsLost',
+ 'packetsRetransmitted',
+ ],
+ }
+ const result: any = {}
+ Object.keys(filter).forEach((entry) => {
+ result[entry] = {}
+ })
+
+ stats.forEach((report: any) => {
+ for (const [key, value] of Object.entries(filter)) {
+ if (
+ report.type == key &&
+ report['mediaType'] === 'audio' &&
+ report['trackIdentifier'] === audioTrackId
+ ) {
+ value.forEach((entry) => {
+ if (report[entry]) {
+ result[key][entry] = report[entry]
+ }
+ })
+ }
+ }
+ }, {})
+
+ return result
+ })
+ console.log('audioStats', audioStats)
+
+ return audioStats
+}
+
+export const expectTotalAudioEnergyToBeGreaterThan = async (
+ page: Page,
+ value: number
+) => {
+ const audioStats = await getAudioStats(page)
+
+ const totalAudioEnergy = audioStats['inbound-rtp']['totalAudioEnergy']
+ if (totalAudioEnergy) {
+ expect(totalAudioEnergy).toBeGreaterThan(value)
+ } else {
+ console.log('Warning - totalAudioEnergy was not present in the audioStats.')
+ }
+}
+
+export const expectPageReceiveAudio = async (page: Page) => {
+ await page.waitForTimeout(10000)
+ await expectTotalAudioEnergyToBeGreaterThan(page, 0.5)
+}
+
+export const expectSDPDirection = async (
+ page: Page,
+ direction: string,
+ value: boolean
+) => {
+ const peerSDP = await page.evaluate(async () => {
+ // @ts-expect-error
+ const roomObj: Video.RoomSession = window._roomObj
+ // @ts-expect-error
+ return roomObj.peer.localSdp
+ })
+
+ expect(peerSDP.split('m=')[1].includes(direction)).toBe(value)
+ expect(peerSDP.split('m=')[2].includes(direction)).toBe(value)
+}
+
+export const getRemoteMediaIP = async (page: Page) => {
+ const remoteIP: string = await page.evaluate(() => {
+ // @ts-expect-error
+ const peer: Video.RoomSessionPeer = window._roomObj.peer
+ const lines = peer.instance?.remoteDescription?.sdp?.split('\r\n')
+ const ipLine = lines?.find((line: any) => line.includes('c=IN IP4'))
+ return ipLine?.split(' ')[2]
+ })
+ return remoteIP
+}
+
+interface WaitForStabilizedStatsParams {
+ propertyPath: string
+ maxAttempts?: number
+ stabilityCount?: number
+ intervalMs?: number
+}
+/**
+ * Waits for a given RTP stats property to stabilize.
+ * A stat is considered stable if the last `stabilityCount` readings are constant.
+ * Returns the stabled value.
+ */
+export const waitForStabilizedStats = async (
+ page: Page,
+ params: WaitForStabilizedStatsParams
+) => {
+ const {
+ propertyPath,
+ maxAttempts = 50,
+ stabilityCount = 10,
+ intervalMs = 1000,
+ } = params
+
+ const recentValues: number[] = []
+
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ const stats = await getStats(page)
+ const currentValue = getValueFromPath(stats, propertyPath) as number
+
+ recentValues.push(currentValue)
+
+ if (recentValues.length >= stabilityCount) {
+ const lastNValues = recentValues.slice(-stabilityCount)
+ const allEqual = lastNValues.every((val) => val === lastNValues[0])
+ if (allEqual) {
+ // The stat is stable now
+ return lastNValues[0]
+ }
+ }
+
+ if (attempt < maxAttempts - 1) {
+ await new Promise((resolve) => setTimeout(resolve, intervalMs))
+ }
+ }
+
+ // If we get here, the value never stabilized.
+ throw new Error(
+ `The value at "${propertyPath}" did not stabilize after ${maxAttempts} attempts.`
+ )
+}
+
+/**
+ * Retrieves a value from an object at a given path.
+ *
+ * @example
+ * const obj = { a: { b: { c: 42 } } };
+ * const result = getValueFromPath(obj, "a.b.c"); // 42
+ */
+export const getValueFromPath = (obj: T, path: string) => {
+ let current: unknown = obj
+ for (const part of path.split('.')) {
+ if (current == null || typeof current !== 'object') {
+ return undefined
+ }
+ current = (current as Record)[part]
+ }
+ return current
+}
+
+interface ExpectStatWithPollingParams {
+ propertyPath: string
+ matcher:
+ | 'toBe'
+ | 'toBeGreaterThan'
+ | 'toBeLessThan'
+ | 'toBeGreaterThanOrEqual'
+ | 'toBeLessThanOrEqual'
+ expected: number
+ message?: string
+ timeout?: number
+}
+
+export async function expectStatWithPolling(
+ page: Page,
+ params: ExpectStatWithPollingParams
+) {
+ const { propertyPath, matcher, expected, message, timeout = 10000 } = params
+
+ const defaultMessage = `Expected \`${propertyPath}\` ${matcher} ${expected}`
+ await expect
+ .poll(
+ async () => {
+ const stats = await getStats(page)
+ const value = getValueFromPath(stats, propertyPath) as number
+ return value
+ },
+ { message: message ?? defaultMessage, timeout }
+ )
+ [matcher](expected)
+}
+
+// #endregion
+
+// #region Utilities for v2 WebRTC testing
+
+export type StatusEvents = 'initiated' | 'ringing' | 'answered' | 'completed'
+
+export const createCallWithCompatibilityApi = async (
+ resource: string,
+ inlineLaml: string,
+ codecs?: string | undefined,
+ statusCallbackUrl?: string | undefined,
+ statusEvents?: StatusEvents[] | undefined,
+ statusCallBackMethod: 'GET' | 'POST' = 'POST'
+) => {
+ const data = new URLSearchParams()
+
+ if (inlineLaml !== null && inlineLaml !== '') {
+ data.append('Laml', inlineLaml)
+ }
+ data.append('From', `${process.env.VOICE_DIAL_FROM_NUMBER}`)
+
+ const vertoDomain = process.env.VERTO_DOMAIN
+ expect(vertoDomain).toBeDefined()
+
+ let to = `verto:${resource}@${vertoDomain}`
+ if (codecs) {
+ to += `;codecs=${codecs}`
+ }
+ data.append('To', to)
+
+ data.append('Record', 'true')
+ data.append('RecordingChannels', 'dual')
+ data.append('Trim', 'do-not-trim')
+
+ if (statusCallbackUrl && statusEvents) {
+ data.append('StatusCallback', statusCallbackUrl)
+ for (const event of statusEvents) {
+ data.append('StatusCallbackEvent', event)
+ }
+ data.append('StatusCallbackMethod', statusCallBackMethod)
+ }
+
+ console.log(
+ 'REST API URL: ',
+ `https://${process.env.API_HOST}/api/laml/2010-04-01/Accounts/${process.env.RELAY_PROJECT}/Calls`
+ )
+ console.log('REST API payload: ', data)
+
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/laml/2010-04-01/Accounts/${process.env.RELAY_PROJECT}/Calls`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ body: data,
+ }
+ )
+
+ if (Number.isInteger(Number(response.status)) && response.status !== null) {
+ if (response.status !== 201) {
+ const responseBody = await response.json()
+ const formattedBody = JSON.stringify(responseBody, null, 2)
+
+ console.log(
+ 'ERROR - response from REST API: ',
+ response.status,
+ ' status text = ',
+ response.statusText,
+ ' body = ',
+ formattedBody
+ )
+ }
+ return response.status
+ }
+ return undefined
+}
+
+export const getDialConferenceLaml = (conferenceNameBase: string) => {
+ const conferenceName = randomizeRoomName(conferenceNameBase)
+ const conferenceRegion = process.env.LAML_CONFERENCE_REGION ?? ''
+ const inlineLaml = `
+
+
+
+ ${conferenceName}
+
+
+ `
+
+ return inlineLaml
+}
+
+export const expectv2HasReceivedAudio = async (
+ page: Page,
+ minTotalAudioEnergy: number,
+ minPacketsReceived: number
+) => {
+ const audioStats = await page.evaluate(async () => {
+ // @ts-expect-error
+ const currentCall = window.__currentCall
+ const audioReceiver = currentCall.peer.instance
+ .getReceivers()
+ .find((r: any) => r.track.kind === 'audio')
+
+ const audioTrackId = audioReceiver.track.id
+
+ const stats = await currentCall.peer.instance.getStats(null)
+ const filter = {
+ 'inbound-rtp': [
+ 'audioLevel',
+ 'totalAudioEnergy',
+ 'totalSamplesDuration',
+ 'totalSamplesReceived',
+ 'packetsDiscarded',
+ 'lastPacketReceivedTimestamp',
+ 'bytesReceived',
+ 'packetsReceived',
+ 'packetsLost',
+ 'packetsRetransmitted',
+ ],
+ }
+ const result: any = {}
+ Object.keys(filter).forEach((entry) => {
+ result[entry] = {}
+ })
+
+ stats.forEach((report: any) => {
+ for (const [key, value] of Object.entries(filter)) {
+ if (
+ report.type == key &&
+ report['mediaType'] === 'audio' &&
+ report['trackIdentifier'] === audioTrackId
+ ) {
+ value.forEach((entry) => {
+ if (report[entry]) {
+ result[key][entry] = report[entry]
+ }
+ })
+ }
+ }
+ }, {})
+
+ return result
+ })
+ console.log('audioStats: ', audioStats)
+
+ /* This is a workaround what we think is a bug in Playwright/Chromium
+ * There are cases where totalAudioEnergy is not present in the report
+ * even though we see audio and it's not silence.
+ * In that case we rely on the number of packetsReceived.
+ * If there is genuine silence, then totalAudioEnergy must be present,
+ * albeit being a small number.
+ */
+ console.log(
+ `Evaluating audio energy (min energy: ${minTotalAudioEnergy}, min packets: ${minPacketsReceived})`
+ )
+
+ const inboundRtp = audioStats['inbound-rtp']
+ const totalAudioEnergy = inboundRtp?.totalAudioEnergy
+ const packetsReceived = inboundRtp?.packetsReceived
+ const totalSamplesReceived = inboundRtp?.totalSamplesReceived
+
+ // Validate that at least one metric is available
+ if (!inboundRtp || (!totalAudioEnergy && !packetsReceived && !totalSamplesReceived)) {
+ throw new Error(
+ 'No audio metrics available in RTCStats report. ' +
+ 'This indicates a problem with the WebRTC connection or RTCStats API. ' +
+ `Stats: ${JSON.stringify(audioStats)}`
+ )
+ }
+
+ // Prefer totalAudioEnergy if available
+ if (totalAudioEnergy !== undefined && totalAudioEnergy !== null) {
+ expect(totalAudioEnergy).toBeGreaterThan(minTotalAudioEnergy)
+ console.log(`✓ Audio energy validated: ${totalAudioEnergy} > ${minTotalAudioEnergy}`)
+ } else {
+ console.log(
+ '⚠️ totalAudioEnergy not available (common with G.711/PCMU/PCMA codecs), ' +
+ 'using packetsReceived as fallback validation'
+ )
+
+ if (packetsReceived !== undefined && packetsReceived !== null) {
+ expect(packetsReceived).toBeGreaterThan(minPacketsReceived)
+ console.log(`✓ Packets validated: ${packetsReceived} > ${minPacketsReceived}`)
+ } else {
+ throw new Error(
+ 'Neither totalAudioEnergy nor packetsReceived available. ' +
+ 'Cannot validate audio reception. ' +
+ `Available metrics: ${JSON.stringify(inboundRtp)}`
+ )
+ }
+ }
+}
+
+export const expectv2HasReceivedSilence = async (
+ page: Page,
+ maxTotalAudioEnergy: number,
+ minPacketsReceived: number
+) => {
+ const audioStats = await page.evaluate(async () => {
+ // @ts-expect-error
+ const currentCall = window.__currentCall
+ const audioReceiver = currentCall.peer.instance
+ .getReceivers()
+ .find((r: any) => r.track.kind === 'audio')
+
+ const audioTrackId = audioReceiver.track.id
+
+ const stats = await currentCall.peer.instance.getStats(null)
+ const filter = {
+ 'inbound-rtp': [
+ 'audioLevel',
+ 'totalAudioEnergy',
+ 'totalSamplesDuration',
+ 'totalSamplesReceived',
+ 'packetsDiscarded',
+ 'lastPacketReceivedTimestamp',
+ 'bytesReceived',
+ 'packetsReceived',
+ 'packetsLost',
+ 'packetsRetransmitted',
+ ],
+ }
+ const result: any = {}
+ Object.keys(filter).forEach((entry) => {
+ result[entry] = {}
+ })
+
+ stats.forEach((report: any) => {
+ for (const [key, value] of Object.entries(filter)) {
+ if (
+ report.type == key &&
+ report['mediaType'] === 'audio' &&
+ report['trackIdentifier'] === audioTrackId
+ ) {
+ value.forEach((entry) => {
+ if (report[entry]) {
+ result[key][entry] = report[entry]
+ }
+ })
+ }
+ }
+ }, {})
+
+ return result
+ })
+ console.log('audioStats: ', audioStats)
+
+ /* This is a workaround what we think is a bug in Playwright/Chromium
+ * There are cases where totalAudioEnergy is not present in the report
+ * even though we see audio and it's not silence.
+ * In that case we rely on the number of packetsReceived.
+ * If there is genuine silence, then totalAudioEnergy must be present,
+ * albeit being a small number.
+ */
+ console.log(
+ `Evaluating audio energy (max energy: ${maxTotalAudioEnergy}, min packets: ${minPacketsReceived})`
+ )
+ const totalAudioEnergy = audioStats['inbound-rtp']['totalAudioEnergy']
+ const packetsReceived = audioStats['inbound-rtp']['packetsReceived']
+ if (totalAudioEnergy) {
+ expect(totalAudioEnergy).toBeLessThan(maxTotalAudioEnergy)
+ } else {
+ console.log('Warning: totalAudioEnergy was missing from the report!')
+ if (packetsReceived) {
+ // We still want the right amount of packets
+ expect(packetsReceived).toBeGreaterThan(minPacketsReceived)
+ } else {
+ console.log('Warning: packetsReceived was missing from the report!')
+ /* We don't make this test fail, because the absence of packetsReceived
+ * is a symptom of an issue with RTCStats, rather than an indication
+ * of lack of RTP flow.
+ */
+ }
+ }
+}
+
+export const expectedMinPackets = (
+ packetRate: number,
+ callDurationMs: number,
+ maxMissingPacketsTolerance: number // 0 to 1.0
+) => {
+ if (maxMissingPacketsTolerance < 0) {
+ maxMissingPacketsTolerance = 0
+ }
+ if (maxMissingPacketsTolerance > 1) {
+ maxMissingPacketsTolerance = 1
+ }
+
+ const minPackets =
+ (callDurationMs * (1 - maxMissingPacketsTolerance) * packetRate) / 1000
+
+ return minPackets
+}
+
+export const randomizeResourceName = (prefix: string = 'e2e') => {
+ return `res-${prefix}${uuid()}`
+}
+
+export const expectInjectRelayHost = async (page: Page, host: string) => {
+ await page.evaluate(
+ async (params) => {
+ // @ts-expect-error
+ window.__host = params.host
+ },
+ {
+ host: host,
+ }
+ )
+}
+
+export const expectInjectIceTransportPolicy = async (
+ page: Page,
+ iceTransportPolicy: string
+) => {
+ await page.evaluate(
+ async (params) => {
+ // @ts-expect-error
+ window.__iceTransportPolicy = params.iceTransportPolicy
+ },
+ {
+ iceTransportPolicy,
+ }
+ )
+}
+
+export const expectRelayConnected = async (
+ page: Page,
+ envRelayProject: string,
+ jwt: string
+) => {
+ // Project locator
+ const project = page.locator('#project')
+ expect(project).not.toBe(null)
+
+ // Token locator
+ const token = page.locator('#token')
+ expect(token).not.toBe(null)
+
+ // Populate project and token using locators
+ await project.fill(envRelayProject)
+ await token.fill(jwt)
+
+ // Click the connect button, which calls the connect function in the browser
+ await page.click('#btnConnect')
+
+ // Start call button locator
+ const startCall = page.locator('#startCall')
+ expect(startCall).not.toBe(null)
+
+ // Wait for call button to be enabled when signalwire.ready occurs
+ await expect(startCall).toBeEnabled()
+}
+
+export class MockWebhookServer extends EventEmitter {
+ private app: Express
+ private server: Server
+ private zrokProcess: ChildProcessWithoutNullStreams
+
+ constructor() {
+ super()
+ this.app = express()
+ this.app.use(express.urlencoded({ extended: true }))
+ const self = this
+ this.app.all('/', (req: Request, res: Response) => {
+ self.emit('request', req)
+ console.log('request body: ', req.body)
+ res.status(204).end()
+ })
+ }
+
+ listen(port: number = 18989, startTunnel: boolean = false) {
+ return new Promise((resolve) => {
+ this.server = this.app.listen(port, (err?: Error) => {
+ if (err) {
+ console.error('Error Starting MockWebhookServer: ', err)
+ process.exit(5)
+ }
+ if (startTunnel == false) {
+ resolve('Started without tunnel')
+ return
+ }
+ })
+
+ if (startTunnel) {
+ const MAX_RETRIES = 3
+ const tunnel = (attempt = 0) => {
+ try {
+ this.zrokProcess = spawn('zrok', [
+ 'share',
+ 'public',
+ '--backend-mode',
+ 'proxy',
+ '--headless',
+ '--insecure',
+ `${port}`,
+ ])
+ this.zrokProcess.on('error', (err) => {
+ console.error('zrok process error event: ', err)
+ })
+ this.zrokProcess.stdout.on('data', (data) => {
+ console.log(`zrok processs stdout: ${data}`)
+ })
+ this.zrokProcess.stderr.on('data', (data) => {
+ const dataStr = data.toString('utf-8')
+ // zrok is writing only to std error for every logs
+ try {
+ const logObj = JSON.parse(dataStr)
+ if (logObj.level == 'info') {
+ console.log(`zrok process stdout: ${data}`)
+ if (
+ logObj.msg &&
+ logObj.msg.startsWith(
+ 'access your zrok share at the following endpoints:'
+ )
+ ) {
+ const tunnelUrl = logObj.msg.split('\n')[1].trim()
+ resolve(tunnelUrl as string)
+ }
+ } else {
+ console.error(`zrok process stderr: ${data}`)
+ }
+ } catch (e) {
+ if (dataStr.startsWith('[ERROR]: unable to create share')) {
+ console.error('Error Starting Zrok Share: ', dataStr)
+ if (attempt < MAX_RETRIES) {
+ console.log(`Retrying (attempt: ${attempt + 1} `)
+ tunnel(attempt + 1)
+ } else {
+ process.exit(5)
+ }
+ }
+ }
+ })
+
+ this.zrokProcess.on('close', (code) => {
+ console.log(`zrok process exited with code ${code}`)
+ })
+ } catch (err) {
+ console.error('Error Starting Zrok Share: ', err)
+ if (attempt < MAX_RETRIES) {
+ console.log(`Retrying (attempt: ${attempt + 1} `)
+ tunnel(attempt + 1)
+ } else {
+ process.exit(5)
+ }
+ }
+ }
+
+ tunnel()
+ }
+ })
+ }
+
+ waitFor(status: StatusEvents) {
+ return new Promise((resolve) => {
+ this.on('request', (req: Request) => {
+ if (req.body.CallStatus === status) {
+ resolve(req.body)
+ }
+ })
+ })
+ }
+
+ close() {
+ this.server.close()
+ this.zrokProcess.kill('SIGKILL')
+ }
+}
+
+// #endregion
+
+// #region Utilities for Resources CRUD operations
+
+export interface Resource {
+ id: string
+ project_id: string
+ type: string
+ display_name: string
+ created_at: string
+ cxml_script?: CXMLApplication
+ cxml_webhook?: CXMLApplication
+}
+
+export interface CXMLApplication {
+ id: string
+ // and other things
+}
+
+export const createVideoRoomResource = async (name?: string) => {
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/fabric/resources/conference_rooms`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ body: JSON.stringify({
+ name: name ?? `e2e_${uuid()}`,
+ }),
+ }
+ )
+ const data = (await response.json()) as Resource
+ console.log('>> Resource VideoRoom created:', data.id, name)
+ return data
+}
+
+export interface CreateSWMLAppResourceParams {
+ name?: string
+ contents: Record
+}
+export const createSWMLAppResource = async ({
+ name,
+ contents,
+}: CreateSWMLAppResourceParams) => {
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/fabric/resources/swml_scripts`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ body: JSON.stringify({
+ name: name ?? `e2e_${uuid()}`,
+ contents: JSON.stringify(contents),
+ }),
+ }
+ )
+ const data = (await response.json()) as Resource
+ console.log('>> Resource SWML App created:', data.id)
+ return data
+}
+
+export interface CreatecXMLScriptParams {
+ name?: string
+ contents: Record
+}
+export const createcXMLScriptResource = async ({
+ name,
+ contents,
+}: CreatecXMLScriptParams) => {
+ const requestBody = {
+ name: name ?? `e2e_${uuid()}`,
+ contents: contents.call_handler_script,
+ }
+ console.log('-----> request body (script):', requestBody)
+
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/fabric/resources/cxml_scripts`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ body: JSON.stringify(requestBody),
+ }
+ )
+ const data = (await response.json()) as Resource
+ console.log('----> data:', data)
+ console.log('>> Resource cXML Script created:', data.id)
+ return data
+}
+
+export interface CreatecXMLExternalURLParams {
+ name?: string
+ contents: Record
+}
+export const createcXMLExternalURLResource = async ({
+ name,
+ contents,
+}: CreatecXMLExternalURLParams) => {
+ const requestBody = {
+ name: name ?? `e2e_${uuid()}`,
+ primary_request_url: contents.primary_request_url,
+ }
+ console.log('-----> request body (external URL):', requestBody)
+
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/fabric/resources/cxml_webhooks`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ body: JSON.stringify(requestBody),
+ }
+ )
+ const data = (await response.json()) as Resource
+ console.log('----> data:', data)
+ console.log('>> Resource cXML External URL created:', data.id)
+ return data
+}
+
+export interface CreateRelayAppResourceParams {
+ name?: string
+ topic: string
+}
+export const createRelayAppResource = async ({
+ name,
+ topic,
+}: CreateRelayAppResourceParams) => {
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/fabric/resources/relay_applications`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ body: JSON.stringify({
+ name: name ?? `e2e_${uuid()}`,
+ topic,
+ }),
+ }
+ )
+ const data = (await response.json()) as Resource
+ console.log('>> Resource Relay App created:', data.id)
+ return data
+}
+
+export const deleteResource = async (id: string) => {
+ const response = await fetch(
+ `https://${process.env.API_HOST}/api/fabric/resources/${id}`,
+ {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${BASIC_TOKEN}`,
+ },
+ }
+ )
+ return response
+}
+
+// #endregion
+
+// #region Utilities for Events assertion
+
+export const expectMemberTalkingEvent = (page: Page) => {
+ return page.evaluate(async () => {
+ return new Promise((resolve) => {
+ // @ts-expect-error
+ const roomObj: Video.RoomSession = window._roomObj
+ roomObj.on('member.talking', resolve)
+ })
+ })
+}
+
+export const expectMediaEvent = (page: Page, event: MediaEventNames) => {
+ return page.evaluate(
+ ({ event }) => {
+ return new Promise((resolve) => {
+ // @ts-expect-error
+ const roomObj: Video.RoomSession = window._roomObj
+ roomObj.on(event, resolve)
+ })
+ },
+ { event }
+ )
+}
+
+export const expectCFInitialEvents = (
+ page: Page,
+ extraEvents: Promise[] = []
+) => {
+ const initialEvents = page.evaluate(async () => {
+ // @ts-expect-error
+ const roomObj: Video.RoomSession = window._roomObj
+
+ const callCreated = new Promise((resolve) => {
+ // @ts-expect-error
+ roomObj.on('call.state', (params: any) => {
+ if (params.call_state === 'created') {
+ resolve(true)
+ }
+ })
+ })
+ const callAnswered = new Promise((resolve) => {
+ // @ts-expect-error
+ roomObj.on('call.state', (params: any) => {
+ if (params.call_state === 'answered') {
+ resolve(true)
+ }
+ })
+ })
+ const callJoined = new Promise((resolve) => {
+ // @ts-expect-error
+ roomObj.on('call.joined', () => resolve(true))
+ })
+
+ return Promise.all([callJoined, callCreated, callAnswered])
+ })
+ return Promise.all([initialEvents, ...extraEvents])
+}
+
+export const expectCFFinalEvents = (
+ page: Page,
+ extraEvents: Promise[] = []
+) => {
+ const finalEvents = page.evaluate(async () => {
+ // @ts-expect-error
+ const roomObj: Video.RoomSession = window._roomObj
+
+ const callLeft = new Promise((resolve) => {
+ roomObj.on('destroy', () => resolve(true))
+ })
+
+ return callLeft
+ })
+
+ return Promise.all([finalEvents, ...extraEvents])
+}
+
+export const expectLayoutChanged = (page: Page, layoutName: string) => {
+ return page.evaluate(
+ (options) => {
+ return new Promise((resolve) => {
+ // @ts-expect-error
+ const roomObj: Video.RoomSession = window._roomObj
+ roomObj.on('layout.changed', ({ layout }: any) => {
+ if (layout.name === options.layoutName) {
+ resolve(true)
+ }
+ })
+ })
+ },
+ { layoutName }
+ )
+}
+
+export const expectRoomJoined = (
+ page: Page,
+ options: { invokeJoin: boolean } = { invokeJoin: true }
+) => {
+ return page.evaluate(({ invokeJoin }) => {
+ return new Promise(async (resolve, reject) => {
+ // @ts-expect-error
+ const roomObj: Video.RoomSession = window._roomObj
+
+ roomObj.once('room.joined', (room) => {
+ console.log('Room joined!')
+ resolve(room)
+ })
+
+ if (invokeJoin) {
+ await roomObj.join().catch(reject)
+ }
+ })
+ }, options)
+}
+
+export const expectRoomJoinWithDefaults = async (
+ page: Page,
+ options?: {
+ invokeJoin?: boolean
+ joinAs?: CreateTestVRTOptions['join_as']
+ }
+) => {
+ const { invokeJoin = true, joinAs = 'member' } = options || {}
+ const params = await expectRoomJoined(page, { invokeJoin })
+ await expectMemberId(page, params.member_id)
+ const dir = joinAs === 'audience' ? 'recvonly' : 'sendrecv'
+ await expectSDPDirection(page, dir, true)
+ const mode = joinAs === 'audience' ? 'audience' : 'member'
+ await expectInteractivityMode(page, mode)
+ return params
+}
+
+export const expectRecordingStarted = (page: Page) => {
+ return page.evaluate(() => {
+ return new Promise((resolve, reject) => {
+ setTimeout(reject, 10000)
+ // At this point window.__roomObj might not have been set yet
+ // we have to pool it and check
+ const interval = setInterval(() => {
+ // @ts-expect-error
+ const roomObj: Video.RoomSession = window._roomObj
+ if (roomObj) {
+ clearInterval(interval)
+ roomObj.on(
+ 'recording.started',
+ (recording: Video.RoomSessionRecording) => resolve(recording)
+ )
+ }
+ }, 100)
+ })
+ })
+}
+
+export const expectScreenShareJoined = async (page: Page) => {
+ return page.evaluate(() => {
+ return new Promise(async (resolve) => {
+ // @ts-expect-error
+ const roomObj: Video.RoomSession = window._roomObj
+
+ roomObj.on('member.joined', (params: any) => {
+ if (params.member.type === 'screen') {
+ resolve(true)
+ }
+ })
+
+ await roomObj.startScreenShare({
+ audio: true,
+ video: true,
+ })
+ })
+ })
+}
+
+// #endregion
+
+export const expectInteractivityMode = async (
+ page: Page,
+ mode: 'member' | 'audience'
+) => {
+ const interactivityMode = await page.evaluate(async () => {
+ // @ts-expect-error
+ const roomObj: Video.RoomSession = window._roomObj
+ return roomObj.interactivityMode
+ })
+
+ expect(interactivityMode).toEqual(mode)
+}
+
+export const setLayoutOnPage = (page: Page, layoutName: string) => {
+ return page.evaluate(
+ async (options) => {
+ // @ts-expect-error
+ const roomObj: Video.RoomSession = window._roomObj
+ return await roomObj.setLayout({ name: options.layoutName })
+ },
+ { layoutName }
+ )
+}
+
+export const randomizeRoomName = (prefix: string = 'e2e') => {
+ return `${prefix}${uuid()}`
+}
+
+export const expectMemberId = async (page: Page, memberId: string) => {
+ const roomMemberId = await page.evaluate(async () => {
+ // @ts-expect-error
+ const roomObj: Video.RoomSession = window._roomObj
+ return roomObj.memberId
+ })
+
+ expect(roomMemberId).toEqual(memberId)
+}