diff --git a/.github/workflows/browser-client-production.yml b/.github/workflows/browser-client-production.yml index 1db6510b9..48b6cff61 100644 --- a/.github/workflows/browser-client-production.yml +++ b/.github/workflows/browser-client-production.yml @@ -18,7 +18,7 @@ jobs: fail-fast: false matrix: node-version: [20.x] - project: [callfabric, renegotiation, videoElement] + project: [default, callfabric, renegotiation, videoElement] steps: - uses: actions/checkout@v4 - name: Install deps diff --git a/.github/workflows/browser-client-staging.yml b/.github/workflows/browser-client-staging.yml index 4adb5555b..7a32dde9e 100644 --- a/.github/workflows/browser-client-staging.yml +++ b/.github/workflows/browser-client-staging.yml @@ -18,7 +18,7 @@ jobs: fail-fast: false matrix: node-version: [20.x] - project: [callfabric, renegotiation, videoElement] + project: [default, callfabric, renegotiation, videoElement] steps: - uses: actions/checkout@v4 - name: Install deps diff --git a/internal/e2e-client/fixtures.ts b/internal/e2e-client/fixtures.ts index d77c4aacd..80444fe06 100644 --- a/internal/e2e-client/fixtures.ts +++ b/internal/e2e-client/fixtures.ts @@ -1,4 +1,3 @@ -import { PageWithWsInspector, intercepWsTraffic } from 'playwrigth-ws-inspector' import { test as baseTest, expect, type Page } from '@playwright/test' import { CreatecXMLScriptParams, @@ -22,9 +21,7 @@ type CustomPage = Page & { swNetworkUp: () => Promise } type CustomFixture = { - createCustomPage(options: { - name: string - }): Promise> + createCustomPage(options: { name: string }): Promise createCustomVanillaPage(options: { name: string }): Promise resource: { createcXMLExternalURLResource: typeof createcXMLExternalURLResource @@ -38,25 +35,20 @@ type CustomFixture = { const test = baseTest.extend({ createCustomPage: async ({ context }, use) => { - const maker = async (options: { - name: string - }): Promise> => { - let page = await context.newPage() + const maker = async (options: { name: string }): Promise => { + const page = (await context.newPage()) as CustomPage enablePageLogs(page, options.name) - //@ts-ignore - page = await intercepWsTraffic(page) - // @ts-expect-error page.swNetworkDown = () => { console.log('Simulate network down..') return context.setOffline(true) } - // @ts-expect-error + page.swNetworkUp = () => { console.log('Simulate network up..') return context.setOffline(false) } - // @ts-expect-error + return page } @@ -145,4 +137,4 @@ const test = baseTest.extend({ }, }) -export { test, expect, Page } +export { test, expect, Page, CustomPage } diff --git a/internal/e2e-client/package.json b/internal/e2e-client/package.json index e4df2199f..7aff78612 100644 --- a/internal/e2e-client/package.json +++ b/internal/e2e-client/package.json @@ -17,7 +17,6 @@ "devDependencies": { "@playwright/test": "^1.52.0", "@types/express": "^5.0.1", - "playwrigth-ws-inspector": "^1.0.0", "vite": "^7.0.0" } } diff --git a/internal/e2e-client/tests/callfabric/utils.spec.ts b/internal/e2e-client/tests/callfabric/utils.spec.ts new file mode 100644 index 000000000..20038a03f --- /dev/null +++ b/internal/e2e-client/tests/callfabric/utils.spec.ts @@ -0,0 +1,313 @@ +import { expectPageEvalToPass, expectToPass, SERVER_URL } from '../../utils' +import { test, expect } from '../../fixtures' + +test.describe('utils', () => { + test.describe('expectToPass', () => { + test('expectToPass: should resolve when the function passes', async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + + await expectToPass( + async () => { + const result = await page.waitForFunction(() => true) + expect(await result.jsonValue()).toBe(true) + }, + { message: 'should resolve when the function passes' } + ) + }) + + test('should fail with a custom message and stack trace', async ({ + createCustomPage, + }) => { + let expectedError: Error | undefined = undefined + const page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + + try { + await expectToPass( + async () => { + await page.waitForFunction(() => { + throw new Error('test error') + }) + }, + { message: 'custom message' } + ) + } catch (error) { + expectedError = error + } + expect(expectedError).toBeDefined() + expect(expectedError).toBeInstanceOf(Error) + expect(expectedError).toMatchObject({ + message: expect.stringContaining('custom message'), + stack: expect.stringContaining('utils.spec.ts'), + }) + }) + + test('should timeout when waiting for a promise to resolve', async () => { + expect( + expectToPass( + async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)) + }, + { message: 'expect timeout' }, + { timeout: 100 } + ) + ).rejects.toThrow('Timeout 100ms exceeded while waiting on the predicate') + }) + + test('should respect custom timeout option', async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: '[page]' }) + + const startTime = Date.now() + let expectedError: Error | undefined = undefined + + try { + await expectToPass( + async () => { + await page.waitForTimeout(3000) // Delay longer than timeout + expect(false).toBe(true) // Will never pass + }, + { message: 'should timeout in 1 second' }, + { timeout: 1000 } // Short timeout + ) + } catch (error) { + expectedError = error + } + + const elapsedTime = Date.now() - startTime + expect(expectedError).toBeDefined() + expect(elapsedTime).toBeLessThan(2000) // Should timeout quickly + }) + + test('should use custom interval array', async ({ createCustomPage }) => { + const page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + + let attemptCount = 0 + const attempts: number[] = [] + + await expectToPass( + async () => { + attempts.push(Date.now()) + attemptCount++ + if (attemptCount < 3) { + throw new Error('Not ready yet') + } + // Pass on 3rd attempt + }, + { message: 'should use custom intervals' }, + { interval: [100, 200, 300], timeout: 10000 } + ) + + expect(attemptCount).toBe(3) + expect(attempts.length).toBe(3) + }) + + test('should use default options when none provided', async () => { + let attemptCount = 0 + + await expectToPass( + async () => { + attemptCount++ + if (attemptCount < 2) { + throw new Error('Not ready yet') + } + // Pass on 2nd attempt + }, + { message: 'should use defaults' } + // No options parameter + ) + + expect(attemptCount).toBe(2) + }) + + test('should handle immediate success without retries', async () => { + let attemptCount = 0 + + await expectToPass( + async () => { + attemptCount++ + // Succeeds immediately + expect(true).toBe(true) + }, + { message: 'should succeed immediately' } + ) + + expect(attemptCount).toBe(1) // Should only run once + }) + + test('should handle promise rejection properly', async () => { + let expectedError: Error | undefined = undefined + + try { + await expectToPass( + async () => { + return Promise.reject(new Error('Async rejection')) + }, + { message: 'should handle rejection' }, + { timeout: 1000 } + ) + } catch (error) { + expectedError = error + } + + expect(expectedError).toBeDefined() + expect(expectedError?.message).toMatch(/should handle rejection/) + }) + + test('should handle longer polling scenarios', async () => { + let attemptCount = 0 + const startTime = Date.now() + + await expectToPass( + async () => { + attemptCount++ + // Simulate waiting for a condition that takes time + if (Date.now() - startTime < 2000) { + throw new Error('Still waiting...') + } + expect(attemptCount).toBeGreaterThan(1) + }, + { message: 'should handle longer polling' }, + { timeout: 5000 } + ) + + expect(attemptCount).toBeGreaterThan(1) + }) + }) +}) + +test.describe('waitForFunction', () => { + test('TODO: should resolve when the function returns a truthy value', async () => { + test.skip( + true, + 'TODO: Implement test for waitForFunction resolving on truthy value' + ) + }) + + test('TODO: should timeout if the function never returns truthy', async () => { + test.skip(true, 'TODO: Implement test for waitForFunction timeout behavior') + }) + + test('TODO: should pass arguments to the page function', async () => { + test.skip(true, 'TODO: Implement test for waitForFunction argument passing') + }) +}) + +test.describe('expectPageEvalToPass', () => { + test('should resolve when the page evaluation passes', async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: '[page]' }) + + const result = await expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result).toBe(true) + }, + evaluateFn: () => true, + message: 'pass - resolve when the function returns a truthy value', + }) + + expect(result).toBe(true) + + // with promise + const result2 = await expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result).toBe(true) + }, + evaluateFn: () => Promise.resolve(true), + message: 'pass - resolve when the function returns a truthy value', + }) + expect(result2).toBe(true) + }) + + test('should throw when the evaluateFn throws an error', async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: '[page]' }) + + await expect( + expectPageEvalToPass(page, { + assertionFn: (result: unknown) => { + // should not be called because the evaluateFn throws an error + expect(result).not.toBeInstanceOf(Error) + expect(result).not.toMatchObject({ + message: 'test error', + }) + // should never pass to ensure expect().toPass() does not resolve + expect(false).toBe(true) + }, + evaluateFn: () => { + return new Promise((_resolve, reject) => { + setTimeout(() => { + reject(new Error('test error')) + }, 100) + }) + }, + message: 'expect error', + }) + ).rejects.toThrow('expect error') + }) + + test('should pass evaluateArgs to the evaluateFn and return the serializable object', async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: '[page]' }) + + const result = await expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result).toMatchObject({ + param: 'test', + param2: false, + param3: 123, + param4: {}, + }) + }, + evaluateArgs: { + param: 'test', + param2: false, + param3: 123, + param4: {}, + }, + evaluateFn: (params) => { + return params + }, + message: 'pass - resolve when the function returns a truthy value', + }) + + expect(result).toMatchObject({ + param: 'test', + param2: false, + param3: 123, + param4: {}, + }) + }) + + test('should timeout when page evaluation takes too long', async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: '[page]' }) + + await expect( + expectPageEvalToPass(page, { + assertionFn: (result) => { + // should never be called + expect(result).not.toMatch('should not resolve') + }, + evaluateFn: () => { + return new Promise((resolve) => + setTimeout(() => resolve('should not resolve'), 5000) + ) + }, + message: 'timeout - should timeout when page evaluation takes too long', + timeoutMs: 100, + }) + ).rejects.toThrow( + 'timeout - should timeout when page evaluation takes too long' + ) + }) +}) diff --git a/internal/e2e-client/tests/callfabric/videoRoom.spec.ts b/internal/e2e-client/tests/callfabric/videoRoom.spec.ts index d7dfdd0ad..815a9be76 100644 --- a/internal/e2e-client/tests/callfabric/videoRoom.spec.ts +++ b/internal/e2e-client/tests/callfabric/videoRoom.spec.ts @@ -1,280 +1,520 @@ import { uuid } from '@signalwire/core' -import { CallSession, CallJoinedEventParams } from '@signalwire/client' -import { test, expect } from '../../fixtures' +import { CallJoinedEventParams, CallSession } from '@signalwire/client' +import { test, expect, CustomPage } from '../../fixtures' import { SERVER_URL, createCFClient, dialAddress, expectLayoutChanged, expectMCUVisible, + expectPageEvalToPass, getStats, setLayoutOnPage, + waitForFunction, } from '../../utils' +import { JSHandle } from '@playwright/test' test.describe('CallCall VideoRoom', () => { test('should handle joining a room, perform actions and then leave the room', async ({ createCustomPage, resource, }) => { - const page = await createCustomPage({ name: '[page]' }) - await page.goto(SERVER_URL) + let callObj = {} as JSHandle + let callSession = {} as CallJoinedEventParams + let page = {} as CustomPage - const roomName = `e2e_${uuid()}` - await resource.createVideoRoomResource(roomName) + await test.step('setup page and call', async () => { + page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) - await createCFClient(page) + const roomName = `e2e_${uuid()}` + await resource.createVideoRoomResource(roomName) - // Dial an address and join a video room - const callSession: CallJoinedEventParams = await dialAddress(page, { - address: `/public/${roomName}?channel=video`, + await createCFClient(page) + + // Dial an address and join a video room + callSession = await dialAddress(page, { + address: `/public/${roomName}?channel=video`, + }) + + expect( + callSession.room_session, + 'room session should be defined' + ).toBeDefined() + expect( + callSession.room_session.name, + 'room session name should be defined' + ).toBeDefined() + expect( + callSession.room_session.display_name, + 'room session display name should be defined' + ).toBeDefined() + + const memberId = callSession.member_id + expect( + callSession.room_session.members.some( + (member) => member.member_id === memberId + ), + 'member should be in the room' + ).toBeTruthy() + + await expectMCUVisible(page) + + // --------------- Call Object ------------------------ + callObj = await waitForFunction(page, { + evaluateFn: () => { + if (window._callObj) { + return window._callObj + } else { + throw new Error('Call object not found') + } + }, + message: 'call object', + }) }) - expect(callSession.room_session).toBeDefined() - expect(callSession.room_session.name).toBeDefined() - expect(callSession.room_session.display_name).toBeDefined() - expect( - callSession.room_session.members.some( - (member) => member.member_id === callSession.member_id + await test.step('sanity check - call object, call session and page are set', async () => { + expect( + callObj.getProperty('on'), + 'call object on should be defined' + ).toBeDefined() + expect( + callObj.getProperty('audioMute'), + 'call object audioMute should be defined' + ).toBeDefined() + expect( + callObj.getProperty('leave'), + 'call object leave should be defined' + ).toBeDefined() + expect(callSession).toHaveProperty( + 'room_session', + 'call session room session should be defined' ) - ).toBeTruthy() - - await expectMCUVisible(page) + expect(callSession).toHaveProperty( + 'member_id', + 'call session member id should be defined' + ) + expect(page.goto, 'page goto should be defined').toBeDefined() + expect(page.evaluate, 'page evaluate should be defined').toBeDefined() + expect( + page.waitForSelector, + 'page waitForSelector should be defined' + ).toBeDefined() + }) // --------------- Muting Audio (self) --------------- - await page.evaluate( - async ({ callSession }) => { - // @ts-expect-error - const callObj: CallSession = window._callObj - - const memberUpdatedMuted = new Promise((resolve) => { - const memberUpdatedEvent = new Promise((res) => { - callObj.on('member.updated', (params) => { + await test.step('muting audio (self)', async () => { + const memberUpdatedMutedEvent = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'member updated muted event resolved').toBe(true) + }, + evaluateArgs: { callObj, callSession }, + evaluateFn: (params) => { + return new Promise((resolve) => { + params.callObj.on('member.updated.audioMuted', ({ member }) => { if ( - params.member.member_id === callSession.member_id && - params.member.updated.includes('audio_muted') && - params.member.audio_muted === true + member.member_id === params.callSession.member_id && + member.audio_muted === true ) { - res(true) + resolve(true) } }) }) - const memberUpdatedMutedEvent = new Promise((res) => { - callObj.on('member.updated.audioMuted', (params) => { + }, + message: 'expect member updated muted event', + }) + + const memberUpdatedMuted = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'member updated muted resolved').toBe(true) + }, + evaluateArgs: { callObj, callSession }, + evaluateFn: (params) => { + return new Promise((resolve) => { + params.callObj.on('member.updated', ({ member }) => { if ( - params.member.member_id === callSession.member_id && - params.member.audio_muted === true + member.member_id === params.callSession.member_id && + member.updated.includes('audio_muted') && + member.audio_muted === true ) { - res(true) + resolve(true) } }) }) + }, + message: 'expect member updated muted', + }) - Promise.all([memberUpdatedEvent, memberUpdatedMutedEvent]).then( - resolve - ) - }) + const audioMuteSelf = waitForFunction(page, { + evaluateArgs: { callObj }, + evaluateFn: async (params) => await params.callObj.audioMute(), + message: 'audio mute self', + }) - const memberUpdatedUnmuted = new Promise((resolve) => { - const memberUpdatedEvent = new Promise((res) => { - callObj.on('member.updated', (params) => { + await audioMuteSelf + await memberUpdatedMuted + await memberUpdatedMutedEvent + }) + // --------------- Unmuting Audio (self) --------------- + await test.step('unmuting audio (self)', async () => { + const memberUpdatedUnmutedEvent = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'member updated unmuted event resolved').toBe(true) + }, + evaluateArgs: { callObj, callSession }, + evaluateFn: (params) => { + return new Promise((resolve) => { + params.callObj.on('member.updated.audioMuted', ({ member }) => { if ( - params.member.member_id === callSession.member_id && - params.member.updated.includes('audio_muted') && - params.member.audio_muted === false + member.member_id === params.callSession.member_id && + member.audio_muted === false ) { - res(true) + resolve(true) } }) }) - const memberUpdatedMutedEvent = new Promise((res) => { - callObj.on('member.updated.audioMuted', (params) => { + }, + message: 'expect member updated unmuted event', + }) + + const memberUpdatedUnmuted = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'member updated unmuted resolved').toBe(true) + }, + evaluateArgs: { callObj, callSession }, + evaluateFn: (params) => { + return new Promise((resolve) => { + params.callObj.on('member.updated', ({ member }) => { if ( - params.member.member_id === callSession.member_id && - params.member.audio_muted === false + member.member_id === params.callSession.member_id && + member.updated.includes('audio_muted') && + member.audio_muted === false ) { - res(true) + resolve(true) } }) }) + }, + message: 'expect member updated unmuted', + }) - Promise.all([memberUpdatedEvent, memberUpdatedMutedEvent]).then( - resolve - ) - }) - - await callObj.audioMute() - await callObj.audioUnmute() - - return Promise.all([memberUpdatedMuted, memberUpdatedUnmuted]) - }, - { callSession } - ) + const audioUnmuteSelf = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'member video updated muted event resolved').toBe(true) + }, + evaluateArgs: { callObj }, + evaluateFn: async (params) => { + await params.callObj.audioUnmute() + return true + }, + message: 'expect audio unmute self', + }) + await audioUnmuteSelf + await memberUpdatedUnmuted + await memberUpdatedUnmutedEvent + }) // --------------- Muting Video (self) --------------- - await page.evaluate( - async ({ callSession }) => { - // @ts-expect-error - const callObj: CallSession = window._callObj - - const memberUpdatedMuted = new Promise((resolve) => { - const memberUpdatedEvent = new Promise((res) => { - callObj.on('member.updated', (params) => { + await test.step('muting video (self)', async () => { + const memberVideoUpdatedMutedEvent = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'member video updated muted event resolved').toBe(true) + }, + evaluateArgs: { callObj, callSession }, + evaluateFn: (params) => { + return new Promise((resolve) => { + params.callObj.on('member.updated.videoMuted', ({ member }) => { if ( - params.member.member_id === callSession.member_id && - params.member.updated.includes('video_muted') && - params.member.updated.includes('visible') && - params.member.video_muted === true && - params.member.visible === false + member.member_id === params.callSession.member_id && + member.video_muted === true && + member.visible === false ) { - res(true) + resolve(true) } }) }) - const memberUpdatedMutedEvent = new Promise((res) => { - callObj.on('member.updated.videoMuted', (params) => { + }, + message: 'expect member video updated muted event', + }) + + const memberVideoUpdatedMuted = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'member video updated unmuted event resolved').toBe( + true + ) + }, + evaluateArgs: { callObj, callSession }, + evaluateFn: (params) => { + return new Promise((resolve) => { + params.callObj.on('member.updated', ({ member }) => { if ( - params.member.member_id === callSession.member_id && - params.member.video_muted === true && - params.member.visible === false + member.member_id === params.callSession.member_id && + member.updated.includes('video_muted') && + member.updated.includes('visible') && + member.video_muted === true && + member.visible === false ) { - res(true) + resolve(true) } }) }) + }, + message: 'expect member video updated muted', + }) + const videoMuteSelf = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'video mute self resolved').toBe(true) + }, + evaluateArgs: { callObj }, + evaluateFn: async (params) => { + await params.callObj.videoMute() + return true + }, + message: 'expect video mute self', + }) - Promise.all([memberUpdatedEvent, memberUpdatedMutedEvent]).then( - resolve - ) - }) - - const memberUpdatedUnmuted = new Promise((resolve) => { - const memberUpdatedEvent = new Promise((res) => { - callObj.on('member.updated', (params) => { + await videoMuteSelf + await memberVideoUpdatedMuted + await memberVideoUpdatedMutedEvent + }) + // --------------- Unmuting Video (self) --------------- + + await test.step('unmuting video (self) ', async () => { + const memberVideoUpdatedUnmutedEvent = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'member video updated unmuted resolved').toBe(true) + }, + evaluateArgs: { callObj, callSession }, + evaluateFn: (params) => { + return new Promise((resolve) => { + params.callObj.on('member.updated.videoMuted', ({ member }) => { if ( - params.member.member_id === callSession.member_id && - params.member.updated.includes('video_muted') && - params.member.updated.includes('visible') && - params.member.video_muted === false && - params.member.visible === true + member.member_id === params.callSession.member_id && + member.video_muted === false && + member.visible === true ) { - res(true) + resolve(true) } }) }) - const memberUpdatedMutedEvent = new Promise((res) => { - callObj.on('member.updated.videoMuted', (params) => { + }, + message: 'expect member video updated unmuted event', + }) + + const memberVideoUpdatedUnmuted = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'video unmute self resolved').toBe(true) + }, + evaluateArgs: { callObj, callSession }, + evaluateFn: async (params) => { + return new Promise((resolve) => { + params.callObj.on('member.updated', ({ member }) => { if ( - params.member.member_id === callSession.member_id && - params.member.video_muted === false && - params.member.visible === true + member.member_id === params.callSession.member_id && + member.updated.includes('video_muted') && + member.updated.includes('visible') && + member.video_muted === false && + member.visible === true ) { - res(true) + resolve(true) } }) }) + }, + message: 'expect member video updated unmuted', + }) - Promise.all([memberUpdatedEvent, memberUpdatedMutedEvent]).then( - resolve - ) - }) - - await callObj.videoMute() - await callObj.videoUnmute() + const videoUnmuteSelf = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'video unmute self resolved').toBe(true) + }, + evaluateArgs: { callObj }, + evaluateFn: async (params) => { + await params.callObj.videoUnmute() + return true + }, + message: 'expect video unmute self', + }) - return Promise.all([memberUpdatedMuted, memberUpdatedUnmuted]) - }, - { callSession } - ) + await videoUnmuteSelf + await memberVideoUpdatedUnmuted + await memberVideoUpdatedUnmutedEvent + }) // --------------- Screenshare --------------- - await page.evaluate(async () => { - // @ts-expect-error - const callObj: CallSession = window._callObj - - let screenMemberId: string | undefined - - const screenJoined = new Promise((resolve) => { - callObj.on('member.joined', (params) => { - if (params.member.type === 'screen') { - screenMemberId = params.member.member_id - resolve(true) - } - }) + await test.step('screen share', async () => { + const screenMemberJoined = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'screen joined result resolved').toBe(true) + }, + evaluateArgs: { callObj }, + evaluateFn: async (params) => { + return new Promise((resolve) => { + params.callObj.on('member.joined', ({ member }) => { + if (member.type === 'screen') { + resolve(member.member_id) + } + }) + }) + }, + message: 'expect screen joined result', }) - const screenLeft = new Promise((resolve) => { - callObj.on('member.left', (params) => { - if ( - params.member.type === 'screen' && - params.member.member_id === screenMemberId - ) { - resolve(true) - } - }) + // --------------- Start Screen Share --------------- + const screenShareObj = await waitForFunction(page, { + evaluateArgs: { callObj }, + evaluateFn: async (params) => + await params.callObj.startScreenShare({ + audio: true, + video: true, + }), + message: 'screen share obj', }) - const screenShareObj = await callObj.startScreenShare({ - audio: true, - video: true, + const screenMemberId = await screenMemberJoined + + // --------------- Check Screen Share ID --------------- + await expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'screen left resolved').toBe(true) + }, + evaluateArgs: { screenShareObj, screenMemberId }, + evaluateFn: (params) => { + return new Promise((resolve) => { + resolve(params.screenMemberId === params.screenShareObj.memberId) + }) + }, + message: 'expect screen share id check', }) - const screenShareIdCheckPromise = new Promise((resolve) => { - resolve(screenMemberId === screenShareObj.memberId) + const screenMemberLeft = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'screen member left resolved').toBe(true) + }, + evaluateArgs: { callObj, screenMemberId }, + evaluateFn: async (params) => { + return new Promise((resolve) => { + params.callObj.on('member.left', ({ member }) => { + if ( + member.type === 'screen' && + member.member_id === params.screenMemberId + ) { + resolve(true) + } + }) + }) + }, + message: 'expect screen left', }) - const screenRoomLeft = new Promise((resolve) => { - screenShareObj.on('room.left', () => resolve(true)) + const screenRoomLeft = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'screen room left resolved').toBe(true) + }, + evaluateArgs: { screenShareObj }, + evaluateFn: (params) => { + return new Promise((resolve) => { + params.screenShareObj.on('room.left', () => resolve(true)) + }) + }, + message: 'expect screen room left', }) - await new Promise((r) => setTimeout(r, 2000)) - - await screenShareObj.leave() + const screenShareObjCallLeave = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'screen share obj left resolved').toBe(true) + }, + evaluateArgs: { screenShareObj }, + evaluateFn: async (params) => { + await params.screenShareObj.leave() + return true + }, + message: 'expect screen share obj left', + }) - return Promise.all([ - screenJoined, - screenLeft, - screenRoomLeft, - screenShareIdCheckPromise, - ]) + // preserve order of these promises + await screenMemberJoined + await screenShareObjCallLeave + await screenRoomLeft + await screenMemberLeft }) // --------------- Room lock/unlock --------------- - await page.evaluate( - // @ts-expect-error - async ({ callSession }) => { - // @ts-expect-error - const callObj: CallSession = window._callObj - - const roomUpdatedLocked = new Promise((resolve) => { - callObj.on('room.updated', (params) => { - if (params.room_session.locked === true) { - resolve(true) - } + await test.step('room lock', async () => { + const roomUpdatedLocked = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'room updated locked resolved').toBe(true) + }, + evaluateArgs: { callObj }, + evaluateFn: async (params) => { + return new Promise((resolve) => { + params.callObj.on('room.updated', ({ room_session }) => { + if (room_session.locked === true) { + resolve(true) + } + }) }) - }) + }, + message: 'expect room updated locked', + }) - const roomUpdatedUnlocked = new Promise((resolve) => { - callObj.on('room.updated', (params) => { - if (params.room_session.locked === false) { - resolve(true) - } + const roomLock = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'room updated unlocked resolved').toBe(true) + }, + evaluateArgs: { callObj }, + evaluateFn: async (params) => { + await params.callObj.lock() + return true + }, + message: 'expect room lock', + }) + + await roomLock + await roomUpdatedLocked + }) + + await test.step('room unlock', async () => { + const roomUpdatedUnlocked = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'room updated unlocked resolved').toBe(true) + }, + evaluateArgs: { callObj }, + evaluateFn: async (params) => { + return new Promise((resolve) => { + params.callObj.on('room.updated', ({ room_session }) => { + if (room_session.locked === false) { + resolve(true) + } + }) }) - }) + }, + message: 'expect room updated unlocked', + }) - await callObj.lock() - await roomUpdatedLocked + const roomUnlock = expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'room updated unlocked resolved').toBe(true) + }, + evaluateArgs: { callObj }, + evaluateFn: async (params) => { + await params.callObj.unlock() + return true + }, + message: 'expect room unlock', + }) - await callObj.unlock() - await roomUpdatedUnlocked - }, - { callSession } - ) + await roomUnlock + await roomUpdatedUnlocked + }) // --------------- Set layout --------------- - const layoutName = '3x3' - const layoutChangedPromise = expectLayoutChanged(page, layoutName) - await setLayoutOnPage(page, layoutName) - expect(await layoutChangedPromise).toBe(true) + await test.step('set layout', async () => { + const LAYOUT_NAME = '3x3' + const layoutChangedPromise = expectLayoutChanged(page, LAYOUT_NAME) + await setLayoutOnPage(page, LAYOUT_NAME) + await expect(layoutChangedPromise).resolves.toBe(true) + }) /** * FIXME: The following APIs are not yet supported by the Call Call SDK @@ -373,28 +613,37 @@ test.describe('CallCall VideoRoom', () => { await createCFClient(page) - // Dial an address and join a video room - const callSession = await page.evaluate(async () => { - try { - const client = window._client! + // Dial an address and join a video room using expectPageEvalToPass + await expectPageEvalToPass(page, { + assertionFn: (result) => { + expect( + result.success, + 'call session should return success as false' + ).toBe(false) + }, + evaluateFn: async () => { + try { + const client = window._client + if (!client) { + throw new Error('Client not found') + } - const call = await client.dial({ - to: `/public/invalid-address?channel=video`, - rootElement: document.getElementById('rootElement'), - }) + const call = client.dial({ + to: `/public/invalid-address?channel=video`, + rootElement: document.getElementById('rootElement'), + }) - // @ts-expect-error - window._callObj = call + window._callObj = call - await call.start() + await call.start() - return { success: true } - } catch (error) { - return { success: false, error } - } + return { success: true } + } catch (error) { + return { success: false, error } + } + }, + message: 'expect call session to fail on invalid address', }) - - expect(callSession.success).toBe(false) }) test('should handle joining a room with audio channel only', async ({ @@ -410,27 +659,40 @@ test.describe('CallCall VideoRoom', () => { await createCFClient(page) // Dial an address with audio only channel - const callSession = await dialAddress(page, { + const callSession: CallJoinedEventParams = await dialAddress(page, { address: `/public/${roomName}?channel=audio`, }) - expect(callSession.room_session).toBeDefined() + expect( + callSession.room_session, + 'room session should be defined' + ).toBeDefined() expect( callSession.room_session.members.some( - (member: any) => member.member_id === callSession.member_id - ) + (member) => member.member_id === callSession.member_id + ), + 'member should be in the room' ).toBeTruthy() // There should be no inbound/outbound video const stats = await getStats(page) - expect(stats.outboundRTP.video?.packetsSent).toBe(0) - expect(stats.inboundRTP.video?.packetsReceived).toBe(0) + expect( + stats.outboundRTP.video?.packetsSent, + 'outbound video packets sent should be 0' + ).toBe(0) + expect( + stats.inboundRTP.video?.packetsReceived, + 'inbound video packets received should be 0' + ).toBe(0) // There should be audio packets - expect(stats.inboundRTP.audio?.packetsReceived).toBeGreaterThan(0) + expect( + stats.inboundRTP.audio?.packetsReceived, + 'inbound audio packets received should be greater than 0' + ).toBeGreaterThan(0) // There should be no MCU either const videoElement = await page.$('div[id^="sw-sdk-"] > video') - expect(videoElement).toBeNull() + expect(videoElement, 'video element should be null').toBeNull() }) }) diff --git a/internal/e2e-client/utils.ts b/internal/e2e-client/utils.ts index f50ed6587..31f00bce7 100644 --- a/internal/e2e-client/utils.ts +++ b/internal/e2e-client/utils.ts @@ -9,7 +9,8 @@ import type { MediaEventNames } from '@signalwire/webrtc' import { createServer } from 'vite' import path from 'path' import { expect } from './fixtures' -import { Page } from '@playwright/test' +import type { Page } from '@playwright/test' +import type { PageFunction } from 'playwright-core/types/structs' import { v4 as uuid } from 'uuid' import express, { Express, Request, Response } from 'express' import { Server } from 'http' @@ -21,6 +22,7 @@ declare global { SignalWire: typeof SignalWire } _client?: SignalWireClient + _callObj?: CallSession } } @@ -58,22 +60,24 @@ export const createTestServer = async ( logLevel: 'silent', resolve: { alias: { - '@signalwire/client': path.resolve(__dirname, '../../packages/client/src'), + '@signalwire/client': path.resolve( + __dirname, + '../../packages/client/src' + ), '@signalwire/core': path.resolve(__dirname, '../../packages/core/src'), - '@signalwire/webrtc': path.resolve(__dirname, '../../packages/webrtc/src'), + '@signalwire/webrtc': path.resolve( + __dirname, + '../../packages/webrtc/src' + ), '@signalwire/js': path.resolve(__dirname, '../../packages/js/src'), }, }, optimizeDeps: { - include: [ - '@signalwire/client', - '@signalwire/core', - '@signalwire/webrtc', - ], + include: ['@signalwire/client', '@signalwire/core', '@signalwire/webrtc'], }, define: { 'process.env': '{}', - 'process': '{}', + process: '{}', }, }) @@ -113,6 +117,7 @@ interface CreateTestVRTOptions { end_room_session_on_leave?: boolean } +// TODO: This is not used anywhere, remove it? export const createTestVRTToken = async (body: CreateTestVRTOptions) => { const response = await fetch( `https://${process.env.API_HOST}/api/video/room_tokens`, @@ -134,6 +139,7 @@ interface CreateTestJWTOptions { refresh_token?: string } +// TODO: This is not used anywhere, remove it? export const createTestJWTToken = async (body: CreateTestJWTOptions) => { const response = await fetch( `https://${process.env.API_HOST}/api/relay/rest/jwt`, @@ -164,7 +170,9 @@ export const createTestSATToken = async (reference?: string) => { }), } ) - const data = await response.json() + const data = (await response.json()) as { + token: string + } return data.token } @@ -209,6 +217,7 @@ interface CreateTestCRTOptions { channels: Record } +// TODO: This is not used anywhere, remove it? export const createTestCRTToken = async (body: CreateTestCRTOptions) => { const response = await fetch( `https://${process.env.API_HOST}/api/chat/tokens`, @@ -295,6 +304,7 @@ export const createRoom = async (body: CreateOrUpdateRoomOptions) => { return response.json() } +// TODO: This is not used anywhere, remove it? export const createStreamForRoom = async (name: string, url: string) => { const room = await getRoomByName(name) if (!room) { @@ -320,6 +330,7 @@ export const createStreamForRoom = async (name: string, url: string) => { return data } +// TODO: This is not used anywhere, remove it? export const deleteRoom = async (id: string) => { return await fetch(`https://${process.env.API_HOST}/api/video/rooms/${id}`, { method: 'DELETE', @@ -332,10 +343,8 @@ export const deleteRoom = async (id: string) => { export const leaveRoom = async (page: Page) => { return page.evaluate(async () => { - const callObj: CallSession = - // @ts-expect-error - window._callObj - console.log('Fixture callObj', callObj) + const callObj = window._callObj + if (callObj && callObj?.roomSessionId) { console.log('Fixture has room', callObj.roomSessionId) await callObj.leave() @@ -362,6 +371,7 @@ export const createCFClient = async ( params?: CreateCFClientParams ) => { const sat = await createTestSATToken(params?.reference) + expect(sat, 'SAT token created').toBeDefined() return createCFClientWithToken(page, sat, params) } @@ -386,10 +396,18 @@ const createCFClientWithToken = async ( const { attachSagaMonitor = false } = params || {} - const swClient = await page.evaluate( - async (options) => { + const swClient = (await expectPageEvalToPass(page, { + assertionFn: (client) => { + expect(client, 'SignalWire client should be defined').toBeDefined() + }, + evaluateArgs: { + RELAY_HOST: process.env.RELAY_HOST, + API_TOKEN: sat, + attachSagaMonitor, + }, + evaluateFn: async (options) => { const _runningWorkers: any[] = [] - // @ts-expect-error + // @ts-expect-error - _runningWorkers is not defined in the window object window._runningWorkers = _runningWorkers const addTask = (task: any) => { if (!_runningWorkers.includes(task)) { @@ -416,6 +434,16 @@ const createCFClientWithToken = async ( } const SignalWire = window._SWJS.SignalWire + if (!SignalWire) { + throw new Error('SignalWire is not defined') + } + if (!options.RELAY_HOST) { + throw new Error('Relay host is not defined') + } + if (!options.API_TOKEN) { + throw new Error('API token is not defined') + } + const client: SignalWireContract = await SignalWire({ host: options.RELAY_HOST, token: options.API_TOKEN, @@ -426,12 +454,8 @@ const createCFClientWithToken = async ( window._client = client return client }, - { - RELAY_HOST: process.env.RELAY_HOST, - API_TOKEN: sat, - attachSagaMonitor, - } - ) + message: 'expect SignalWire client to be created', + })) as SignalWireContract return swClient } @@ -444,17 +468,31 @@ interface DialAddressParams { 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 ({ + +export const dialAddress = ( + page: Page, + params: DialAddressParams = { + address: '', + dialOptions: {}, + reattach: false, + shouldPassRootElement: true, + shouldStartCall: true, + shouldWaitForJoin: true, + } +) => { + type EvaluateArgs = Omit & { + dialOptions: string + } + + return expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'dialAddress result should be defined').toBeDefined() + }, + evaluateArgs: { + ...params, + dialOptions: JSON.stringify(params.dialOptions), + }, + evaluateFn: async ({ address, dialOptions, reattach, @@ -463,7 +501,9 @@ export const dialAddress = (page: Page, params: DialAddressParams) => { shouldWaitForJoin, }) => { return new Promise(async (resolve, _reject) => { - // @ts-expect-error + if (!window._client) { + throw new Error('Client is not defined') + } const client: SignalWireContract = window._client const dialer = reattach ? client.reattach : client.dial @@ -477,10 +517,11 @@ export const dialAddress = (page: Page, params: DialAddressParams) => { }) if (shouldWaitForJoin) { - call.on('room.joined', resolve) + call.on('room.joined', (params) => { + resolve(params) + }) } - // @ts-expect-error window._callObj = call if (shouldStartCall) { @@ -492,15 +533,8 @@ export const dialAddress = (page: Page, params: DialAddressParams) => { } }) }, - { - address, - dialOptions: JSON.stringify(dialOptions), - reattach, - shouldPassRootElement, - shouldStartCall, - shouldWaitForJoin, - } - ) + message: 'expect dialAddress to succeed', + }) } export const reloadAndReattachAddress = async ( @@ -515,8 +549,7 @@ export const reloadAndReattachAddress = async ( export const disconnectClient = (page: Page) => { return page.evaluate(async () => { - // @ts-expect-error - const client: SignalWireContract = window._client + const client = window._client if (!client) { console.log('Client is not available') @@ -537,7 +570,7 @@ export const expectMCUVisible = async (page: Page) => { export const expectMCUNotVisible = async (page: Page) => { const mcuVideo = await page.$('div[id^="sw-sdk-"] > video') - expect(mcuVideo).toBeNull() + expect(mcuVideo, 'MCU video should be null').toBeNull() } export const expectMCUVisibleForAudience = async (page: Page) => { @@ -573,131 +606,143 @@ interface GetStatsResult { } export const getStats = async (page: Page): Promise => { - return await page.evaluate(async () => { - // @ts-expect-error - const callObj: CallSession = window._callObj - // @ts-expect-error - const rtcPeer = callObj.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, + let result = {} as GetStatsResult + await expectPageEvalToPass(page, { + evaluateFn: async () => { + const callObj = window._callObj + if (!callObj) { + throw new Error('Call object not found') + } + + // @ts-expect-error - peer is not defined in the call object + const rtcPeer = callObj.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, + }, }, - video: { - active: false, - packetsSent: 0, - targetBitrate: 0, - totalPacketSendDelay: 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 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 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 + 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 + // 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 + 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] + }) } - inboundRTPFilters[media].forEach((key) => { - result.inboundRTP[media][key] = report[key] - }) - } + const handleOutboundRTP = (report: any) => { + const media = report.mediaType as 'audio' | 'video' + if (!media) return - 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 + // 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] + }) } - 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 + } }) - } - - // 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 + return result + }, + assertionFn: (result) => { + expect(result, 'expect RTP stats to be defined').toBeDefined() + }, + message: 'expect to get RTP stats', }) + return result } +// TODO: This is not used anywhere, remove it? export const expectPageReceiveMedia = async (page: Page, delay = 5_000) => { const first = await getStats(page) await page.waitForTimeout(delay) @@ -707,23 +752,31 @@ export const expectPageReceiveMedia = async (page: Page, delay = 5_000) => { const minAudioPacketsExpected = 40 * seconds const minVideoPacketsExpected = 25 * seconds - expect(last.inboundRTP.video?.packetsReceived).toBeGreaterThan( + expect( + last.inboundRTP.video?.packetsReceived, + 'last inbound video packets received should be greater than first inbound video packets received' + ).toBeGreaterThan( (first.inboundRTP.video?.packetsReceived || 0) + minVideoPacketsExpected ) - expect(last.inboundRTP.audio?.packetsReceived).toBeGreaterThan( + expect( + last.inboundRTP.audio?.packetsReceived, + 'last inbound audio packets received should be greater than first inbound audio packets received' + ).toBeGreaterThan( (first.inboundRTP.audio?.packetsReceived || 0) + minAudioPacketsExpected ) } export const getAudioStats = async (page: Page) => { const audioStats = await page.evaluate(async () => { - // @ts-expect-error - const callObj: CallSession = window._callObj + const callObj = window._callObj + if (!callObj) { + throw new Error('Call object not found') + } - // @ts-expect-error + // @ts-expect-error - peer is not defined in the call object const audioTrackId = callObj.peer._getReceiverByKind('audio').track.id - // @ts-expect-error + // @ts-expect-error - peer is not defined in the call object const stats = await callObj.peer.instance.getStats(null) const filter = { 'inbound-rtp': [ @@ -775,9 +828,14 @@ export const expectTotalAudioEnergyToBeGreaterThan = async ( const totalAudioEnergy = audioStats['inbound-rtp']['totalAudioEnergy'] if (totalAudioEnergy) { - expect(totalAudioEnergy).toBeGreaterThan(value) + expect( + totalAudioEnergy, + 'totalAudioEnergy should be greater than value' + ).toBeGreaterThan(value) } else { - console.log('Warning - totalAudioEnergy was not present in the audioStats.') + console.warn( + 'Warning - totalAudioEnergy was not present in the audioStats.' + ) } } @@ -786,22 +844,33 @@ export const expectPageReceiveAudio = async (page: Page) => { await expectTotalAudioEnergyToBeGreaterThan(page, 0.5) } +// TODO: This is not used anywhere, remove it? export const expectSDPDirection = async ( page: Page, direction: string, value: boolean ) => { const peerSDP = await page.evaluate(async () => { - // @ts-expect-error - const callObj: CallSession = window._callObj - // @ts-expect-error + const callObj = window._callObj + if (!callObj) { + throw new Error('Call object not found') + } + + // @ts-expect-error - peer is not defined in the call object return callObj.peer.localSdp }) - expect(peerSDP.split('m=')[1].includes(direction)).toBe(value) - expect(peerSDP.split('m=')[2].includes(direction)).toBe(value) + expect( + peerSDP.split('m=')[1].includes(direction), + 'peerSDP should include direction in m= section 1' + ).toBe(value) + expect( + peerSDP.split('m=')[2].includes(direction), + 'peerSDP should include direction in m= section 2' + ).toBe(value) } +// TODO: This is not used anywhere, remove it? export const getRemoteMediaIP = async (page: Page) => { const remoteIP: string = await page.evaluate(() => { // @ts-expect-error @@ -919,6 +988,7 @@ export async function expectStatWithPolling( export type StatusEvents = 'initiated' | 'ringing' | 'answered' | 'completed' +// TODO: This is not used anywhere, remove it? export const createCallWithCompatibilityApi = async ( resource: string, inlineLaml: string, @@ -935,7 +1005,7 @@ export const createCallWithCompatibilityApi = async ( data.append('From', `${process.env.VOICE_DIAL_FROM_NUMBER}`) const vertoDomain = process.env.VERTO_DOMAIN - expect(vertoDomain).toBeDefined() + expect(vertoDomain, 'vertoDomain should be defined').toBeDefined() let to = `verto:${resource}@${vertoDomain}` if (codecs) { @@ -992,6 +1062,7 @@ export const createCallWithCompatibilityApi = async ( return undefined } +// TODO: This is not used anywhere, remove it? export const getDialConferenceLaml = (conferenceNameBase: string) => { const conferenceName = randomizeRoomName(conferenceNameBase) const conferenceRegion = process.env.LAML_CONFERENCE_REGION ?? '' @@ -1012,6 +1083,7 @@ export const getDialConferenceLaml = (conferenceNameBase: string) => { return inlineLaml } +// TODO: This is not used anywhere, remove it? export const expectv2HasReceivedAudio = async ( page: Page, minTotalAudioEnergy: number, @@ -1079,14 +1151,20 @@ export const expectv2HasReceivedAudio = async ( const totalAudioEnergy = audioStats['inbound-rtp']['totalAudioEnergy'] const packetsReceived = audioStats['inbound-rtp']['packetsReceived'] if (totalAudioEnergy) { - expect(totalAudioEnergy).toBeGreaterThan(minTotalAudioEnergy) + expect( + totalAudioEnergy, + 'totalAudioEnergy should be greater than minTotalAudioEnergy' + ).toBeGreaterThan(minTotalAudioEnergy) } else { - console.log('Warning: totalAudioEnergy was missing from the report!') + console.warn('Warning: totalAudioEnergy was missing from the report!') if (packetsReceived) { // We still want the right amount of packets - expect(packetsReceived).toBeGreaterThan(minPacketsReceived) + expect( + packetsReceived, + 'packetsReceived should be greater than minPacketsReceived' + ).toBeGreaterThan(minPacketsReceived) } else { - console.log('Warning: packetsReceived was missing from the report!') + console.warn('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. @@ -1095,6 +1173,7 @@ export const expectv2HasReceivedAudio = async ( } } +// TODO: This is not used anywhere, remove it? export const expectv2HasReceivedSilence = async ( page: Page, maxTotalAudioEnergy: number, @@ -1162,14 +1241,20 @@ export const expectv2HasReceivedSilence = async ( const totalAudioEnergy = audioStats['inbound-rtp']['totalAudioEnergy'] const packetsReceived = audioStats['inbound-rtp']['packetsReceived'] if (totalAudioEnergy) { - expect(totalAudioEnergy).toBeLessThan(maxTotalAudioEnergy) + expect( + totalAudioEnergy, + 'totalAudioEnergy should be less than maxTotalAudioEnergy' + ).toBeLessThan(maxTotalAudioEnergy) } else { - console.log('Warning: totalAudioEnergy was missing from the report!') + console.warn('Warning: totalAudioEnergy was missing from the report!') if (packetsReceived) { // We still want the right amount of packets - expect(packetsReceived).toBeGreaterThan(minPacketsReceived) + expect( + packetsReceived, + 'packetsReceived should be greater than minPacketsReceived' + ).toBeGreaterThan(minPacketsReceived) } else { - console.log('Warning: packetsReceived was missing from the report!') + console.warn('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. @@ -1178,6 +1263,7 @@ export const expectv2HasReceivedSilence = async ( } } +// TODO: This is not used anywhere, remove it? export const expectedMinPackets = ( packetRate: number, callDurationMs: number, @@ -1196,10 +1282,12 @@ export const expectedMinPackets = ( return minPackets } +// TODO: This is not used anywhere, remove it? export const randomizeResourceName = (prefix: string = 'e2e') => { return `res-${prefix}${uuid()}` } +// TODO: This is not used anywhere, remove it? export const expectInjectRelayHost = async (page: Page, host: string) => { await page.evaluate( async (params) => { @@ -1405,10 +1493,8 @@ export const createVideoRoomResource = async (name?: string) => { } ) const data = (await response.json()) as Resource + expect(data.id, 'Video Room resource created').toBeDefined() console.log('>> Resource VideoRoom created:', data.id, name) - if (!data.id) { - throw new Error('Failed to create Video Room resource') - } return data } @@ -1558,22 +1644,28 @@ export const deleteResource = async (id: string) => { // #region Utilities for Events assertion +// TODO: This is not used anywhere, remove it? export const expectMemberTalkingEvent = (page: Page) => { return page.evaluate(async () => { return new Promise((resolve) => { - // @ts-expect-error const callObj = window._callObj + if (!callObj) { + throw new Error('Call object not found') + } callObj.on('member.talking', resolve) }) }) } +// TODO: This is not used anywhere, remove it? export const expectMediaEvent = (page: Page, event: MediaEventNames) => { return page.evaluate( ({ event }) => { return new Promise((resolve) => { - // @ts-expect-error const callObj = window._callObj + if (!callObj) { + throw new Error('Call object not found') + } callObj.on(event, resolve) }) }, @@ -1586,8 +1678,10 @@ export const expectCFInitialEvents = ( extraEvents: Promise[] = [] ) => { const initialEvents = page.evaluate(async () => { - // @ts-expect-error - const callObj: CallSession = window._callObj + const callObj = window._callObj + if (!callObj) { + throw new Error('Call object not found') + } const callCreated = new Promise((resolve) => { callObj.on('call.state', (params) => { @@ -1617,8 +1711,10 @@ export const expectCFFinalEvents = ( extraEvents: Promise[] = [] ) => { const finalEvents = page.evaluate(async () => { - // @ts-expect-error - const callObj: CallSession = window._callObj + const callObj = window._callObj + if (!callObj) { + throw new Error('Call object not found') + } const callLeft = new Promise((resolve) => { callObj.on('destroy', () => resolve(true)) @@ -1630,21 +1726,27 @@ export const expectCFFinalEvents = ( return Promise.all([finalEvents, ...extraEvents]) } -export const expectLayoutChanged = (page: Page, layoutName: string) => { - return page.evaluate( - (options) => { - return new Promise((resolve) => { - // @ts-expect-error - const callObj: CallSession = window._callObj - callObj.on('layout.changed', ({ layout }: any) => { - if (layout.name === options.layoutName) { +export const expectLayoutChanged = async (page: Page, layoutName: string) => { + return await expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'expect layout changed result').toBe(true) + }, + evaluateArgs: { layoutName }, + evaluateFn: (params) => { + return new Promise((resolve) => { + const callObj = window._callObj + if (!callObj) { + throw new Error('Call object not found') + } + callObj.on('layout.changed', ({ layout }) => { + if (layout.name === params.layoutName) { resolve(true) } }) }) }, - { layoutName } - ) + message: 'expect layout changed result', + }) } export const expectRoomJoined = ( @@ -1653,8 +1755,10 @@ export const expectRoomJoined = ( ) => { return page.evaluate(({ invokeJoin }) => { return new Promise(async (resolve, reject) => { - // @ts-expect-error - const callObj: CallSession = window._callObj + const callObj = window._callObj + if (!callObj) { + throw new Error('Call object not found') + } callObj.once('room.joined', (room) => { console.log('Room joined!') @@ -1668,62 +1772,187 @@ export const expectRoomJoined = ( }, options) } -export const expectScreenShareJoined = async (page: Page) => { - return page.evaluate(() => { - return new Promise(async (resolve) => { - // @ts-expect-error - const callObj: CallSession = window._callObj - - callObj.on('member.joined', (params) => { - if (params.member.type === 'screen') { - resolve(true) - } - }) - - await callObj.startScreenShare({ - audio: true, - video: true, - }) - }) - }) -} - // #endregion +// TODO: This is not used anywhere, remove it? export const expectInteractivityMode = async ( page: Page, mode: 'member' | 'audience' ) => { const interactivityMode = await page.evaluate(async () => { - // @ts-expect-error const callObj = window._callObj + if (!callObj) { + throw new Error('Call object not found') + } + // @ts-expect-error - interactivityMode is not defined in the CallSession interface return callObj.interactivityMode }) - expect(interactivityMode).toEqual(mode) + expect( + interactivityMode, + 'interactivityMode should be equal to mode' + ).toEqual(mode) } -export const setLayoutOnPage = (page: Page, layoutName: string) => { - return page.evaluate( - async (options) => { - // @ts-expect-error +export const setLayoutOnPage = async (page: Page, layoutName: string) => { + const layoutChanged = await expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'layout changed result should be true').toBe(true) + }, + evaluateArgs: { layoutName }, + evaluateFn: async (params) => { const callObj = window._callObj - return await callObj.setLayout({ name: options.layoutName }) + if (!callObj) { + throw new Error('Call object not found') + } + await callObj.setLayout({ name: params.layoutName }) + return true }, - { layoutName } - ) + message: 'expect set layout', + }) + return layoutChanged } export const randomizeRoomName = (prefix: string = 'e2e') => { return `${prefix}${uuid()}` } +// TODO: This is not used anywhere, remove it? export const expectMemberId = async (page: Page, memberId: string) => { const roomMemberId = await page.evaluate(async () => { - // @ts-expect-error const callObj = window._callObj + if (!callObj) { + throw new Error('Call object not found') + } return callObj.memberId }) - expect(roomMemberId).toEqual(memberId) + expect(roomMemberId, 'roomMemberId should be equal to memberId').toBe( + memberId + ) +} + +/** + * @description + * Uses the expect().toPass() Playwright with a default timeout + * + */ +export const expectToPass = async ( + assertion: () => Promise, + assertionMessage: string | { message: string }, + options?: { interval?: number[]; timeout?: number } +) => { + const mergedOptions = { + interval: [10_000], // 10 seconds to avoid polling + timeout: 10_000, + ...options, + } + return await expect(assertion, assertionMessage).toPass(mergedOptions) +} +/** + * @description + * Waits for a function to return a truthy value or not throw within the page context. + * + * This utility wraps Playwright's `page.waitForFunction` and is useful for polling the browser context + * until a certain condition is met. In this wrapper the interval and timeout are set to the same value by default. + * This is to avoid polling, and have a default timeout of 10 seconds. + * + * @note + * - The function is evaluated in the browser context, so only serializable values can be passed. + * - Returns when the pageFunction returns a truthy value. It resolves to a JSHandle of the truthy value. + * - The JSHandle can be passed to other Playwright functions, like `page.evaluate` or `page.evaluateHandle`. + */ + +export const waitForFunction = async ( + page: Page, + { + evaluateArgs, + evaluateFn, + message, + interval = [10_000], + timeoutMs = 10_000, + }: { + evaluateArgs?: TArgs + evaluateFn: PageFunction + message: string + interval?: number[] + timeoutMs?: number + } +) => { + try { + const mergedOptions = { + interval: interval ?? [10_000], // 10 seconds to avoid polling + timeout: timeoutMs ?? 10_000, + message, + } + if (evaluateArgs) { + return await page.waitForFunction(evaluateFn, evaluateArgs, mergedOptions) + } else { + // FIXME: remove the type assertion + return await page.waitForFunction( + evaluateFn as PageFunction, + mergedOptions + ) + } + } catch (error) { + // TODO: improve error message and logging + if (message) { + throw new Error(`waitForFunction: ${message} - ${error}`) + } else { + throw new Error(`waitForFunction: ${error}`) + } + } +} + +/** + * @description + * Utility to evaluate a function in the browser context and assert its result using Playwright's expect. + * + * This function wraps a call to `page.evaluate` and uses the expectToPass utility to assert that a promise resolves + * + * @note + * - The function is evaluated in the browser context, so only serializable values can be passed. + * - Only serializable values can be returned + * - The assertion function should use the `expect` function to assert the result + * - Throws timeout error if the promise does not resolve within the timeout + */ +export const expectPageEvalToPass = async ( + page: Page, + { + assertionFn, + evaluateArgs, + evaluateFn, + message, + interval = [10_000], + timeoutMs = 10_000, + }: { + assertionFn: (result: TResult) => void + evaluateArgs?: TArgs + evaluateFn: PageFunction + message: string + interval?: number[] + timeoutMs?: number + } +) => { + // NOTE: force the result to be the resolved value of the promise to avoid `undefined` check + let result = undefined as TResult + await expectToPass( + async () => { + // evaluate the function with the provided arguments + if (evaluateArgs) { + result = await page.evaluate( + evaluateFn as PageFunction, + evaluateArgs + ) + } else { + // evaluate the function without arguments + result = await page.evaluate(evaluateFn as PageFunction) + } + + assertionFn(result) + }, + { message: message }, + { timeout: timeoutMs, interval: interval } + ) + return result } diff --git a/package-lock.json b/package-lock.json index 551eb6cdd..72d5c2560 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,6 @@ "devDependencies": { "@playwright/test": "^1.52.0", "@types/express": "^5.0.1", - "playwrigth-ws-inspector": "^1.0.0", "vite": "^7.0.0" } }, @@ -13424,16 +13423,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/playwrigth-ws-inspector": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/playwrigth-ws-inspector/-/playwrigth-ws-inspector-1.1.0.tgz", - "integrity": "sha512-vF6rT9ue1W9zALiC8YbCcGvTnTU+VFlR1tRrl7GKl8N1djEC7PINLti1A21fyZAHq3ZuxtGoNm81Af9wxlQ8GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash": "^4.17.21" - } - }, "node_modules/polite-json": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/polite-json/-/polite-json-4.0.1.tgz",