diff --git a/.github/workflows/browser-client-production.yml b/.github/workflows/browser-client-production.yml index 48b6cff61..483182dc8 100644 --- a/.github/workflows/browser-client-production.yml +++ b/.github/workflows/browser-client-production.yml @@ -18,7 +18,22 @@ jobs: fail-fast: false matrix: node-version: [20.x] - project: [default, callfabric, renegotiation, videoElement] + project: + [ + default, + Utilities, + CoreRoom, + AudioVideo, + Device, + Agent, + Connection, + Interaction, + Renegotiation, + Conversation, + IncomingCall, + Websocket, + 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 7a32dde9e..17d661fd1 100644 --- a/.github/workflows/browser-client-staging.yml +++ b/.github/workflows/browser-client-staging.yml @@ -18,7 +18,22 @@ jobs: fail-fast: false matrix: node-version: [20.x] - project: [default, callfabric, renegotiation, videoElement] + project: + [ + default, + Utilities, + CoreRoom, + AudioVideo, + Device, + Agent, + Connection, + Interaction, + Renegotiation, + Conversation, + IncomingCall, + Websocket, + VideoElement, + ] steps: - uses: actions/checkout@v4 - name: Install deps diff --git a/internal/e2e-client/TestContext.ts b/internal/e2e-client/TestContext.ts new file mode 100644 index 000000000..005952c2b --- /dev/null +++ b/internal/e2e-client/TestContext.ts @@ -0,0 +1,307 @@ +import { randomUUID } from 'crypto' +import type { JSONRPCMethod } from '@signalwire/core/' + +type EventCategory = + | 'transport' + | 'connection' + | 'callSession' + | 'conversations' + | 'tests' + +type Event = + | 'websocket_open' + | 'websocket_close' + | 'websocket_error' + | 'websocket_message' + | 'websocket_closed' + +type Params = { + call_id?: string + room_session?: { + id?: string + } +} + +type Result = { + call_id?: string + room_session?: { + id?: string + } +} + +type Metadata = Record + +type Payload = { + error?: any + method?: JSONRPCMethod + event?: Event + params?: Params + result?: Result + metadata?: Metadata +} + +export class SDKEvent { + constructor( + public id: string, + public timestamp: number, + public direction: 'send' | 'recv', + public category: 'request' | 'response' | 'notification' | 'connection', + public eventType: string, + public eventCategory: EventCategory, + public payload: Payload, + public context: { + isCallEvent: boolean + isRoomEvent: boolean + isConnectionEvent: boolean + isError: boolean + callId?: string + roomId?: string + }, + public method?: JSONRPCMethod, + public metadata?: Metadata + ) {} + + // Utility methods for common checks + isCallRelated(): boolean { + return this.context.isCallEvent + } + + isRoomRelated(): boolean { + return this.context.isRoomEvent + } + + isConnectionRelated(): boolean { + return this.context.isConnectionEvent + } + + isError(): boolean { + return this.context.isError + } + + isSent(): boolean { + return this.direction === 'send' + } + + isReceived(): boolean { + return this.direction === 'recv' + } + + // Category checks + isTransport(): boolean { + return this.eventCategory === 'transport' + } + + isConnection(): boolean { + return this.eventCategory === 'connection' + } + + isCallSession(): boolean { + return this.eventCategory === 'callSession' + } + + isConversations(): boolean { + return this.eventCategory === 'conversations' + } + + isTests(): boolean { + return this.eventCategory === 'tests' + } +} + +export interface EventStats { + totalEvents: number + sentEvents: number + receivedEvents: number + errorEvents: number + callEvents: number + roomEvents: number + connectionEvents: number + // Category-based counts + transportCategoryEvents: number + connectionCategoryEvents: number + callSessionCategoryEvents: number + conversationsCategoryEvents: number + testsCategoryEvents: number +} + +/** + * TestContext class for tracking SDK events during e2e tests. + * + * Usage: + * - Automatically set up via Playwright fixtures + * - Events are captured via WebSocket monitoring + * - Context dumped to console and attachments on test failures + */ +export class TestContext { + private sdkEvents: SDKEvent[] = [] + private startTime: number = Date.now() + + addSDKEvent(payload: Payload, direction: 'send' | 'recv') { + const event = this.categorizeSDKEvent(payload, direction) + this.sdkEvents.push(event) + } + + /** + * Get comprehensive statistics about captured events. + * Includes both legacy boolean-based counts and new category-based counts. + */ + getStats() { + return { + totalEvents: this.sdkEvents.length, + sentEvents: this.sdkEvents.filter((event) => event.isSent()).length, + receivedEvents: this.sdkEvents.filter((event) => event.isReceived()) + .length, + errorEvents: this.sdkEvents.filter((event) => event.isError()).length, + callEvents: this.sdkEvents.filter((event) => event.isCallRelated()) + .length, + roomEvents: this.sdkEvents.filter((event) => event.isRoomRelated()) + .length, + connectionEvents: this.sdkEvents.filter((event) => + event.isConnectionRelated() + ).length, + // Category-based counts + transportCategoryEvents: this.sdkEvents.filter((event) => + event.isTransport() + ).length, + connectionCategoryEvents: this.sdkEvents.filter((event) => + event.isConnection() + ).length, + callSessionCategoryEvents: this.sdkEvents.filter((event) => + event.isCallSession() + ).length, + conversationsCategoryEvents: this.sdkEvents.filter((event) => + event.isConversations() + ).length, + testsCategoryEvents: this.sdkEvents.filter((event) => event.isTests()) + .length, + } + } + + getAllEvents() { + return [...this.sdkEvents] + } + + getTestDuration() { + return Date.now() - this.startTime + } + + private categorizeSDKEvent( + payload: Payload, + direction: 'send' | 'recv' + ): SDKEvent { + const timestamp = Date.now() + const id = `${timestamp}-${randomUUID()}` + + const method = payload.method || '' + + const context = { + isCallEvent: method.includes('call.') || method.includes('calling.'), + isRoomEvent: + method.includes('room.') || + method.includes('member.') || + method.includes('layout.'), + isConnectionEvent: + method.startsWith('signalwire.') || method.startsWith('subscriber.'), + isError: Boolean(payload.error), + callId: payload.params?.call_id || payload.result?.call_id, + roomId: + payload.params?.room_session?.id || payload.result?.room_session?.id, + } + + return new SDKEvent( + id, + timestamp, + direction, + this.getCategory(payload), + this.getEventType(payload), + this.categorizeMethod(payload), + payload, + context, + payload.method, + payload.metadata + ) + } + + private getCategory(payload: Payload) { + // JSON-RPC 2.0 specification: + // - Response: Has 'id' and ('result' OR 'error') + // - Request: Has 'method' and 'id' + // - Notification: Has 'method' but no 'id' + const hasId = 'id' in payload + const hasMethod = 'method' in payload + const hasResult = 'result' in payload + const hasError = 'error' in payload + + // Response: has id and (result or error), typically no method + if (hasId && (hasResult || hasError)) { + return 'response' as const + } + + // Request: has both method and id + if (hasMethod && hasId) { + return 'request' as const + } + + // Notification: has method but no id + if (hasMethod && !hasId) { + return 'notification' as const + } + + // Fallback for connection events (websocket events, etc.) + return 'connection' as const + } + + private categorizeMethod(payload: Payload): EventCategory { + const method = payload.method || '' + const event = payload.event || '' + + if ( + event === 'websocket_open' || + event === 'websocket_close' || + event === 'websocket_error' || + event === 'websocket_message' || + event === 'websocket_closed' + ) { + return 'transport' + } + + // SignalWire connection events + if (method.startsWith('signalwire.') || method.startsWith('subscriber.')) { + return 'connection' + } + + // Conversations events + if (method.startsWith('conversations.')) { + return 'conversations' + } + + // Test events (artificial events) + if (method.startsWith('test.') || method.includes('artificial')) { + return 'tests' + } + + // Everything else is callSession (call, room, member, chat, verto, voice, etc.) + // Note: verto.* are call signaling events, not transport events + return 'callSession' + } + + private getEventType(payload: { + error?: any + method?: JSONRPCMethod + event?: Event + }) { + if (payload.error) { + return payload.error.message + } + + if (payload.method) { + return payload.method + } + + if (payload.event) { + return payload.event + } + + return 'unknown' + } +} diff --git a/internal/e2e-client/fixtures.ts b/internal/e2e-client/fixtures.ts index d8036b53e..a4eb2301a 100644 --- a/internal/e2e-client/fixtures.ts +++ b/internal/e2e-client/fixtures.ts @@ -1,4 +1,11 @@ -import { test as baseTest, expect, type Page } from '@playwright/test' +import { + test as baseTest, + BrowserContext, + expect, + type Page, +} from '@playwright/test' +import { TestContext } from './TestContext' +import { attachTestContext } from './utils' import { CreatecXMLScriptParams, CreateRelayAppResourceParams, @@ -14,6 +21,7 @@ import { enablePageLogs, leaveRoom, CreatecXMLExternalURLParams, + setupWebSocketMonitoring, } from './utils' type CustomPage = Page & { @@ -23,6 +31,7 @@ type CustomPage = Page & { type CustomFixture = { createCustomPage(options: { name: string }): Promise createCustomVanillaPage(options: { name: string }): Promise + testContext: TestContext resource: { createcXMLExternalURLResource: typeof createcXMLExternalURLResource createcXMLScriptResource: typeof createcXMLScriptResource @@ -34,9 +43,32 @@ type CustomFixture = { } const test = baseTest.extend({ - createCustomPage: async ({ context }, use) => { + testContext: async ({ context }, use) => { + const enableTestContext = ({ context }: { context: BrowserContext }) => { + const testContext = new TestContext() + + // Set up WebSocket monitoring for all pages in the context + const setupMonitoringForPage = (page: Page) => { + setupWebSocketMonitoring(page, testContext) + } + + // Monitor existing pages + context.pages().forEach(setupMonitoringForPage) + + // Monitor new pages as they're created + context.on('page', setupMonitoringForPage) + + return testContext + } + + const startTestContext = () => enableTestContext({ context }) + + await use(startTestContext()) + }, + createCustomPage: async ({ context, testContext }, use, testInfo) => { const maker = async (options: { name: string }): Promise => { const page = (await context.newPage()) as CustomPage + enablePageLogs(page, options.name) page.swNetworkDown = () => { @@ -55,6 +87,12 @@ const test = baseTest.extend({ try { await use(maker) } finally { + // attach test context if failed test + if (testInfo.status !== 'passed' && testInfo.status !== 'skipped') { + console.log('Attaching test context...') + attachTestContext(testInfo, testContext) + } + console.log('Cleaning up pages..') /** * If we have a __callObj in the page means we tested the Call APIs diff --git a/internal/e2e-client/playwright.config.ts b/internal/e2e-client/playwright.config.ts index 6e3ecb4ea..33cf6b71a 100644 --- a/internal/e2e-client/playwright.config.ts +++ b/internal/e2e-client/playwright.config.ts @@ -2,30 +2,46 @@ require('dotenv').config() import { PlaywrightTestConfig, devices } from '@playwright/test' -const callfabricTests = [ - 'address.spec.ts', - 'agent_customer.spec.ts', - 'audioFlags.spec.ts', - 'cleanup.spec.ts', - 'conversation.spec.ts', - 'deviceEvent.spec.ts', - 'deviceState.spec.ts', - 'holdunhold.spec.ts', - 'incomingCall.spec.ts', - 'mirrorVideo.spec.ts', - 'muteUnmuteAll.spec.ts', - 'raiseHand.spec.ts', - 'reattach.spec.ts', - 'relayApp.spec.ts', - 'swml.spec.ts', - 'videoRoom.spec.ts', - 'videoRoomLayout.spec.ts', +const callfabricCoreRoomTests = [ + 'callfabric/videoRoom.spec.ts', + 'callfabric/videoRoomLayout.spec.ts', ] -const renegotiationTests = [ - 'renegotiateAudio.spec.ts', - 'renegotiateVideo.spec.ts', +const callfabricAudioVideoTests = [ + 'callfabric/audioFlags.spec.ts', + 'callfabric/mirrorVideo.spec.ts', + 'callfabric/muteUnmuteAll.spec.ts', ] -const videoElementTests = ['buildVideoWithCallSDK.spec.ts'] +const callfabricDeviceTests = [ + 'callfabric/deviceEvent.spec.ts', + 'callfabric/deviceState.spec.ts', +] +const callfabricAgentTests = [ + 'callfabric/agent_customer.spec.ts', + 'callfabric/address.spec.ts', + 'callfabric/relayApp.spec.ts', + 'callfabric/swml.spec.ts', +] +const callfabricConnectionTests = [ + 'callfabric/reattach.spec.ts', + 'callfabric/cleanup.spec.ts', +] +const callfabricInteractionTests = [ + 'callfabric/raiseHand.spec.ts', + 'callfabric/holdunhold.spec.ts', +] +const callfabricRenegotiationTests = [ + 'callfabric/renegotiateAudio.spec.ts', + 'callfabric/renegotiateVideo.spec.ts', +] +const callfabricConversationTests = ['callfabric/conversation.spec.ts'] +const callfabricIncomingCallTests = ['callfabric/incomingCall.spec.ts'] +const callFabricWebsocketTests = [ + 'callfabric/incoming_call_over_websocket.spec.ts', + 'callfabric/websocket_reconnect.spec.ts', +] +const callfabricUtilsTests = ['callfabric/utils.spec.ts'] + +const videoElementTests = ['buildVideoWithFabricSDK.spec.ts'] const useDesktopChrome: PlaywrightTestConfig['use'] = { ...devices['Desktop Chrome'], @@ -61,23 +77,77 @@ const config: PlaywrightTestConfig = { name: 'default', use: useDesktopChrome, testIgnore: [ - ...callfabricTests, - ...renegotiationTests, + ...callfabricAgentTests, + ...callfabricCoreRoomTests, + ...callfabricAudioVideoTests, + ...callfabricDeviceTests, + ...callfabricConnectionTests, + ...callfabricInteractionTests, + ...callfabricConversationTests, + ...callfabricIncomingCallTests, + ...callFabricWebsocketTests, + ...callfabricUtilsTests, + ...callfabricRenegotiationTests, ...videoElementTests, ], }, { - name: 'callfabric', + name: 'Utilities', + use: useDesktopChrome, + testMatch: callfabricUtilsTests, + }, + { + name: 'CoreRoom', + use: useDesktopChrome, + testMatch: callfabricCoreRoomTests, + }, + { + name: 'AudioVideo', + use: useDesktopChrome, + testMatch: callfabricAudioVideoTests, + }, + { + name: 'Device', + use: useDesktopChrome, + testMatch: callfabricDeviceTests, + }, + { + name: 'Agent', + use: useDesktopChrome, + testMatch: callfabricAgentTests, + }, + { + name: 'Connection', + use: useDesktopChrome, + testMatch: callfabricConnectionTests, + }, + { + name: 'Interaction', + use: useDesktopChrome, + testMatch: callfabricInteractionTests, + }, + { + name: 'Renegotiation', + use: useDesktopChrome, + testMatch: callfabricRenegotiationTests, + }, + { + name: 'Conversation', + use: useDesktopChrome, + testMatch: callfabricConversationTests, + }, + { + name: 'IncomingCall', use: useDesktopChrome, - testMatch: callfabricTests, + testMatch: callfabricIncomingCallTests, }, { - name: 'renegotiation', + name: 'Websocket', use: useDesktopChrome, - testMatch: renegotiationTests, + testMatch: callFabricWebsocketTests, }, { - name: 'videoElement', + name: 'VideoElement', use: useDesktopChrome, testMatch: videoElementTests, }, diff --git a/internal/e2e-client/test-reporter.ts b/internal/e2e-client/test-reporter.ts index 95702055a..d1694778a 100644 --- a/internal/e2e-client/test-reporter.ts +++ b/internal/e2e-client/test-reporter.ts @@ -1,10 +1,12 @@ -import { Reporter, TestCase, TestResult, TestError } from '@playwright/test/reporter' +import { Reporter } from '@playwright/test/reporter' +import type { TestCase, TestResult, TestError } from '@playwright/test/reporter' +import { createContextDumpText } from './utils' class TestNameReporter implements Reporter { printsToStdio() { return false } - + onTestBegin(test: TestCase) { const timestamp = new Date().toISOString() console.log('\n' + '='.repeat(80)) @@ -25,47 +27,56 @@ class TestNameReporter implements Reporter { console.log(`STATUS: ${status.toUpperCase()}`) console.log(`DURATION: ${duration}ms`) console.log(`TITLE: ${test.title}`) - + // Log error details for failed tests if (status === 'failed' && result.errors.length > 0) { console.log('\nERROR DETAILS:') result.errors.forEach((error: TestError, index: number) => { console.log(`\nError ${index + 1}:`) console.log(`Message: ${error.message || 'No message'}`) - + // Extract enhanced error information if (error.message?.includes('page.evaluate')) { console.log('TYPE: page.evaluate error') - + // Check for timeout if (error.message?.includes('Timeout')) { console.log('CAUSE: Possible event timeout within page.evaluate') - console.log('HINT: One or more promises inside page.evaluate may have timed out') + console.log( + 'HINT: One or more promises inside page.evaluate may have timed out' + ) } - + // Check for object serialization error if (error.message?.includes('Object')) { console.log('CAUSE: Object serialization error') - console.log('HINT: Ensure all returned values from page.evaluate are serializable') + console.log( + 'HINT: Ensure all returned values from page.evaluate are serializable' + ) } } - + // Extract timeout information if present - if (error.message?.includes('timeout') || error.message?.includes('Timeout')) { + if ( + error.message?.includes('timeout') || + error.message?.includes('Timeout') + ) { console.log('TYPE: Timeout Error') - + // Try to extract event name from enhanced error messages - const eventMatch = error.message.match(/waiting for (?:event|SDK event)[:\s]+['"]?([^'"]+)['"]?/i) + const eventMatch = error.message.match( + /waiting for (?:event|SDK event)[:\s]+['"]?([^'"]+)['"]?/i + ) if (eventMatch) { console.log(`EVENT WAITING FOR: ${eventMatch[1]}`) } - + // Extract timeout duration if present const timeoutMatch = error.message.match(/\(timeout:\s*(\d+)ms\)/i) if (timeoutMatch) { console.log(`TIMEOUT DURATION: ${timeoutMatch[1]}ms`) } - + // Check for test vs promise timeout if (error.message?.includes('Test timeout of')) { console.log('TIMEOUT TYPE: Playwright test timeout') @@ -73,22 +84,51 @@ class TestNameReporter implements Reporter { console.log('TIMEOUT TYPE: Promise/Event timeout') } } - + if (error.stack) { console.log('\nStack trace:') // Show more lines for timeout errors to help debug const linesToShow = error.message?.includes('timeout') ? 10 : 5 const stackLines = error.stack.split('\n').slice(0, linesToShow) - stackLines.forEach(line => console.log(` ${line}`)) + stackLines.forEach((line) => console.log(` ${line}`)) if (error.stack.split('\n').length > linesToShow) { console.log(' ... (truncated)') } } }) + + // Add SDK Test Context dump for failed tests (only if TestContext is available) + const hasTestContext = result.attachments.some( + (att) => att.name === 'SDK Test Context (JSON)' + ) + if (hasTestContext) { + this.dumpTestContext(result) + } } - + console.log('-'.repeat(80) + '\n') } + + private dumpTestContext(result: TestResult) { + // Find SDK Test Context attachment (we know it exists from the caller check) + const contextAttachment = result.attachments.find( + (att) => att.name === 'SDK Test Context (JSON)' + )! + + try { + const contextData = JSON.parse(contextAttachment.body!.toString()) + console.log('\n[INFO] Using TestContext from attachment') + + // Use the existing utility to format and display the context dump + const formattedDump = createContextDumpText(contextData) + console.log('\n' + formattedDump) + } catch (error) { + console.log( + '\n[WARNING] Failed to parse TestContext from attachment:', + error + ) + } + } } -export default TestNameReporter \ No newline at end of file +export default TestNameReporter diff --git a/internal/e2e-client/tests/callfabric/address.spec.ts b/internal/e2e-client/tests/callfabric/address.spec.ts index ae5ff6eeb..45fe059e1 100644 --- a/internal/e2e-client/tests/callfabric/address.spec.ts +++ b/internal/e2e-client/tests/callfabric/address.spec.ts @@ -1,76 +1,194 @@ -import { SignalWireClient } from '@signalwire/client' -import { test, expect } from '../../fixtures' -import { SERVER_URL, createCFClient } from '../../utils' +import { + GetAddressResponse, + GetAddressesResult, + SignalWireClient, +} from '@signalwire/client' +import { test, expect, CustomPage } from '../../fixtures' +import { SERVER_URL, createCFClient, expectPageEvalToPass } from '../../utils' test.describe('Addresses', () => { test('query multiple addresses and single address', async ({ createCustomPage, }) => { - const page = await createCustomPage({ name: '[page]' }) - await page.goto(SERVER_URL) + let addressById = {} as GetAddressResponse + let addressByName = {} as GetAddressResponse + let addressToCompare = {} as GetAddressResponse + let page = {} as CustomPage - await createCFClient(page) + await test.step('setup page and client', async () => { + page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + await createCFClient(page) + }) - const { addressById, addressByName, addressToCompare } = - await page.evaluate(async () => { - // @ts-expect-error - const client: SignalWireClient = window._client + await test.step('query multiple addresses and select one for comparison', async () => { + addressToCompare = await expectPageEvalToPass(page, { + evaluateFn: async () => { + const client = window._client - const response = await client.address.getAddresses() - const addressToCompare = response.data[1] + if (!client) { + throw new Error('Client not found') + } - const addressById = await client.address.getAddress({ - id: addressToCompare.id, - }) + const response = await client.address.getAddresses() - const addressByName = await client.address.getAddress({ - name: addressToCompare.name, - }) - return { addressById, addressByName, addressToCompare } + return response.data[1] as GetAddressResponse + }, + assertionFn: (result) => { + expect(result, 'second address should be defined').toBeDefined() + }, + message: 'expect to get multiple addresses', }) - expect(addressById.id).toEqual(addressToCompare.id) - expect(addressByName.id).toEqual(addressToCompare.id) + expect( + addressToCompare, + 'address to compare should be defined' + ).toBeDefined() + expect( + addressToCompare.id, + 'address to compare should have an id' + ).toBeDefined() + }) + + await test.step('query address by ID', async () => { + addressById = await expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'address by ID should be defined').toBeDefined() + expect( + result.id, + 'address by ID should have correct id' + ).toBeDefined() + }, + evaluateArgs: { addressId: addressToCompare.id }, + evaluateFn: async (params) => { + const client = window._client + + if (!client) { + throw new Error('Client not found') + } + + return await client.address.getAddress({ + id: params.addressId, + }) + }, + message: 'expect to get address by ID', + }) + }) + + await test.step('query address by name', async () => { + addressByName = await expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'address by name should be defined').toBeDefined() + expect( + result.id, + 'address by name should have correct id' + ).toBeDefined() + }, + evaluateArgs: { addressName: addressToCompare.name }, + evaluateFn: async (params) => { + const client = window._client + + if (!client) { + throw new Error('Client not found') + } + + return await client.address.getAddress({ + name: params.addressName, + }) + }, + message: 'expect to get address by name', + }) + }) + + await test.step('verify addresses match', async () => { + expect( + addressById.id, + 'address queried by ID should match original address ID' + ).toEqual(addressToCompare.id) + expect( + addressByName.id, + 'address queried by name should match original address ID' + ).toEqual(addressToCompare.id) + }) }) test('Should return only type rooms in ASC order by name', async ({ createCustomPage, }) => { - const page = await createCustomPage({ name: '[page]' }) - await page.goto(SERVER_URL) + let addressesResponse = {} as GetAddressesResult + let page = {} as CustomPage - await createCFClient(page) + await test.step('setup page and client', async () => { + page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + await createCFClient(page) + }) - const isCorrectlySorted = await page.evaluate(async () => { - // @ts-expect-error - const client: SignalWireClient = window._client + await test.step('query filtered and sorted addresses', async () => { + addressesResponse = await expectPageEvalToPass(page, { + assertionFn: (result) => { + expect(result, 'addresses response should be defined').toBeDefined() + expect(result.data, 'addresses data should be defined').toBeDefined() + expect( + result.data.length, + 'should have at least 1 address' + ).toBeGreaterThan(0) + }, + evaluateFn: async () => { + const client = window._client - const response = await client.address.getAddresses({ - type: 'room', - sortBy: 'name', - sortOrder: 'asc', - pageSize: 3, + if (!client) { + throw new Error('Client not found') + } + + return await client.address.getAddresses({ + type: 'room', + sortBy: 'name', + sortOrder: 'asc', + pageSize: 3, + }) + }, + message: 'expect to get filtered and sorted addresses', }) + }) - const isSorted = (arr: string[]) => { - for (let i = 0; i < arr.length - 1; i++) { - if (arr[i] > arr[i + 1]) { - return false - } - } + await test.step('verify addresses are sorted correctly', async () => { + const addressNames = addressesResponse.data.map((addr) => addr.name) - return true - } + // Verify we have addresses to test + expect( + addressNames.length, + 'should have addresses to verify sorting' + ).toBeGreaterThan(0) - return isSorted( - response.data.map((addr) => { - console.log(addr.name) - return addr.name - }) - ) - }) + // Verify all addresses are type 'room' + addressesResponse.data.forEach((addr) => { + expect(addr.type, `address ${addr.name} should be of type 'room'`).toBe( + 'room' + ) + }) - expect(isCorrectlySorted).toBeTruthy() + // Verify addresses are sorted in ascending order + for (let i = 0; i < addressNames.length - 1; i++) { + const addressNameCurrent = addressNames[i] + const addressNameNext = addressNames[i + 1] + expect( + addressNameCurrent, + 'address name current should be defined' + ).toBeDefined() + expect( + addressNameNext, + 'address name next should be defined' + ).toBeDefined() + if (!addressNameCurrent || !addressNameNext) { + throw new Error('Address name current or next is undefined') + } + expect( + addressNameCurrent <= addressNameNext, + `address '${addressNameCurrent}' should come before or equal '${addressNameNext}' in ascending order` + ).toBeTruthy() + } + }) }) // TODO unskip this test once this is sorted out in the backend. @@ -114,7 +232,12 @@ test.describe('Addresses', () => { const isSorted = (arr: string[]) => { for (let i = 0; i < arr.length - 1; i++) { - if (arr[i] < arr[i + 1]) { + const addressNameCurrent = arr[i] + const addressNameNext = arr[i + 1] + if (!addressNameCurrent || !addressNameNext) { + return false + } + if (addressNameCurrent < addressNameNext) { return false } } diff --git a/internal/e2e-client/tests/callfabric/agent_customer.spec.ts b/internal/e2e-client/tests/callfabric/agent_customer.spec.ts index db036af21..e1d2b4143 100644 --- a/internal/e2e-client/tests/callfabric/agent_customer.spec.ts +++ b/internal/e2e-client/tests/callfabric/agent_customer.spec.ts @@ -1,6 +1,5 @@ import { uuid } from '@signalwire/core' -import { test } from '../../fixtures' -import { expect } from '../../fixtures' +import { test, expect, CustomPage } from '../../fixtures' import { SERVER_URL, createCFClient, @@ -9,7 +8,9 @@ import { expectCFFinalEvents, expectCFInitialEvents, expectTotalAudioEnergyToBeGreaterThan, + expectPageEvalToPass, getResourceAddresses, + Resource, } from '../../utils' const agent_customer_static_scripts_desc = @@ -28,116 +29,182 @@ test.describe(agent_customer_static_scripts_desc, () => { createCustomPage, resource, }) => { - // Agent - const agent_page = await createCustomPage({ name: '[agent_page]' }) - await agent_page.goto(SERVER_URL) - - const agentResourceName = `e2e_${uuid()}` - const agent_resource_data = await resource.createcXMLScriptResource({ - name: agentResourceName, - contents: cXMLScriptAgentContent, + let agentPage = {} as CustomPage + let customerPage = {} as CustomPage + let agentResourceData = {} as Resource + let customerResourceData = {} as Resource + let allowedAddresses = [] as string[] + let expectInitialEventsForAgent: Promise< + [[boolean, boolean, boolean], ...boolean[]] + > + let expectInitialEventsForCustomer: Promise< + [[boolean, boolean, boolean], ...boolean[]] + > + let customerFinalEvents: Promise<[boolean, ...boolean[]]> + let agentFinalEvents: Promise<[boolean, ...boolean[]]> + + await test.step('setup agent page and resource', async () => { + agentPage = await createCustomPage({ name: '[agent_page]' }) + await agentPage.goto(SERVER_URL) + + const agentResourceName = `e2e_${uuid()}` + agentResourceData = await resource.createcXMLScriptResource({ + name: agentResourceName, + contents: cXMLScriptAgentContent, + }) + + expect( + agentResourceData.cxml_script?.id, + 'agent cXML script should be created' + ).toBeDefined() + + await createCFClient(agentPage) + + await dialAddress(agentPage, { + address: `/public/${agentResourceName}`, + shouldWaitForJoin: false, + shouldStartCall: false, + }) }) - expect(agent_resource_data.cxml_script?.id).toBeDefined() + await test.step('start agent call', async () => { + expectInitialEventsForAgent = expectCFInitialEvents(agentPage, []) - await createCFClient(agent_page) + await expectPageEvalToPass(agentPage, { + evaluateFn: async () => { + const call = window._callObj - await dialAddress(agent_page, { - address: `/public/${agentResourceName}`, // or /public/? - shouldWaitForJoin: false, - shouldStartCall: false, - }) + if (!call) { + throw new Error('Agent call object not found') + } - const expectInitialEventsForAgent = expectCFInitialEvents(agent_page, []) - await agent_page.evaluate(async () => { - // @ts-expect-error - const call = window._callObj + await call.start() + return true + }, + assertionFn: (result) => { + expect(result, 'agent call should start successfully').toBe(true) + }, + message: 'expect agent call to start', + }) - await call.start() + await expectInitialEventsForAgent }) - console.log('Address dialled by Agent...') - expectInitialEventsForAgent - console.log('After CF Initial events for agent...') - - console.log('--------- creating customer ------------------') - // Customer - const customer_page = await createCustomPage({ name: '[customer_page]' }) - await customer_page.goto(SERVER_URL) + await test.step('setup customer page and resource', async () => { + customerPage = await createCustomPage({ name: '[customer_page]' }) + await customerPage.goto(SERVER_URL) - const customerResourceName = `e2e_${uuid()}` - const customer_resource_data = await resource.createcXMLScriptResource({ - name: customerResourceName, - contents: cXMLScriptCustomerContent, + const customerResourceName = `e2e_${uuid()}` + customerResourceData = await resource.createcXMLScriptResource({ + name: customerResourceName, + contents: cXMLScriptCustomerContent, + }) + + expect( + customerResourceData.id, + 'customer resource should be created' + ).toBeDefined() + + const resourceAddresses = await getResourceAddresses( + customerResourceData.id + ) + allowedAddresses = resourceAddresses.data.map( + (address: { id: string }) => address.id ?? '' + ) + + expect( + allowedAddresses.length, + 'should have allowed addresses for customer' + ).toBeGreaterThan(0) + + await createGuestCFClient(customerPage, { + allowed_addresses: allowedAddresses, + }) + + await dialAddress(customerPage, { + address: `/public/${customerResourceName}`, + shouldWaitForJoin: false, + shouldStartCall: false, + }) }) - expect(customer_resource_data.id).toBeDefined() - const resource_addresses = await getResourceAddresses( - customer_resource_data.id - ) - const allowed_addresses: string[] = resource_addresses.data.map( - (address: { id: any }) => address.id ?? '' - ) + await test.step('start customer call', async () => { + // Let the Agent wait a little before the Customer joins + await customerPage.waitForTimeout(2000) - console.log('Allowed addresses: ', allowed_addresses, ' <---------------') + expectInitialEventsForCustomer = expectCFInitialEvents(customerPage, []) - await createGuestCFClient(customer_page, { - allowed_addresses: allowed_addresses, - }) + await expectPageEvalToPass(customerPage, { + evaluateFn: async () => { + const call = window._callObj - await dialAddress(customer_page, { - address: `/public/${customerResourceName}`, // or /public/? - shouldWaitForJoin: false, - shouldStartCall: false, - }) - - // Let the Agent wait a little before the Customer joins - await customer_page.waitForTimeout(2000) + if (!call) { + throw new Error('Customer call object not found') + } - const expectInitialEventsForCustomer = expectCFInitialEvents( - customer_page, - [] - ) - await customer_page.evaluate(async () => { - // @ts-expect-error - const call = window._callObj + await call.start() + return true + }, + assertionFn: (result) => { + expect(result, 'customer call should start successfully').toBe(true) + }, + message: 'expect customer call to start', + }) - await call.start() + await expectInitialEventsForCustomer }) - await expectInitialEventsForCustomer - - // 5 seconds' call - await customer_page.waitForTimeout(5000) - console.log('Expect to have received audio...') - await expectTotalAudioEnergyToBeGreaterThan(agent_page, 0.15) - await expectTotalAudioEnergyToBeGreaterThan(customer_page, 0.15) + await test.step('verify audio communication', async () => { + // Wait for 5 seconds of call time + await customerPage.waitForTimeout(5000) - // Attach final listeners - const customerFinalEvents = expectCFFinalEvents(customer_page) - const agentFinalEvents = expectCFFinalEvents(agent_page) - - console.log('Test done - hanging up customer') - - await customer_page.evaluate(async () => { - // @ts-expect-error - const call = window._callObj - - await call.hangup() + await expectTotalAudioEnergyToBeGreaterThan(agentPage, 0.15) + await expectTotalAudioEnergyToBeGreaterThan(customerPage, 0.15) }) - console.log('Test done - hanging up agent') - - await agent_page.evaluate(async () => { - // @ts-expect-error - const call = window._callObj - - await call.hangup() + await test.step('cleanup calls', async () => { + // Attach final listeners + customerFinalEvents = expectCFFinalEvents(customerPage) + agentFinalEvents = expectCFFinalEvents(agentPage) + + // Hangup customer + await expectPageEvalToPass(customerPage, { + evaluateFn: async () => { + const call = window._callObj + + if (!call) { + throw new Error('Customer call object not found') + } + + await call.hangup() + return true + }, + assertionFn: (result) => { + expect(result, 'customer call should hangup successfully').toBe(true) + }, + message: 'expect customer call to hangup', + }) + + // Hangup agent + await expectPageEvalToPass(agentPage, { + evaluateFn: async () => { + const call = window._callObj + + if (!call) { + throw new Error('Agent call object not found') + } + + await call.hangup() + return true + }, + assertionFn: (result) => { + expect(result, 'agent call should hangup successfully').toBe(true) + }, + message: 'expect agent call to hangup', + }) + + await Promise.all([customerFinalEvents, agentFinalEvents]) }) - - await Promise.all([customerFinalEvents, agentFinalEvents]) - - console.log('Test done -', agent_customer_static_scripts_desc) }) }) @@ -153,123 +220,186 @@ test.describe(agent_customer_external_url_desc, () => { primary_request_url: external_url_for_cxml, } - // const test_uuid = `${uuid()}` - test('agent and customer should dial an address linked to a cXML script with external URL and expect to join a Conference', async ({ createCustomPage, resource, }) => { - // Agent - const agent_page = await createCustomPage({ name: '[agent_page]' }) - await agent_page.goto(SERVER_URL) - - const agentResourceName = `e2e_${uuid()}` - const agent_resource_data = await resource.createcXMLExternalURLResource({ - name: agentResourceName, - contents: cXMLExternalURLAgent, + let agentPage = {} as CustomPage + let customerPage = {} as CustomPage + let agentResourceData = {} as Resource + let customerResourceData = {} as Resource + let allowedAddresses = [] as string[] + let expectInitialEventsForAgent: Promise< + [[boolean, boolean, boolean], ...boolean[]] + > + let expectInitialEventsForCustomer: Promise< + [[boolean, boolean, boolean], ...boolean[]] + > + let customerFinalEvents: Promise<[boolean, ...boolean[]]> + let agentFinalEvents: Promise<[boolean, ...boolean[]]> + + await test.step('setup agent page and external URL resource', async () => { + agentPage = await createCustomPage({ name: '[agent_page]' }) + await agentPage.goto(SERVER_URL) + + const agentResourceName = `e2e_${uuid()}` + agentResourceData = await resource.createcXMLExternalURLResource({ + name: agentResourceName, + contents: cXMLExternalURLAgent, + }) + + expect( + agentResourceData.cxml_webhook?.id, + 'agent cXML webhook should be created' + ).toBeDefined() + + await createCFClient(agentPage) + + await dialAddress(agentPage, { + address: `/public/${agentResourceName}`, + shouldWaitForJoin: false, + shouldStartCall: false, + }) }) - expect(agent_resource_data.cxml_webhook?.id).toBeDefined() + await test.step('start agent call', async () => { + expectInitialEventsForAgent = expectCFInitialEvents(agentPage, []) - await createCFClient(agent_page) + await expectPageEvalToPass(agentPage, { + evaluateFn: async () => { + const call = window._callObj - await dialAddress(agent_page, { - address: `/public/${agentResourceName}`, // or /public/? - shouldWaitForJoin: false, - shouldStartCall: false, - }) + if (!call) { + throw new Error('Agent call object not found') + } - const expectInitialEventsForAgent = expectCFInitialEvents(agent_page, []) - await agent_page.evaluate(async () => { - // @ts-expect-error - const call = window._callObj + await call.start() + return true + }, + assertionFn: (result) => { + expect(result, 'agent call should start successfully').toBe(true) + }, + message: 'expect agent call to start', + }) - await call.start() + await expectInitialEventsForAgent }) - console.log('Address dialled by Agent...') - expectInitialEventsForAgent - console.log('After CF Initial events for agent...') + await test.step('setup customer page and external URL resource', async () => { + customerPage = await createCustomPage({ name: '[customer_page]' }) + await customerPage.goto(SERVER_URL) - console.log('--------- creating customer ------------------') - // Customer - const customer_page = await createCustomPage({ name: '[customer_page]' }) - await customer_page.goto(SERVER_URL) - - const customerResourceName = `e2e_${uuid()}` - const customer_resource_data = await resource.createcXMLExternalURLResource( - { + const customerResourceName = `e2e_${uuid()}` + customerResourceData = await resource.createcXMLExternalURLResource({ name: customerResourceName, contents: cXMLExternalURLCustomer, - } - ) - - expect(customer_resource_data.id).toBeDefined() - const resource_addresses = await getResourceAddresses( - customer_resource_data.id - ) - const allowed_addresses: string[] = resource_addresses.data.map( - (address: { id: any }) => address.id ?? '' - ) - - console.log('Allowed addresses: ', allowed_addresses, ' <---------------') - - await createGuestCFClient(customer_page, { - allowed_addresses: allowed_addresses, - }) - - await dialAddress(customer_page, { - address: `/public/${customerResourceName}`, // or /public/? - shouldWaitForJoin: false, - shouldStartCall: false, + }) + + expect( + customerResourceData.id, + 'customer external URL resource should be created' + ).toBeDefined() + + const resourceAddresses = await getResourceAddresses( + customerResourceData.id + ) + allowedAddresses = resourceAddresses.data.map( + (address: { id: string }) => address.id ?? '' + ) + + expect( + allowedAddresses.length, + 'should have allowed addresses for customer' + ).toBeGreaterThan(0) + + await createGuestCFClient(customerPage, { + allowed_addresses: allowedAddresses, + }) + + await dialAddress(customerPage, { + address: `/public/${customerResourceName}`, + shouldWaitForJoin: false, + shouldStartCall: false, + }) }) - // Let the Agent wait a little before the Customer joins - await new Promise((r) => setTimeout(r, 2000)) - - const expectInitialEventsForCustomer = expectCFInitialEvents( - customer_page, - [] - ) - await customer_page.evaluate(async () => { - // @ts-expect-error - const call = window._callObj - - await call.start() - }) - await expectInitialEventsForCustomer + await test.step('start customer call', async () => { + // Let the Agent wait a little before the Customer joins + await customerPage.waitForTimeout(2000) - // 5 seconds' call - await customer_page.waitForTimeout(5000) + expectInitialEventsForCustomer = expectCFInitialEvents(customerPage, []) - console.log('Expect to have received audio...') - await expectTotalAudioEnergyToBeGreaterThan(agent_page, 0.15) - await expectTotalAudioEnergyToBeGreaterThan(customer_page, 0.15) + await expectPageEvalToPass(customerPage, { + evaluateFn: async () => { + const call = window._callObj - // Attach final listeners - const customerFinalEvents = expectCFFinalEvents(customer_page) - const agentFinalEvents = expectCFFinalEvents(agent_page) + if (!call) { + throw new Error('Customer call object not found') + } - console.log('Test done - hanging up customer') + await call.start() + return true + }, + assertionFn: (result) => { + expect(result, 'customer call should start successfully').toBe(true) + }, + message: 'expect customer call to start', + }) - await customer_page.evaluate(async () => { - // @ts-expect-error - const call = window._callObj - - await call.hangup() + await expectInitialEventsForCustomer }) - console.log('Test done - hanging up agent') - - await agent_page.evaluate(async () => { - // @ts-expect-error - const call = window._callObj + await test.step('verify audio communication', async () => { + // Wait for 5 seconds of call time + await customerPage.waitForTimeout(5000) - await call.hangup() + await expectTotalAudioEnergyToBeGreaterThan(agentPage, 0.15) + await expectTotalAudioEnergyToBeGreaterThan(customerPage, 0.15) }) - await Promise.all([customerFinalEvents, agentFinalEvents]) - console.log('Test done -', agent_customer_external_url_desc) + await test.step('cleanup calls', async () => { + // Attach final listeners + customerFinalEvents = expectCFFinalEvents(customerPage) + agentFinalEvents = expectCFFinalEvents(agentPage) + + // Hangup customer + await expectPageEvalToPass(customerPage, { + evaluateFn: async () => { + const call = window._callObj + + if (!call) { + throw new Error('Customer call object not found') + } + + await call.hangup() + return true + }, + assertionFn: (result) => { + expect(result, 'customer call should hangup successfully').toBe(true) + }, + message: 'expect customer call to hangup', + }) + + // Hangup agent + await expectPageEvalToPass(agentPage, { + evaluateFn: async () => { + const call = window._callObj + + if (!call) { + throw new Error('Agent call object not found') + } + + await call.hangup() + return true + }, + assertionFn: (result) => { + expect(result, 'agent call should hangup successfully').toBe(true) + }, + message: 'expect agent call to hangup', + }) + + await Promise.all([customerFinalEvents, agentFinalEvents]) + }) }) }) diff --git a/internal/e2e-client/tests/callfabric/audioFlags.spec.ts b/internal/e2e-client/tests/callfabric/audioFlags.spec.ts index 118de36b2..c8e325f62 100644 --- a/internal/e2e-client/tests/callfabric/audioFlags.spec.ts +++ b/internal/e2e-client/tests/callfabric/audioFlags.spec.ts @@ -1,11 +1,12 @@ import { uuid } from '@signalwire/core' -import { CallSession, CallJoinedEventParams } from '@signalwire/client' -import { test, expect } from '../../fixtures' +import { CallJoinedEventParams } from '@signalwire/client' +import { test, expect, CustomPage } from '../../fixtures' import { SERVER_URL, createCFClient, dialAddress, expectMCUVisible, + expectPageEvalToPass, } from '../../utils' test.describe('CallCall Audio Flags', () => { @@ -13,133 +14,287 @@ test.describe('CallCall Audio Flags', () => { createCustomPage, resource, }) => { - const page = await createCustomPage({ name: '[page]' }) - await page.goto(SERVER_URL) + let page = {} as CustomPage + let roomName = '' + let roomSessionBefore = {} as CallJoinedEventParams + let roomSessionAfter = {} as CallJoinedEventParams + let memberId = '' - const roomName = `e2e_${uuid()}` - await resource.createVideoRoomResource(roomName) + await test.step('setup page, room and initial call', async () => { + page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) - await createCFClient(page) + roomName = `e2e_${uuid()}` + await resource.createVideoRoomResource(roomName) - // Dial an address and join a video room - const roomSessionBefore: CallJoinedEventParams = await dialAddress(page, { - address: `/public/${roomName}?channel=video`, + await createCFClient(page) + + // Dial an address and join a video room + roomSessionBefore = await dialAddress(page, { + address: `/public/${roomName}?channel=video`, + }) + + expect( + roomSessionBefore.room_session, + 'room session should be defined' + ).toBeDefined() + await expectMCUVisible(page) + + memberId = roomSessionBefore.member_id + expect(memberId, 'member ID should be defined').toBeDefined() }) - expect(roomSessionBefore.room_session).toBeDefined() - await expectMCUVisible(page) - const memberId = roomSessionBefore.member_id - // --------------- Set audio flags (self) --------------- await test.step('change audio flags', async () => { - await page.evaluate(async (memberId) => { - // @ts-expect-error - const callObj: CallSession = window._callObj - - const memberUpdatedEvent = new Promise((res) => { - callObj.on('member.updated', (params) => { - if ( - params.member.member_id === memberId && - params.member.updated.includes('noise_suppression') && - params.member.updated.includes('echo_cancellation') && - params.member.updated.includes('auto_gain') && - params.member.auto_gain === false && - params.member.echo_cancellation === false && - params.member.noise_suppression === false - ) { - res(true) + // Set up all event listeners first + const memberUpdatedEvent = expectPageEvalToPass(page, { + evaluateArgs: { memberId }, + evaluateFn: (params) => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') } + + callObj.on('member.updated', (eventParams) => { + if ( + eventParams.member.member_id === params.memberId && + eventParams.member.updated.includes('noise_suppression') && + eventParams.member.updated.includes('echo_cancellation') && + eventParams.member.updated.includes('auto_gain') && + eventParams.member.auto_gain === false && + eventParams.member.echo_cancellation === false && + eventParams.member.noise_suppression === false + ) { + resolve(true) + } + }) }) - }) - const memberUpdatedAutoGainEvent = new Promise((res) => { - callObj.on('member.updated.autoGain', (params) => { - if ( - params.member.member_id === memberId && - params.member.updated.includes('auto_gain') && - params.member.auto_gain === false - ) { - res(true) + }, + assertionFn: (result) => { + expect(result, 'member updated event should resolve').toBe(true) + }, + message: 'expect member updated event', + }) + + const memberUpdatedAutoGainEvent = expectPageEvalToPass(page, { + evaluateArgs: { memberId }, + evaluateFn: (params) => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') } + + callObj.on('member.updated.autoGain', (eventParams) => { + if ( + eventParams.member.member_id === params.memberId && + eventParams.member.updated.includes('auto_gain') && + eventParams.member.auto_gain === false + ) { + resolve(true) + } + }) }) - }) - const memberUpdatedEchoCancellationEvent = new Promise((res) => { - callObj.on('member.updated.echoCancellation', (params) => { - if ( - params.member.member_id === memberId && - params.member.updated.includes('echo_cancellation') && - params.member.echo_cancellation === false - ) { - res(true) + }, + assertionFn: (result) => { + expect(result, 'auto gain updated event should resolve').toBe(true) + }, + message: 'expect member updated auto gain event', + }) + + const memberUpdatedEchoCancellationEvent = expectPageEvalToPass(page, { + evaluateArgs: { memberId }, + evaluateFn: (params) => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') } + + callObj.on('member.updated.echoCancellation', (eventParams) => { + if ( + eventParams.member.member_id === params.memberId && + eventParams.member.updated.includes('echo_cancellation') && + eventParams.member.echo_cancellation === false + ) { + resolve(true) + } + }) }) - }) - const memberUpdatedNoiseSuppressionEvent = new Promise((res) => { - callObj.on('member.updated.noiseSuppression', (params) => { - if ( - params.member.member_id === memberId && - params.member.updated.includes('noise_suppression') && - params.member.noise_suppression === false - ) { - res(true) + }, + assertionFn: (result) => { + expect(result, 'echo cancellation updated event should resolve').toBe( + true + ) + }, + message: 'expect member updated echo cancellation event', + }) + + const memberUpdatedNoiseSuppressionEvent = expectPageEvalToPass(page, { + evaluateArgs: { memberId }, + evaluateFn: (params) => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') } + + callObj.on('member.updated.noiseSuppression', (eventParams) => { + if ( + eventParams.member.member_id === params.memberId && + eventParams.member.updated.includes('noise_suppression') && + eventParams.member.noise_suppression === false + ) { + resolve(true) + } + }) }) - }) - - await callObj.setAudioFlags({ - autoGain: false, - echoCancellation: false, - noiseSuppression: false, - }) - - return Promise.all([ - memberUpdatedEvent, - memberUpdatedAutoGainEvent, - memberUpdatedEchoCancellationEvent, - memberUpdatedNoiseSuppressionEvent, - ]) - }, memberId) + }, + assertionFn: (result) => { + expect(result, 'noise suppression updated event should resolve').toBe( + true + ) + }, + message: 'expect member updated noise suppression event', + }) + + // Trigger the audio flags change + await expectPageEvalToPass(page, { + evaluateFn: async () => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + await callObj.setAudioFlags({ + autoGain: false, + echoCancellation: false, + noiseSuppression: false, + }) + return true + }, + assertionFn: (result) => { + expect(result, 'audio flags should be set successfully').toBe(true) + }, + message: 'expect audio flags to be set', + }) + + // Wait for all events to complete + await memberUpdatedEvent + await memberUpdatedAutoGainEvent + await memberUpdatedEchoCancellationEvent + await memberUpdatedNoiseSuppressionEvent }) - const roomSessionAfter = - await test.step('reload page and reattach', async () => { - await page.reload({ waitUntil: 'domcontentloaded' }) - await createCFClient(page) + await test.step('reload page and reattach', async () => { + await page.reload({ waitUntil: 'domcontentloaded' }) + await createCFClient(page) - // Reattach to an address to join the same call session - const roomSession: CallJoinedEventParams = await page.evaluate( - async ({ roomName }) => { - return new Promise(async (resolve, _reject) => { - const client = window._client! + // Set up the reattach call + await expectPageEvalToPass(page, { + evaluateArgs: { roomName }, + evaluateFn: async (params) => { + const client = window._client - const call = await client.reattach({ - to: `/public/${roomName}?channel=video`, - rootElement: document.getElementById('rootElement'), - }) + if (!client) { + throw new Error('Client not found') + } - call.on('call.joined', resolve) + const call = await client.reattach({ + to: `/public/${params.roomName}?channel=video`, + rootElement: document.getElementById('rootElement'), + }) - // @ts-expect-error - window._callObj = call - await call.start() - }) - }, - { roomName } - ) + window._callObj = call + return call.id + }, + assertionFn: (result) => { + expect( + result, + 'reattach call should be created successfully' + ).toBeDefined() + expect(typeof result, 'call id should be a string').toBe('string') + }, + message: 'expect reattach call to be created', + }) - return roomSession + // Set up listener for call.joined event + const callJoinedEvent = expectPageEvalToPass(page, { + evaluateFn: () => { + return new Promise((resolve) => { + const call = window._callObj + + if (!call) { + throw new Error('Call object not found') + } + + call.on('call.joined', (params) => resolve(params)) + }) + }, + assertionFn: (result) => { + expect(result, 'call joined event should be received').toBeDefined() + expect( + result.room_session, + 'call joined should include room session' + ).toBeDefined() + }, + message: 'expect call joined event', }) + // Start the reattach call + await expectPageEvalToPass(page, { + evaluateFn: async () => { + const call = window._callObj + + if (!call) { + throw new Error('Call object not found') + } + + await call.start() + return true + }, + assertionFn: (result) => { + expect(result, 'call should start successfully').toBe(true) + }, + message: 'expect reattach call to start', + }) + + // Wait for the call.joined event to complete + roomSessionAfter = await callJoinedEvent + }) + await test.step('assert room state', async () => { - expect(roomSessionAfter.room_session).toBeDefined() - expect(roomSessionAfter.call_id).toEqual(roomSessionBefore.call_id) + expect( + roomSessionAfter.room_session, + 'room session after reattach should be defined' + ).toBeDefined() + expect( + roomSessionAfter.call_id, + 'call ID should match original call' + ).toEqual(roomSessionBefore.call_id) const selfMember = roomSessionAfter.room_session.members.find( (member) => member.member_id === roomSessionAfter.member_id ) - expect(selfMember).toBeDefined() - expect(selfMember?.auto_gain).toBe(false) - expect(selfMember?.echo_cancellation).toBe(false) - expect(selfMember?.noise_suppression).toBe(false) + expect(selfMember, 'self member should be found').toBeDefined() + expect( + selfMember?.auto_gain, + 'auto gain should be false after reattach' + ).toBe(false) + expect( + selfMember?.echo_cancellation, + 'echo cancellation should be false after reattach' + ).toBe(false) + expect( + selfMember?.noise_suppression, + 'noise suppression should be false after reattach' + ).toBe(false) }) }) @@ -147,221 +302,492 @@ test.describe('CallCall Audio Flags', () => { createCustomPage, resource, }) => { - const pageOne = await createCustomPage({ name: '[pageOne]' }) - const pageTwo = await createCustomPage({ name: '[pageTwo]' }) - await pageOne.goto(SERVER_URL) - await pageTwo.goto(SERVER_URL) - - const roomName = `e2e_${uuid()}` - await resource.createVideoRoomResource(roomName) + let pageOne = {} as CustomPage + let pageTwo = {} as CustomPage + let roomName = '' + let roomSessionOne = {} as CallJoinedEventParams + let roomSessionTwo = {} as CallJoinedEventParams + let roomSessionTwoAfter = {} as CallJoinedEventParams + let memberOneId = '' + let memberTwoId = '' + + // Scoped variables for pageTwo event promises + let pageTwoMemberUpdatedEvent: Promise + let pageTwoMemberUpdatedAutoGainEvent: Promise + let pageTwoMemberUpdatedEchoCancellationEvent: Promise + let pageTwoMemberUpdatedNoiseSuppressionEvent: Promise + + await test.step('setup pages and room', async () => { + pageOne = await createCustomPage({ name: '[pageOne]' }) + pageTwo = await createCustomPage({ name: '[pageTwo]' }) + await pageOne.goto(SERVER_URL) + await pageTwo.goto(SERVER_URL) + + roomName = `e2e_${uuid()}` + await resource.createVideoRoomResource(roomName) + }) - await test.step('[pageOne] create client and join a room', async () => { + await test.step('[pageOne] create client and join room', async () => { await createCFClient(pageOne) - // Dial an address and join a video room - const roomSession: CallJoinedEventParams = await dialAddress(pageOne, { + + roomSessionOne = await dialAddress(pageOne, { address: `/public/${roomName}?channel=video`, }) - expect(roomSession.room_session).toBeDefined() - expect(roomSession.room_session.members).toBeDefined() - expect(roomSession.room_session.members).toHaveLength(1) + + expect( + roomSessionOne.room_session, + 'pageOne room session should be defined' + ).toBeDefined() + expect( + roomSessionOne.room_session.members, + 'pageOne should have members array' + ).toBeDefined() + expect( + roomSessionOne.room_session.members, + 'pageOne should have 1 member' + ).toHaveLength(1) await expectMCUVisible(pageOne) - return roomSession }) - const roomSessionTwo = - await test.step('[pageTwo] create client and join a room', async () => { - await createCFClient(pageTwo) - // Dial an address and join a video room - const roomSession: CallJoinedEventParams = await dialAddress(pageTwo, { - address: `/public/${roomName}?channel=video`, - }) - expect(roomSession.room_session).toBeDefined() - expect(roomSession.room_session.members).toBeDefined() - expect(roomSession.room_session.members).toHaveLength(2) - await expectMCUVisible(pageTwo) - return roomSession - }) + await test.step('[pageTwo] create client and join room', async () => { + await createCFClient(pageTwo) - const [_memberOneId, memberTwoId] = roomSessionTwo.room_session.members.map( - (member) => member.member_id - ) - - // --------------- Attach listeners on pageTwo --------------- - const waitForMemberUpdatedEvents = pageTwo.evaluate((memberId) => { - // @ts-expect-error - const callObj: CallSession = window._callObj - - const memberUpdatedEvent = new Promise((res) => { - callObj.on('member.updated', (params) => { - if ( - params.member.member_id === memberId && - params.member.updated.includes('noise_suppression') && - params.member.updated.includes('echo_cancellation') && - params.member.updated.includes('auto_gain') && - params.member.auto_gain === false && - params.member.echo_cancellation === false && - params.member.noise_suppression === false - ) { - res(true) - } - }) - }) - const memberUpdatedAutoGainEvent = new Promise((res) => { - callObj.on('member.updated.autoGain', (params) => { - if ( - params.member.member_id === memberId && - params.member.updated.includes('auto_gain') && - params.member.auto_gain === false - ) { - res(true) - } - }) - }) - const memberUpdatedEchoCancellationEvent = new Promise((res) => { - callObj.on('member.updated.echoCancellation', (params) => { - if ( - params.member.member_id === memberId && - params.member.updated.includes('echo_cancellation') && - params.member.echo_cancellation === false - ) { - res(true) - } - }) - }) - const memberUpdatedNoiseSuppressionEvent = new Promise((res) => { - callObj.on('member.updated.noiseSuppression', (params) => { - if ( - params.member.member_id === memberId && - params.member.updated.includes('noise_suppression') && - params.member.noise_suppression === false - ) { - res(true) - } - }) + roomSessionTwo = await dialAddress(pageTwo, { + address: `/public/${roomName}?channel=video`, }) - return Promise.all([ - memberUpdatedEvent, - memberUpdatedAutoGainEvent, - memberUpdatedEchoCancellationEvent, - memberUpdatedNoiseSuppressionEvent, - ]) - }, memberTwoId) + expect( + roomSessionTwo.room_session, + 'pageTwo room session should be defined' + ).toBeDefined() + expect( + roomSessionTwo.room_session.members, + 'pageTwo should have members array' + ).toBeDefined() + expect( + roomSessionTwo.room_session.members, + 'pageTwo should have 2 members' + ).toHaveLength(2) + await expectMCUVisible(pageTwo) + + const memberIds = roomSessionTwo.room_session.members.map( + (member) => member.member_id + ) + memberOneId = memberIds[0] + memberTwoId = memberIds[1] + + expect(memberOneId, 'member one ID should be defined').toBeDefined() + expect(memberTwoId, 'member two ID should be defined').toBeDefined() + }) + + await test.step('[pageTwo] setup event listeners for member updates', async () => { + // Set up listeners on pageTwo to detect when pageOne changes memberTwo's audio flags + pageTwoMemberUpdatedEvent = expectPageEvalToPass(pageTwo, { + evaluateArgs: { memberId: memberTwoId }, + evaluateFn: (params) => { + return new Promise((resolve) => { + const callObj = window._callObj - // --------------- Set audio flags (self) --------------- - await test.step('[pageOne] change audio flags for memberTwo', async () => { - await pageOne.evaluate(async (memberId) => { - // @ts-expect-error - const callObj: CallSession = window._callObj - - const memberUpdatedEvent = new Promise((res) => { - callObj.on('member.updated', (params) => { - if ( - params.member.member_id === memberId && - params.member.updated.includes('noise_suppression') && - params.member.updated.includes('echo_cancellation') && - params.member.updated.includes('auto_gain') && - params.member.auto_gain === false && - params.member.echo_cancellation === false && - params.member.noise_suppression === false - ) { - res(true) + if (!callObj) { + throw new Error('Call object not found') } + + callObj.on('member.updated', (eventParams) => { + if ( + eventParams.member.member_id === params.memberId && + eventParams.member.updated.includes('noise_suppression') && + eventParams.member.updated.includes('echo_cancellation') && + eventParams.member.updated.includes('auto_gain') && + eventParams.member.auto_gain === false && + eventParams.member.echo_cancellation === false && + eventParams.member.noise_suppression === false + ) { + resolve(true) + } + }) }) - }) - const memberUpdatedAutoGainEvent = new Promise((res) => { - callObj.on('member.updated.autoGain', (params) => { - if ( - params.member.member_id === memberId && - params.member.updated.includes('auto_gain') && - params.member.auto_gain === false - ) { - res(true) + }, + assertionFn: (result) => { + expect(result, 'pageTwo member updated event should resolve').toBe( + true + ) + }, + message: 'expect pageTwo member updated event', + }) + + pageTwoMemberUpdatedAutoGainEvent = expectPageEvalToPass(pageTwo, { + evaluateArgs: { memberId: memberTwoId }, + evaluateFn: (params) => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') } + + callObj.on('member.updated.autoGain', (eventParams) => { + if ( + eventParams.member.member_id === params.memberId && + eventParams.member.updated.includes('auto_gain') && + eventParams.member.auto_gain === false + ) { + resolve(true) + } + }) }) - }) - const memberUpdatedEchoCancellationEvent = new Promise((res) => { - callObj.on('member.updated.echoCancellation', (params) => { - if ( - params.member.member_id === memberId && - params.member.updated.includes('echo_cancellation') && - params.member.echo_cancellation === false - ) { - res(true) + }, + assertionFn: (result) => { + expect(result, 'pageTwo auto gain updated event should resolve').toBe( + true + ) + }, + message: 'expect pageTwo member updated auto gain event', + }) + + pageTwoMemberUpdatedEchoCancellationEvent = expectPageEvalToPass( + pageTwo, + { + evaluateArgs: { memberId: memberTwoId }, + evaluateFn: (params) => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + callObj.on('member.updated.echoCancellation', (eventParams) => { + if ( + eventParams.member.member_id === params.memberId && + eventParams.member.updated.includes('echo_cancellation') && + eventParams.member.echo_cancellation === false + ) { + resolve(true) + } + }) + }) + }, + assertionFn: (result) => { + expect( + result, + 'pageTwo echo cancellation updated event should resolve' + ).toBe(true) + }, + message: 'expect pageTwo member updated echo cancellation event', + } + ) + + pageTwoMemberUpdatedNoiseSuppressionEvent = expectPageEvalToPass( + pageTwo, + { + evaluateArgs: { memberId: memberTwoId }, + evaluateFn: (params) => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + callObj.on('member.updated.noiseSuppression', (eventParams) => { + if ( + eventParams.member.member_id === params.memberId && + eventParams.member.updated.includes('noise_suppression') && + eventParams.member.noise_suppression === false + ) { + resolve(true) + } + }) + }) + }, + assertionFn: (result) => { + expect( + result, + 'pageTwo noise suppression updated event should resolve' + ).toBe(true) + }, + message: 'expect pageTwo member updated noise suppression event', + } + ) + }) + + await test.step('[pageOne] change audio flags for memberTwo', async () => { + // Set up listeners on pageOne for the audio flag changes it will make + const pageOneMemberUpdatedEvent = expectPageEvalToPass(pageOne, { + evaluateArgs: { memberId: memberTwoId }, + evaluateFn: (params) => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') } + + callObj.on('member.updated', (eventParams) => { + if ( + eventParams.member.member_id === params.memberId && + eventParams.member.updated.includes('noise_suppression') && + eventParams.member.updated.includes('echo_cancellation') && + eventParams.member.updated.includes('auto_gain') && + eventParams.member.auto_gain === false && + eventParams.member.echo_cancellation === false && + eventParams.member.noise_suppression === false + ) { + resolve(true) + } + }) }) - }) - const memberUpdatedNoiseSuppressionEvent = new Promise((res) => { - callObj.on('member.updated.noiseSuppression', (params) => { - if ( - params.member.member_id === memberId && - params.member.updated.includes('noise_suppression') && - params.member.noise_suppression === false - ) { - res(true) + }, + assertionFn: (result) => { + expect(result, 'pageOne member updated event should resolve').toBe( + true + ) + }, + message: 'expect pageOne member updated event', + }) + + const pageOneMemberUpdatedAutoGainEvent = expectPageEvalToPass(pageOne, { + evaluateArgs: { memberId: memberTwoId }, + evaluateFn: (params) => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') } + + callObj.on('member.updated.autoGain', (eventParams) => { + if ( + eventParams.member.member_id === params.memberId && + eventParams.member.updated.includes('auto_gain') && + eventParams.member.auto_gain === false + ) { + resolve(true) + } + }) + }) + }, + assertionFn: (result) => { + expect(result, 'pageOne auto gain updated event should resolve').toBe( + true + ) + }, + message: 'expect pageOne member updated auto gain event', + }) + + const pageOneMemberUpdatedEchoCancellationEvent = expectPageEvalToPass( + pageOne, + { + evaluateArgs: { memberId: memberTwoId }, + evaluateFn: (params) => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + callObj.on('member.updated.echoCancellation', (eventParams) => { + if ( + eventParams.member.member_id === params.memberId && + eventParams.member.updated.includes('echo_cancellation') && + eventParams.member.echo_cancellation === false + ) { + resolve(true) + } + }) + }) + }, + assertionFn: (result) => { + expect( + result, + 'pageOne echo cancellation updated event should resolve' + ).toBe(true) + }, + message: 'expect pageOne member updated echo cancellation event', + } + ) + + const pageOneMemberUpdatedNoiseSuppressionEvent = expectPageEvalToPass( + pageOne, + { + evaluateArgs: { memberId: memberTwoId }, + evaluateFn: (params) => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + callObj.on('member.updated.noiseSuppression', (eventParams) => { + if ( + eventParams.member.member_id === params.memberId && + eventParams.member.updated.includes('noise_suppression') && + eventParams.member.noise_suppression === false + ) { + resolve(true) + } + }) + }) + }, + assertionFn: (result) => { + expect( + result, + 'pageOne noise suppression updated event should resolve' + ).toBe(true) + }, + message: 'expect pageOne member updated noise suppression event', + } + ) + + // Trigger the audio flags change for memberTwo + await expectPageEvalToPass(pageOne, { + evaluateArgs: { memberId: memberTwoId }, + evaluateFn: async (params) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + await callObj.setAudioFlags({ + autoGain: false, + echoCancellation: false, + noiseSuppression: false, + memberId: params.memberId, }) - }) - - await callObj.setAudioFlags({ - autoGain: false, - echoCancellation: false, - noiseSuppression: false, - memberId, - }) - - return Promise.all([ - memberUpdatedEvent, - memberUpdatedAutoGainEvent, - memberUpdatedEchoCancellationEvent, - memberUpdatedNoiseSuppressionEvent, - ]) - }, memberTwoId) + return true + }, + assertionFn: (result) => { + expect( + result, + 'audio flags should be set successfully for memberTwo' + ).toBe(true) + }, + message: 'expect audio flags to be set for memberTwo', + }) + + // Wait for all pageOne events to complete + await pageOneMemberUpdatedEvent + await pageOneMemberUpdatedAutoGainEvent + await pageOneMemberUpdatedEchoCancellationEvent + await pageOneMemberUpdatedNoiseSuppressionEvent + + // Wait for all pageTwo events to complete + await pageTwoMemberUpdatedEvent + await pageTwoMemberUpdatedAutoGainEvent + await pageTwoMemberUpdatedEchoCancellationEvent + await pageTwoMemberUpdatedNoiseSuppressionEvent }) - await waitForMemberUpdatedEvents + await test.step('[pageTwo] reload page and reattach', async () => { + await pageTwo.reload({ waitUntil: 'domcontentloaded' }) + await createCFClient(pageTwo) - const roomSessionTwoAfter = - await test.step('[pageTwo] reload page and reattach', async () => { - await pageTwo.reload({ waitUntil: 'domcontentloaded' }) - await createCFClient(pageTwo) + // Set up the reattach call + await expectPageEvalToPass(pageTwo, { + evaluateArgs: { roomName }, + evaluateFn: async (params) => { + const client = window._client - // Reattach to an address to join the same call session - const roomSession: CallJoinedEventParams = await pageTwo.evaluate( - async ({ roomName }) => { - return new Promise(async (resolve, _reject) => { - const client = window._client! + if (!client) { + throw new Error('Client not found') + } - const call = await client.reattach({ - to: `/public/${roomName}?channel=video`, - rootElement: document.getElementById('rootElement'), - }) + const call = await client.reattach({ + to: `/public/${params.roomName}?channel=video`, + rootElement: document.getElementById('rootElement'), + }) + + window._callObj = call + return call.id + }, + assertionFn: (result) => { + expect( + result, + 'pageTwo reattach call should be created successfully' + ).toBeDefined() + expect(typeof result, 'call id should be a string').toBe('string') + }, + message: 'expect pageTwo reattach call to be created', + }) - call.on('call.joined', resolve) + // Set up listener for call.joined event + const callJoinedEvent = expectPageEvalToPass(pageTwo, { + evaluateFn: () => { + return new Promise((resolve) => { + const call = window._callObj - // @ts-expect-error - window._callObj = call - await call.start() - }) - }, - { roomName } - ) + if (!call) { + throw new Error('Call object not found') + } - return roomSession + call.on('call.joined', (params) => resolve(params)) + }) + }, + assertionFn: (result) => { + expect( + result, + 'pageTwo call joined event should be received' + ).toBeDefined() + expect( + result.room_session, + 'pageTwo call joined should include room session' + ).toBeDefined() + }, + message: 'expect pageTwo call joined event', + }) + + // Start the reattach call + await expectPageEvalToPass(pageTwo, { + evaluateFn: async () => { + const call = window._callObj + + if (!call) { + throw new Error('Call object not found') + } + + await call.start() + return true + }, + assertionFn: (result) => { + expect(result, 'pageTwo call should start successfully').toBe(true) + }, + message: 'expect pageTwo reattach call to start', }) + // Wait for the call.joined event to complete + roomSessionTwoAfter = await callJoinedEvent + }) + await test.step('[pageTwo] assert room state', async () => { - expect(roomSessionTwoAfter.room_session).toBeDefined() - expect(roomSessionTwoAfter.call_id).toEqual(roomSessionTwo.call_id) + expect( + roomSessionTwoAfter.room_session, + 'pageTwo room session after reattach should be defined' + ).toBeDefined() + expect( + roomSessionTwoAfter.call_id, + 'pageTwo call ID should match original call' + ).toEqual(roomSessionTwo.call_id) const selfMember = roomSessionTwoAfter.room_session.members.find( (member) => member.member_id === roomSessionTwoAfter.member_id ) - expect(selfMember).toBeDefined() - expect(selfMember?.auto_gain).toBe(false) - expect(selfMember?.echo_cancellation).toBe(false) - expect(selfMember?.noise_suppression).toBe(false) + expect(selfMember, 'pageTwo self member should be found').toBeDefined() + expect( + selfMember?.auto_gain, + 'pageTwo auto gain should be false after reattach' + ).toBe(false) + expect( + selfMember?.echo_cancellation, + 'pageTwo echo cancellation should be false after reattach' + ).toBe(false) + expect( + selfMember?.noise_suppression, + 'pageTwo noise suppression should be false after reattach' + ).toBe(false) }) }) }) diff --git a/internal/e2e-client/tests/callfabric/cleanup.spec.ts b/internal/e2e-client/tests/callfabric/cleanup.spec.ts index 112026d47..0a4a42821 100644 --- a/internal/e2e-client/tests/callfabric/cleanup.spec.ts +++ b/internal/e2e-client/tests/callfabric/cleanup.spec.ts @@ -1,90 +1,196 @@ import { uuid } from '@signalwire/core' -import { test, expect } from '../../fixtures' +import { test, expect, CustomPage } from '../../fixtures' import { createCFClient, dialAddress, disconnectClient, + expectPageEvalToPass, leaveRoom, SERVER_URL, } from '../../utils' +import { CallSession, SignalWireContract } from '@signalwire/client' +import { WSClientContract } from 'packages/client/src/unified/interfaces/wsClient' + +interface WindowWithRunningWorkers extends Window { + _runningWorkers: any[] + _callObj: CallSession & { + eventNames: () => string[] + _runningWorkers: any[] + } +} + +interface SignalWireClient extends SignalWireContract { + __wsClient: WSClientContract & { + sessionEventNames: () => string[] + _runningWorkers: any[] + } +} + +interface Watchers { + clientListenersLength?: number + clientWorkersLength?: number + callListeners?: number + callWorkersLength?: number + globalWorkersLength?: number +} test.describe('Clean up', () => { - test('it should create a webscoket client', async ({ createCustomPage }) => { - const page = await createCustomPage({ name: '[page]' }) - await page.goto(SERVER_URL) - + test('it should create a websocket client', async ({ createCustomPage }) => { + let page = {} as CustomPage let websocketUrl: string | null = null let websocketClosed = false + let waitForWebSocketClose: Promise + + await test.step('setup page and websocket listeners', async () => { + page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) - // A promise to wait for the WebSocket close event - const waitForWebSocketClose = new Promise((resolve) => { - page.on('websocket', (ws) => { - websocketUrl = ws.url() + // Set up WebSocket monitoring + waitForWebSocketClose = new Promise((resolve) => { + page.on('websocket', (ws) => { + websocketUrl = ws.url() - ws.on('close', () => { - websocketClosed = true - resolve() + ws.on('close', () => { + websocketClosed = true + resolve() + }) }) }) + + expect(websocketUrl, 'websocket URL should initially be null').toBe(null) + expect(websocketClosed, 'websocket should initially be open').toBe(false) }) - expect(websocketUrl).toBe(null) + await test.step('create client', async () => { + await createCFClient(page) + }) - await createCFClient(page) + await test.step('disconnect client and verify websocket cleanup', async () => { + await disconnectClient(page) - await disconnectClient(page) + // Wait for websocket to close + await waitForWebSocketClose - await waitForWebSocketClose - expect(websocketUrl).toBeTruthy() - expect(websocketUrl).toContain('wss://') - expect(websocketClosed).toBeTruthy() + expect(websocketUrl, 'websocket URL should be captured').toBeTruthy() + expect(websocketUrl, 'websocket should use secure protocol').toContain( + 'wss://' + ) + expect( + websocketClosed, + 'websocket should be closed after disconnect' + ).toBeTruthy() + }) }) test('it should cleanup session emitter and workers', async ({ createCustomPage, }) => { - const page = await createCustomPage({ name: '[page]' }) - await page.goto(SERVER_URL) - - await createCFClient(page, { attachSagaMonitor: true }) - - await test.step('the client should have workers and listeners attached', async () => { - const watchers: Record = await page.evaluate(() => { - const client = window._client! - - return { - // @ts-expect-error - clientListenersLength: client.__wsClient.sessionEventNames().length, - // @ts-expect-error - clientWorkersLength: client.__wsClient._runningWorkers.length, - // @ts-expect-error - globalWorkersLength: window._runningWorkers.length, - } - }) + let page = {} as CustomPage + + await test.step('setup page and create client with saga monitor', async () => { + page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) - expect(watchers.clientWorkersLength).toBeGreaterThan(0) - expect(watchers.globalWorkersLength).toBeGreaterThan(0) + await createCFClient(page, { attachSagaMonitor: true }) }) - await disconnectClient(page) + await test.step('verify client has workers and listeners attached', async () => { + await expectPageEvalToPass(page, { + evaluateFn: () => { + const client = window._client as SignalWireClient + const windowWithWorkers = + window as unknown as WindowWithRunningWorkers - await test.step('the client should not have workers and listeners attached', async () => { - const watchers: Record = await page.evaluate(() => { - const client = window._client! + if (!client) { + throw new Error('Client not found') + } + + // Access internal properties for cleanup testing + const wsClient = (client as SignalWireClient).__wsClient + if (!wsClient) { + throw new Error('WebSocket client not found') + } - return { - // @ts-expect-error - clientListenersLength: client.__wsClient.sessionEventNames().length, - // @ts-expect-error - clientWorkersLength: client.__wsClient._runningWorkers.length, - // @ts-expect-error - globalWorkersLength: window._runningWorkers.length, - } + const runningWorkers = windowWithWorkers._runningWorkers + if (!runningWorkers) { + throw new Error('Running workers not found') + } + + return { + clientListenersLength: wsClient.sessionEventNames().length, + clientWorkersLength: wsClient._runningWorkers.length, + globalWorkersLength: runningWorkers.length, + } satisfies Watchers + }, + assertionFn: (result) => { + expect(result, 'initial watchers should be defined').toBeDefined() + // TODO: what is the correct count of listeners? + // expect( + // result.clientListenersLength, + // 'client should have listeners attached initially' + // ).toBe + expect( + result.clientWorkersLength, + 'client should have workers attached initially' + ).toBeGreaterThan(0) + expect( + result.globalWorkersLength, + 'global workers should be attached initially' + ).toBeGreaterThan(0) + }, + message: 'expect to get initial client watchers', }) + }) - expect(watchers.clientListenersLength).toBe(0) - expect(watchers.clientWorkersLength).toBe(0) - expect(watchers.globalWorkersLength).toBe(0) + await test.step('disconnect client', async () => { + await disconnectClient(page) + }) + + await test.step('verify client has no workers and listeners attached', async () => { + await expectPageEvalToPass(page, { + evaluateFn: () => { + const client = window._client as SignalWireClient + const windowWithWorkers = + window as unknown as WindowWithRunningWorkers + + if (!client) { + throw new Error('Client not found') + } + + // Access internal properties for cleanup testing + const wsClient = (client as SignalWireClient).__wsClient + if (!wsClient) { + throw new Error('WebSocket client not found') + } + + const runningWorkers = windowWithWorkers._runningWorkers + if (!runningWorkers) { + throw new Error('Running workers not found') + } + + return { + clientListenersLength: wsClient.sessionEventNames().length, + clientWorkersLength: wsClient._runningWorkers.length, + globalWorkersLength: runningWorkers.length, + } satisfies Watchers + }, + assertionFn: (result) => { + expect(result, 'final watchers should be defined').toBeDefined() + expect( + result.clientListenersLength, + 'client should have no listeners after disconnect' + ).toBe(0) + expect( + result.clientWorkersLength, + 'client should have no workers after disconnect' + ).toBe(0) + expect( + result.globalWorkersLength, + 'global workers should be cleaned up after disconnect' + ).toBe(0) + }, + message: 'expect client watchers to be cleaned up', + }) }) }) @@ -92,108 +198,170 @@ test.describe('Clean up', () => { createCustomPage, resource, }) => { - const page = await createCustomPage({ name: '[page]' }) - await page.goto(SERVER_URL) + let page = {} as CustomPage + let initialWatchers = {} as Watchers + + await test.step('setup page and create client', async () => { + page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) - const roomName = `e2e_${uuid()}` - await resource.createVideoRoomResource(roomName) + const roomName = `e2e_${uuid()}` + await resource.createVideoRoomResource(roomName) - await createCFClient(page, { attachSagaMonitor: true }) + await createCFClient(page, { attachSagaMonitor: true }) - // Dial an address and join a video room - await dialAddress(page, { - address: `/public/${roomName}?channel=video`, + await dialAddress(page, { + address: `/public/${roomName}?channel=video`, + }) }) - const { beforeClientListenersLength, beforeClientWorkersLength } = - await test.step('call and client should have watchers attached', async () => { - const watchers: Record = await page.evaluate(() => { - const client = window._client! + await test.step('get initial watcher counts after call is created', async () => { + initialWatchers = await expectPageEvalToPass(page, { + evaluateFn: () => { + const client = window._client as SignalWireClient + const windowWithWorkers = + window as unknown as WindowWithRunningWorkers + + if (!client) { + throw new Error('Client not found') + } return { - // @ts-expect-error clientListenersLength: client.__wsClient.sessionEventNames().length, - // @ts-expect-error clientWorkersLength: client.__wsClient._runningWorkers.length, - - // @ts-expect-error - callListeners: window._callObj.eventNames().length, - // @ts-expect-error - callWorkersLength: window._callObj._runningWorkers.length, - - // @ts-expect-error - globalWorkersLength: window._runningWorkers.length, + callListeners: windowWithWorkers._callObj.eventNames().length, + callWorkersLength: + windowWithWorkers._callObj._runningWorkers.length, + globalWorkersLength: windowWithWorkers._runningWorkers.length, } - }) - - expect(watchers.clientListenersLength).toBeGreaterThan(0) - expect(watchers.clientWorkersLength).toBeGreaterThan(0) - expect(watchers.callListeners).toBeGreaterThan(0) - expect(watchers.callWorkersLength).toBeGreaterThan(0) - expect(watchers.globalWorkersLength).toBeGreaterThan(0) - - return { - beforeClientListenersLength: watchers.clientListenersLength, - beforeClientWorkersLength: watchers.clientWorkersLength, - } + }, + assertionFn: (result) => { + expect(result, 'initial watchers should be defined').toBeDefined() + expect( + result.clientListenersLength, + 'should have client listeners' + ).toBeGreaterThan(0) + expect( + result.clientWorkersLength, + 'should have client workers' + ).toBeGreaterThan(0) + expect( + result.callListeners, + 'should have call listeners' + ).toBeGreaterThan(0) + expect( + result.callWorkersLength, + 'should have call workers' + ).toBeGreaterThan(0) + expect( + result.globalWorkersLength, + 'should have global workers' + ).toBeGreaterThan(0) + }, + message: 'expect to get initial watcher counts after call is created', }) + }) - await leaveRoom(page) - - await test.step('call should not have any watchers attached', async () => { - const watchers: Record = await page.evaluate(() => { - const client = window._client! + // leave room after call is created + await test.step('leave room', async () => { + await leaveRoom(page) + }) - return { - // @ts-expect-error - clientListenersLength: client.__wsClient.sessionEventNames().length, - // @ts-expect-error - clientWorkersLength: client.__wsClient._runningWorkers.length, + await test.step('call should not have workers or listeners attached', async () => { + await expectPageEvalToPass(page, { + evaluateFn: () => { + const client = window._client as SignalWireClient + const windowWithWorkers = + window as unknown as WindowWithRunningWorkers - // @ts-expect-error - callListeners: window._callObj.eventNames().length, - // @ts-expect-error - callWorkersLength: window._callObj._runningWorkers.length, + if (!client) { + throw new Error('Client not found') + } - // @ts-expect-error - globalWorkersLength: window._runningWorkers.length, - } + return { + callListeners: windowWithWorkers._callObj.eventNames().length, + callWorkersLength: + windowWithWorkers._callObj._runningWorkers.length, + clientListenersLength: client.__wsClient.sessionEventNames().length, + clientWorkersLength: client.__wsClient._runningWorkers.length, + globalWorkersLength: windowWithWorkers._runningWorkers.length, + } + }, + assertionFn: (result) => { + expect( + result.callListeners, + 'call should have no listeners after leaving room' + ).toBe(0) + expect( + result.callWorkersLength, + 'call should have no workers after leaving room' + ).toBe(0) + expect( + result.clientListenersLength, + 'client should have same number of listeners as before leaving room' + ).toBe(initialWatchers.clientListenersLength) + expect( + result.clientWorkersLength, + 'client should have same number of workers as before leaving room' + ).toBe(initialWatchers.clientWorkersLength) + expect( + result.globalWorkersLength, + 'global workers should still be attached after leaving room' + ).toBeGreaterThan(0) + }, + message: 'expect client watchers to be cleaned up after leaving room', }) - - expect(watchers.clientListenersLength).toBe(beforeClientListenersLength) - expect(watchers.clientWorkersLength).toBe(beforeClientWorkersLength) - expect(watchers.callListeners).toBe(0) - expect(watchers.callWorkersLength).toBe(0) - expect(watchers.globalWorkersLength).toBeGreaterThan(0) }) - await disconnectClient(page) - - await test.step('client should not have any watchers attached', async () => { - const watchers: Record = await page.evaluate(() => { - const client = window._client! + await test.step('disconnect client after leaving room', async () => { + await disconnectClient(page) + }) - return { - // @ts-expect-error - clientListenersLength: client.__wsClient.sessionEventNames().length, - // @ts-expect-error - clientWorkersLength: client.__wsClient._runningWorkers.length, + await test.step('client should have no workers or listeners attached', async () => { + await expectPageEvalToPass(page, { + evaluateFn: async () => { + const client = window._client as SignalWireClient + const windowWithWorkers = + window as unknown as WindowWithRunningWorkers - // @ts-expect-error - callListeners: window._callObj.eventNames().length, - // @ts-expect-error - callWorkersLength: window._callObj._runningWorkers.length, + if (!client) { + throw new Error('Client not found') + } - // @ts-expect-error - globalWorkersLength: window._runningWorkers.length, - } + return { + clientListenersLength: client.__wsClient.sessionEventNames().length, + clientWorkersLength: client.__wsClient._runningWorkers.length, + callListeners: windowWithWorkers._callObj.eventNames().length, + callWorkersLength: + windowWithWorkers._callObj._runningWorkers.length, + globalWorkersLength: windowWithWorkers._runningWorkers.length, + } + }, + assertionFn: (result) => { + expect( + result.clientListenersLength, + 'client listeners should be cleaned up' + ).toBe(0) + expect( + result.clientWorkersLength, + 'client workers should be cleaned up' + ).toBe(0) + expect( + result.callListeners, + 'call listeners should be cleaned up' + ).toBe(0) + expect( + result.callWorkersLength, + 'call workers should be cleaned up' + ).toBe(0) + expect( + result.globalWorkersLength, + 'global workers should be cleaned up' + ).toBe(0) + }, + message: + 'expect client watchers, listeners, and global workers to be cleaned up after disconnect', }) - - expect(watchers.clientListenersLength).toBe(0) - expect(watchers.clientWorkersLength).toBe(0) - expect(watchers.callListeners).toBe(0) - expect(watchers.callWorkersLength).toBe(0) - expect(watchers.globalWorkersLength).toBe(0) }) }) }) diff --git a/internal/e2e-client/tests/callfabric/conversation.spec.ts b/internal/e2e-client/tests/callfabric/conversation.spec.ts index 958e97c06..571a14207 100644 --- a/internal/e2e-client/tests/callfabric/conversation.spec.ts +++ b/internal/e2e-client/tests/callfabric/conversation.spec.ts @@ -1,11 +1,7 @@ import { uuid } from '@signalwire/core' -import { - SignalWireClient, - ConversationMessageEventParams, - Address, -} from '@signalwire/client' -import { test, expect } from '../../fixtures' -import { SERVER_URL, createCFClient } from '../../utils' +import { ConversationMessageEventParams, Address } from '@signalwire/client' +import { test, expect, CustomPage } from '../../fixtures' +import { SERVER_URL, createCFClient, expectPageEvalToPass } from '../../utils' // FIXME: Enable this test once the issue with conversation APIs is resolved test.describe('Conversation Room', () => { @@ -13,162 +9,379 @@ test.describe('Conversation Room', () => { createCustomPage, resource, }) => { - const page = await createCustomPage({ name: '[page]' }) - const page2 = await createCustomPage({ - name: '[page2]', + let page = {} as CustomPage + let page2 = {} as CustomPage + let roomName = '' + let roomAddress = {} as Address + let firstMsgEvent = {} as ConversationMessageEventParams + let firstMsgEventFromPage2 = {} as ConversationMessageEventParams + let secondMsgEventFromPage1 = {} as ConversationMessageEventParams + let groupId = '' + + await test.step('setup pages and clients', async () => { + page = await createCustomPage({ name: '[page]' }) + page2 = await createCustomPage({ + name: '[page2]', + }) + await page.goto(SERVER_URL) + await page2.goto(SERVER_URL) + + await createCFClient(page) + await createCFClient(page2) + + roomName = `e2e_${uuid()}` + await resource.createVideoRoomResource(roomName) }) - await page.goto(SERVER_URL) - await page2.goto(SERVER_URL) - await createCFClient(page) - await createCFClient(page2) + await test.step('get room address', async () => { + roomAddress = await expectPageEvalToPass(page, { + evaluateArgs: { roomName }, + evaluateFn: async (params) => { + const client = window._client - const roomName = `e2e_${uuid()}` - await resource.createVideoRoomResource(roomName) + if (!client) { + throw new Error('Client not found') + } - const roomAddress = await page.evaluate( - ({ roomName }) => { - return new Promise
(async (resolve) => { - // @ts-expect-error - const client: SignalWireClient = window._client const addresses = await client.address.getAddresses({ - displayName: roomName, + displayName: params.roomName, }) - resolve(addresses.data[0]) - }) - }, - { roomName } - ) - - const firstMsgEvent = await page.evaluate( - ({ roomAddress }) => { - return new Promise(async (resolve) => { - // @ts-expect-error - const client: SignalWireClient = window._client - - const from_fabric_address_id = (await client.address.getMyAddresses())[0].id - console.log('from_fabric_address_id:', from_fabric_address_id) - - const addressId = roomAddress.id - // Note: subscribe will trigger for call logs too - // we need to make sure call logs don't resolve promise - client.conversation.subscribe((event) => { - if (event.subtype == 'chat') { - resolve(event) + return addresses.data[0] + }, + assertionFn: (result) => { + expect(result, 'room address should be found').toBeDefined() + expect(result.id, 'room address should have id').toBeDefined() + }, + message: 'expect to get room address', + }) + }) + + await test.step('setup first message event and send message from page1', async () => { + let fabricAddressIdPage1 = '' + let addressId = '' + let joinResponsePage1 = { group_id: '' } + let messageSubscriptionPromisePage1: Promise + + // Step 1: Get fabric address ID for page1 + fabricAddressIdPage1 = await expectPageEvalToPass(page, { + evaluateFn: async () => { + const client = window._client + + if (!client) { + throw new Error('Client not found') + } + + const myAddresses = await client.address.getMyAddresses() + return myAddresses[0].id + }, + assertionFn: (result) => { + expect(result, 'fabric address ID should be defined').toBeDefined() + expect(typeof result, 'fabric address ID should be string').toBe( + 'string' + ) + }, + message: 'expect to get fabric address ID for page1', + }) + + // Step 2: Get room address ID + addressId = await expectPageEvalToPass(page, { + evaluateArgs: { roomAddress }, + evaluateFn: async (params) => { + return params.roomAddress.id + }, + assertionFn: (result) => { + expect(result, 'address ID should be defined').toBeDefined() + expect(typeof result, 'address ID should be string').toBe('string') + }, + message: 'expect to get room address ID', + }) + + // Step 3: Set up subscription listener on page1 + messageSubscriptionPromisePage1 = expectPageEvalToPass(page, { + evaluateFn: () => { + return new Promise((resolve) => { + const client = window._client + + if (!client) { + throw new Error('Client not found') } + + // Note: subscribe will trigger for call logs too + // we need to make sure call logs don't resolve promise + client.conversation.subscribe((event) => { + if (event.subtype == 'chat') { + resolve(event) + } + }) }) + }, + assertionFn: (result) => { + expect(result, 'first message event should be defined').toBeDefined() + expect( + result.type, + 'first message event should be message type' + ).toBe('message') + }, + message: 'expect to set up subscription listener on page1', + }) + + // Step 4: Join conversation on page1 + joinResponsePage1 = await expectPageEvalToPass(page, { + evaluateArgs: { addressId, fabricAddressIdPage1 }, + evaluateFn: async (params) => { + const client = window._client + + if (!client) { + throw new Error('Client not found') + } - const joinResponse = await client.conversation.join({ - addressIds: [addressId, from_fabric_address_id], - fromAddressId: from_fabric_address_id, + return await client.conversation.join({ + addressIds: [params.addressId, params.fabricAddressIdPage1], + fromAddressId: params.fabricAddressIdPage1, }) + }, + assertionFn: (result) => { + expect(result, 'join response should be defined').toBeDefined() + expect( + result.group_id, + 'join response should have group_id' + ).toBeDefined() + }, + message: 'expect to join conversation on page1', + }) + + // Step 5: Send message on page1 + await expectPageEvalToPass(page, { + evaluateArgs: { + groupId: joinResponsePage1.group_id, + fabricAddressIdPage1, + }, + evaluateFn: async (params) => { + const client = window._client + + if (!client) { + throw new Error('Client not found') + } - client.conversation.sendMessage({ - groupId: joinResponse.group_id, + await client.conversation.sendMessage({ + groupId: params.groupId, text: '1st message from 1st subscriber', - fromAddressId: from_fabric_address_id, + fromAddressId: params.fabricAddressIdPage1, }) - }) - }, - { roomAddress } - ) - - expect(firstMsgEvent.type).toBe('message') - console.log('firstMsgEvent:', firstMsgEvent) - const group_id = firstMsgEvent.group_id - - const secondMsgEventPromiseFromPage1 = page.evaluate(() => { - return new Promise((resolve) => { - // @ts-expect-error - const client: SignalWireClient = window._client - // Note: subscribe will trigger for call logs too - // we need to make sure call logs don't resolve promise - client.conversation.subscribe((event) => { - // Note we need to do this - if (event.subtype == 'chat') { - resolve(event) - } - }) + + return true // Simple confirmation that message was sent + }, + assertionFn: (result) => { + expect(result, 'message should be sent successfully').toBe(true) + }, + message: 'expect to send message on page1', }) + + // Wait for the subscription promise to resolve + firstMsgEvent = await messageSubscriptionPromisePage1 + groupId = firstMsgEvent.group_id }) - const firstMsgEventFromPage2 = await page2.evaluate( - ({ roomAddress }) => { - return new Promise(async (resolve) => { - // @ts-expect-error - const client: SignalWireClient = window._client - const from_fabric_address_id = (await client.address.getMyAddresses())[0].id - // Note: subscribe will trigger for call logs too - // we need to make sure call logs don't resolve promise - client.conversation.subscribe((event) => { - if (event.subtype == 'chat') { - resolve(event) + await test.step('setup second message event listener on page1', async () => { + // Set up promise for second message event from page1 (will be resolved later) + const secondMsgEventPromiseFromPage1 = expectPageEvalToPass(page, { + evaluateFn: () => { + return new Promise((resolve) => { + const client = window._client + + if (!client) { + throw new Error('Client not found') } + + // Note: subscribe will trigger for call logs too + // we need to make sure call logs don't resolve promise + client.conversation.subscribe((event) => { + // Note we need to do this + if (event.subtype == 'chat') { + resolve(event) + } + }) + }) + }, + assertionFn: (result) => { + expect(result, 'second message event should be defined').toBeDefined() + expect( + result.type, + 'second message event should be message type' + ).toBe('message') + }, + message: 'expect to receive second message event on page1', + }) + + // Break up page2 message sending into separate steps + let fabricAddressId = '' + let joinResponse = { group_id: '' } + let messageSubscriptionPromise: Promise + + // Step 1: Get fabric address ID for page2 + fabricAddressId = await expectPageEvalToPass(page2, { + evaluateFn: async () => { + const client = window._client + + if (!client) { + throw new Error('Client not found') + } + + const myAddresses = await client.address.getMyAddresses() + return myAddresses[0].id + }, + assertionFn: (result) => { + expect(result, 'fabric address ID should be defined').toBeDefined() + expect(typeof result, 'fabric address ID should be string').toBe( + 'string' + ) + }, + message: 'expect to get fabric address ID for page2', + }) + + // Step 2: Join conversation on page2 + joinResponse = await expectPageEvalToPass(page2, { + evaluateArgs: { roomAddress, fabricAddressId }, + evaluateFn: async (params) => { + const client = window._client + + if (!client) { + throw new Error('Client not found') + } + + return await client.conversation.join({ + addressIds: [params.roomAddress.id, params.fabricAddressId], + fromAddressId: params.fabricAddressId, }) - const joinResponse = await client.conversation.join({ - addressIds: [roomAddress.id, from_fabric_address_id], - fromAddressId: from_fabric_address_id, + }, + assertionFn: (result) => { + expect(result, 'join response should be defined').toBeDefined() + expect( + result.group_id, + 'join response should have group_id' + ).toBeDefined() + }, + message: 'expect to join conversation on page2', + }) + + // Step 3: Set up subscription listener on page2 + messageSubscriptionPromise = expectPageEvalToPass(page2, { + evaluateFn: () => { + return new Promise((resolve) => { + const client = window._client + + if (!client) { + throw new Error('Client not found') + } + + // Note: subscribe will trigger for call logs too + // we need to make sure call logs don't resolve promise + client.conversation.subscribe((event) => { + if (event.subtype == 'chat') { + resolve(event) + } + }) }) + }, + assertionFn: (result) => { + expect( + result, + 'subscription message event should be defined' + ).toBeDefined() + expect( + result.type, + 'subscription message event should be message type' + ).toBe('message') + }, + message: 'expect to set up subscription listener on page2', + }) + + // Step 4: Send message on page2 + await expectPageEvalToPass(page2, { + evaluateArgs: { groupId: joinResponse.group_id, fabricAddressId }, + evaluateFn: async (params) => { + const client = window._client + + if (!client) { + throw new Error('Client not found') + } - client.conversation.sendMessage({ - groupId: joinResponse.group_id, + await client.conversation.sendMessage({ + groupId: params.groupId, text: '1st message from 2nd subscriber', - fromAddressId: from_fabric_address_id, + fromAddressId: params.fabricAddressId, }) - }) - }, - { roomAddress } - ) - - const secondMsgEventFromPage1 = await secondMsgEventPromiseFromPage1 - expect(secondMsgEventFromPage1).not.toBeUndefined() - - expect(secondMsgEventFromPage1.type).toBe('message') - - expect(firstMsgEventFromPage2).not.toBeUndefined() - - expect(firstMsgEventFromPage2.type).toBe('message') - - const messages = await page.evaluate( - async ({ group_id }) => { - // @ts-expect-error - const client: SignalWireClient = window._client - return await client.conversation.getConversationMessages({ - groupId: group_id, - }) - }, - { group_id } - ) - - expect(messages).not.toBeUndefined() - - // Note: even though we are only sending 2 messages - // there can be call logs inside the messages - expect(messages.data.length).toBeGreaterThanOrEqual(2) - - expect(messages.data).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - details: {}, - id: expect.anything(), - kind: null, - subtype: 'chat', - text: '1st message from 2nd subscriber', - ts: expect.anything(), - type: 'message', - from_fabric_address_id: expect.anything(), - }), - expect.objectContaining({ - details: {}, - id: expect.anything(), - kind: null, - subtype: 'chat', - text: '1st message from 1st subscriber', - ts: expect.anything(), - type: 'message', - from_fabric_address_id: expect.anything(), - }), - ]) - ) + + return true // Simple confirmation that message was sent + }, + assertionFn: (result) => { + expect(result, 'message should be sent successfully').toBe(true) + }, + message: 'expect to send message on page2', + }) + + // Wait for both subscription promises to resolve + firstMsgEventFromPage2 = await messageSubscriptionPromise + secondMsgEventFromPage1 = await secondMsgEventPromiseFromPage1 + }) + + await test.step('verify message events', async () => { + expect(secondMsgEventFromPage1).toBeDefined() + expect(secondMsgEventFromPage1.type).toBe('message') + expect(firstMsgEventFromPage2).toBeDefined() + expect(firstMsgEventFromPage2.type).toBe('message') + }) + + await test.step('get and verify conversation messages', async () => { + const messages = await expectPageEvalToPass(page, { + evaluateArgs: { groupId }, + evaluateFn: async (params) => { + const client = window._client + + if (!client) { + throw new Error('Client not found') + } + + return await client.conversation.getConversationMessages({ + groupId: params.groupId, + }) + }, + assertionFn: (result) => { + expect(result, 'messages should be defined').toBeDefined() + // Note: even though we are only sending 2 messages + // there can be call logs inside the messages + expect( + result.data.length, + 'should have at least 2 messages' + ).toBeGreaterThanOrEqual(2) + }, + message: 'expect to get conversation messages', + }) + + expect(messages.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + details: {}, + id: expect.anything(), + kind: null, + subtype: 'chat', + text: '1st message from 2nd subscriber', + ts: expect.anything(), + type: 'message', + from_fabric_address_id: expect.anything(), + }), + expect.objectContaining({ + details: {}, + id: expect.anything(), + kind: null, + subtype: 'chat', + text: '1st message from 1st subscriber', + ts: expect.anything(), + type: 'message', + from_fabric_address_id: expect.anything(), + }), + ]) + ) + }) }) }) diff --git a/internal/e2e-client/tests/callfabric/deviceEvent.spec.ts b/internal/e2e-client/tests/callfabric/deviceEvent.spec.ts index e81a6a71a..e66067a43 100644 --- a/internal/e2e-client/tests/callfabric/deviceEvent.spec.ts +++ b/internal/e2e-client/tests/callfabric/deviceEvent.spec.ts @@ -1,11 +1,12 @@ import { uuid } from '@signalwire/core' -import { test, expect } from '../../fixtures' -import type { CallSession } from '@signalwire/client' +import { test, expect, CustomPage } from '../../fixtures' +import { CallJoinedEventParams, type CallSession } from '@signalwire/client' import { SERVER_URL, createCFClient, dialAddress, expectMCUVisible, + expectPageEvalToPass, } from '../../utils' test.describe('CallCall Room Device', () => { @@ -13,51 +14,162 @@ test.describe('CallCall Room Device', () => { createCustomPage, resource, }) => { - const page = await createCustomPage({ name: '[page]' }) - await page.goto(SERVER_URL) + let page = {} as CustomPage + let roomName = '' + let callSession = {} as CallJoinedEventParams + let microphoneUpdatedPromise: Promise + let cameraUpdatedPromise: Promise + let devices: unknown[] = [] - const roomName = `e2e_${uuid()}` - await resource.createVideoRoomResource(roomName) + await test.step('setup page and join video room', async () => { + page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) - await createCFClient(page) + roomName = `e2e_${uuid()}` + await resource.createVideoRoomResource(roomName) - // Dial an address and join a video room - const callSession = await dialAddress(page, { - address: `/public/${roomName}?channel=video`, - }) - expect(callSession.room_session).toBeDefined() + await createCFClient(page) - await expectMCUVisible(page) + // Dial an address and join a video room + callSession = await dialAddress(page, { + address: `/public/${roomName}?channel=video`, + }) + expect(callSession.room_session).toBeDefined() - // --------------- Change the microphone & camera --------------- - const devices = await page.evaluate(async () => { - // @ts-expect-error - const callObj: CallSession = window._callObj + await expectMCUVisible(page) + }) - const microphoneUpdated = new Promise((resolve) => { - callObj.on('microphone.updated', (payload) => { - resolve(payload) - }) + await test.step('setup microphone updated event listener', async () => { + microphoneUpdatedPromise = expectPageEvalToPass(page, { + evaluateFn: () => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + callObj.on('microphone.updated', (payload) => { + resolve(payload) + }) + }) + }, + assertionFn: (result) => { + expect( + result, + 'microphone updated event should be defined' + ).toBeDefined() + expect( + result, + 'microphone updated event should have previous deviceId' + ).toHaveProperty('previous.deviceId') + expect( + result, + 'microphone updated event should have previous label' + ).toHaveProperty('previous.label') + expect( + result, + 'microphone updated event should have current deviceId' + ).toHaveProperty('current.deviceId') + expect( + result, + 'microphone updated event should have current label' + ).toHaveProperty('current.label') + }, + message: 'expect to set up microphone updated event listener', }) + }) - const cameraUpdated = new Promise((resolve) => { - callObj.on('camera.updated', (payload) => { - resolve(payload) - }) + await test.step('setup camera updated event listener', async () => { + cameraUpdatedPromise = expectPageEvalToPass(page, { + evaluateFn: () => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + callObj.on('camera.updated', (payload) => { + resolve(payload) + }) + }) + }, + assertionFn: (result) => { + expect(result, 'camera updated event should be defined').toBeDefined() + expect( + result, + 'camera updated event should have previous deviceId' + ).toHaveProperty('previous.deviceId') + expect( + result, + 'camera updated event should have previous label' + ).toHaveProperty('previous.label') + expect( + result, + 'camera updated event should have current deviceId' + ).toHaveProperty('current.deviceId') + expect( + result, + 'camera updated event should have current label' + ).toHaveProperty('current.label') + }, + message: 'expect to set up camera updated event listener', }) + }) - await callObj.updateMicrophone({ deviceId: 'test-mic-id' }) - await callObj.updateCamera({ deviceId: 'test-camera-id' }) + await test.step('update microphone device', async () => { + await expectPageEvalToPass(page, { + evaluateFn: async () => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + await callObj.updateMicrophone({ deviceId: 'test-mic-id' }) + return true + }, + assertionFn: (result) => { + expect(result, 'microphone update should be successful').toBe(true) + }, + message: 'expect to update microphone device', + }) + }) - return Promise.all([microphoneUpdated, cameraUpdated]) + await test.step('update camera device', async () => { + await expectPageEvalToPass(page, { + evaluateFn: async () => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + await callObj.updateCamera({ deviceId: 'test-camera-id' }) + return true + }, + assertionFn: (result) => { + expect(result, 'camera update should be successful').toBe(true) + }, + message: 'expect to update camera device', + }) }) - expect(devices).toHaveLength(2) - devices.forEach((item) => { - expect(item).toHaveProperty('previous.deviceId') - expect(item).toHaveProperty('previous.label') - expect(item).toHaveProperty('current.deviceId') - expect(item).toHaveProperty('current.label') + await test.step('wait for device update events and verify', async () => { + // Wait for both promises to resolve + const microphoneEvent = await microphoneUpdatedPromise + const cameraEvent = await cameraUpdatedPromise + + devices = [microphoneEvent, cameraEvent] + + expect(devices).toHaveLength(2) + devices.forEach((item) => { + expect(item).toHaveProperty('previous.deviceId') + expect(item).toHaveProperty('previous.label') + expect(item).toHaveProperty('current.deviceId') + expect(item).toHaveProperty('current.label') + }) }) }) @@ -118,107 +230,250 @@ test.describe('CallCall Room Device', () => { createCustomPage, resource, }) => { - const page = await createCustomPage({ name: '[page]' }) - await page.goto(SERVER_URL) + let page = {} as CustomPage + let roomName = '' + let callSession = {} as CallJoinedEventParams + let speakerId = '' + let speakerUpdatedPromise: Promise + let device: unknown - const roomName = `e2e_${uuid()}` - await resource.createVideoRoomResource(roomName) + await test.step('setup page and join video room', async () => { + page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) - await createCFClient(page) + roomName = `e2e_${uuid()}` + await resource.createVideoRoomResource(roomName) - // Dial an address and join a video room - const callSession = await dialAddress(page, { - address: `/public/${roomName}?channel=video`, - }) - expect(callSession.room_session).toBeDefined() + await createCFClient(page) - await expectMCUVisible(page) + // Dial an address and join a video room + callSession = await dialAddress(page, { + address: `/public/${roomName}?channel=video`, + }) + expect(callSession.room_session).toBeDefined() - // --------------- Change the speaker --------------- - const device = await page.evaluate(async () => { - // @ts-expect-error - const callObj: CallSession = window._callObj + await expectMCUVisible(page) + }) - const speakerUpdated = new Promise((resolve) => { - callObj.on('speaker.updated', (payload) => { - resolve(payload) - }) + await test.step('get available speaker device ID', async () => { + speakerId = await expectPageEvalToPass(page, { + evaluateFn: async () => { + const devices = await navigator.mediaDevices?.enumerateDevices() + + if (!devices) { + throw new Error('Media devices not available') + } + + const audioOutputDevices = devices.filter( + (device) => + device.kind === 'audiooutput' && device.deviceId !== 'default' + ) + + if (audioOutputDevices.length === 0) { + throw new Error('No non-default audio output devices found') + } + + return audioOutputDevices[0].deviceId + }, + assertionFn: (result) => { + expect(result, 'speaker device ID should be defined').toBeDefined() + expect(typeof result, 'speaker device ID should be string').toBe( + 'string' + ) + expect(result, 'speaker device ID should not be empty').not.toBe('') + }, + message: 'expect to get available speaker device ID', }) + }) - const speakerId = (await navigator.mediaDevices?.enumerateDevices()) - .filter( - (device) => - device.kind === 'audiooutput' && device.deviceId !== 'default' - ) - .map((device) => device.deviceId)[0] - - await callObj.updateSpeaker({ deviceId: speakerId }) + await test.step('setup speaker updated event listener', async () => { + speakerUpdatedPromise = expectPageEvalToPass(page, { + evaluateFn: () => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + callObj.on('speaker.updated', (payload) => { + resolve(payload) + }) + }) + }, + assertionFn: (result) => { + expect( + result, + 'speaker updated event should be defined' + ).toBeDefined() + expect( + result, + 'speaker updated event should have previous deviceId' + ).toHaveProperty('previous.deviceId') + expect( + result, + 'speaker updated event should have previous label' + ).toHaveProperty('previous.label') + expect( + result, + 'speaker updated event should have current deviceId' + ).toHaveProperty('current.deviceId') + expect( + result, + 'speaker updated event should have current label' + ).toHaveProperty('current.label') + }, + message: 'expect to set up speaker updated event listener', + }) + }) - return await speakerUpdated + await test.step('update speaker device', async () => { + await expectPageEvalToPass(page, { + evaluateArgs: { speakerId }, + evaluateFn: async (params) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + await callObj.updateSpeaker({ deviceId: params.speakerId }) + return true + }, + assertionFn: (result) => { + expect(result, 'speaker update should be successful').toBe(true) + }, + message: 'expect to update speaker device', + }) }) - expect(device).toHaveProperty('previous.deviceId') - expect(device).toHaveProperty('previous.label') - expect(device).toHaveProperty('current.deviceId') - expect(device).toHaveProperty('current.label') + await test.step('wait for speaker updated event and verify', async () => { + // Wait for the speaker updated event + device = await speakerUpdatedPromise + + expect(device).toHaveProperty('previous.deviceId') + expect(device).toHaveProperty('previous.label') + expect(device).toHaveProperty('current.deviceId') + expect(device).toHaveProperty('current.label') + }) }) test('should emit the speaker disconnected event', async ({ createCustomPage, resource, }) => { - const page = await createCustomPage({ name: '[page]' }) - await page.goto(SERVER_URL) - - const roomName = `e2e_${uuid()}` - await resource.createVideoRoomResource(roomName) - - page.evaluate(async () => { - // @ts-expect-error - navigator.mediaDevices.enumerateDevices = async () => { - return [ - { - deviceId: 'default', - kind: 'audiooutput', - label: 'Default Speaker', - }, - ] - } - }) - - await createCFClient(page) - - // Dial an address and join a video room - const callSession = await dialAddress(page, { - address: `/public/${roomName}?channel=video`, + let page = {} as CustomPage + let roomName = '' + let callSession = {} as CallJoinedEventParams + let speakerDisconnectedPromise: Promise + let device: unknown + + await test.step('setup page and mock initial device enumeration', async () => { + page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + + roomName = `e2e_${uuid()}` + await resource.createVideoRoomResource(roomName) + + // Set up initial mock for enumerate devices with default speaker + await expectPageEvalToPass(page, { + evaluateFn: async () => { + // Mock the enumerate devices to return a default speaker + navigator.mediaDevices.enumerateDevices = async () => { + return [ + { + deviceId: 'default', + kind: 'audiooutput', + label: 'Default Speaker', + } as MediaDeviceInfo, + ] + } + + return true + }, + assertionFn: (result) => { + expect(result, 'device enumeration mock should be set up').toBe(true) + }, + message: 'expect to mock initial device enumeration', + }) }) - expect(callSession.room_session).toBeDefined() - await expectMCUVisible(page) + await test.step('create client and join video room', async () => { + await createCFClient(page) - // --------------- Change the speaker ------------- - const device = await page.evaluate(async () => { - // @ts-expect-error - const callObj: CallSession = window._callObj + // Dial an address and join a video room + callSession = await dialAddress(page, { + address: `/public/${roomName}?channel=video`, + }) + expect(callSession.room_session).toBeDefined() - navigator.mediaDevices.enumerateDevices = async () => { - return [] - } + await expectMCUVisible(page) + }) - const speakerDisconnected = new Promise((resolve) => { - callObj.on('speaker.disconnected', (payload) => { - resolve(payload) - }) - const event = new Event('devicechange') - navigator.mediaDevices.dispatchEvent(event) + await test.step('setup speaker disconnected event listener', async () => { + speakerDisconnectedPromise = expectPageEvalToPass(page, { + evaluateFn: () => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + callObj.on('speaker.disconnected', (payload) => { + resolve(payload) + }) + }) + }, + assertionFn: (result) => { + expect( + result, + 'speaker disconnected event should be defined' + ).toBeDefined() + expect( + result, + 'speaker disconnected event should have deviceId' + ).toHaveProperty('deviceId') + expect( + result, + 'speaker disconnected event should have label' + ).toHaveProperty('label') + }, + message: 'expect to set up speaker disconnected event listener', }) + }) - return await speakerDisconnected + await test.step('mock device removal and trigger disconnection', async () => { + await expectPageEvalToPass(page, { + evaluateFn: () => { + // Change the enumerate devices to return empty array (no devices) + navigator.mediaDevices.enumerateDevices = async () => { + return [] + } + + // Trigger the devicechange event to simulate device removal + const event = new Event('devicechange') + navigator.mediaDevices.dispatchEvent(event) + + return true + }, + assertionFn: (result) => { + expect(result, 'device removal simulation should be successful').toBe( + true + ) + }, + message: 'expect to mock device removal and trigger disconnection', + }) }) - expect(device).toStrictEqual({ - deviceId: 'default', - label: 'Default Speaker', + await test.step('wait for speaker disconnected event and verify', async () => { + // Wait for the speaker disconnected event + device = await speakerDisconnectedPromise + + expect(device).toStrictEqual({ + deviceId: 'default', + label: 'Default Speaker', + }) }) }) }) diff --git a/internal/e2e-client/tests/callfabric/deviceState.spec.ts b/internal/e2e-client/tests/callfabric/deviceState.spec.ts index 88752402f..336241900 100644 --- a/internal/e2e-client/tests/callfabric/deviceState.spec.ts +++ b/internal/e2e-client/tests/callfabric/deviceState.spec.ts @@ -3,10 +3,11 @@ import { createCFClient, dialAddress, expectMCUVisible, + expectPageEvalToPass, SERVER_URL, } from '../../utils' -import { test, expect } from '../../fixtures' -import { CallSession } from '@signalwire/client' +import { test, expect, CustomPage } from '../../fixtures' +import { CallJoinedEventParams } from '@signalwire/client' type CameraTest = { stopCameraWhileMuted: boolean @@ -33,57 +34,121 @@ test.describe('CallCall - Device State', () => { ? 'by stopping the device' : 'without stopping the device' }`, async ({ createCustomPage, resource }) => { - const page = await createCustomPage({ name: '[page]' }) - await page.goto(SERVER_URL) + let page = {} as CustomPage + let roomName = '' + let callSession = {} as CallJoinedEventParams + let memberId = '' - const roomName = `e2e_${uuid()}` - await resource.createVideoRoomResource(roomName) + await test.step('setup page and join video room', async () => { + page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) - await createCFClient(page) + roomName = `e2e_${uuid()}` + await resource.createVideoRoomResource(roomName) - // Dial an address and join a video room - const callSession = await dialAddress(page, { - address: `/public/${roomName}?channel=video`, - dialOptions: { - stopCameraWhileMuted, - }, - }) - const memberId = callSession.member_id + await createCFClient(page) - await expectMCUVisible(page) + // Dial an address and join a video room + callSession = await dialAddress(page, { + address: `/public/${roomName}?channel=video`, + dialOptions: { + stopCameraWhileMuted, + }, + }) + memberId = callSession.member_id + + await expectMCUVisible(page) + }) - // --------------- Muting Video (self) --------------- await test.step('mute the self video', async () => { - await page.evaluate(async (memberId) => { - // @ts-expect-error - const callObj: CallSession = window._callObj - - const memberUpdatedMutedEvent = new Promise((res) => { - callObj.on('member.updated.videoMuted', (event) => { - if ( - event.member.member_id === memberId && - event.member.video_muted === true - ) { - res(true) + let memberUpdatedMutedPromise: Promise + + // Step 1: Set up event listener for video mute + memberUpdatedMutedPromise = expectPageEvalToPass(page, { + evaluateArgs: { memberId }, + evaluateFn: (params) => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') } + + callObj.on('member.updated.videoMuted', (event) => { + if ( + event.member.member_id === params.memberId && + event.member.video_muted === true + ) { + resolve(true) + } + }) }) - }) + }, + assertionFn: (result) => { + expect(result, 'video muted event should fire').toBe(true) + }, + message: 'expect to set up video muted event listener', + }) + + // Step 2: Execute video mute operation + await expectPageEvalToPass(page, { + evaluateFn: async () => { + const callObj = window._callObj - await callObj.videoMute() - await memberUpdatedMutedEvent - }, memberId) + if (!callObj) { + throw new Error('Call object not found') + } + + await callObj.videoMute() + return true + }, + assertionFn: (result) => { + expect(result, 'video mute operation should complete').toBe(true) + }, + message: 'expect to execute video mute operation', + }) + + // Step 3: Wait for the member updated event + const eventReceived = await memberUpdatedMutedPromise + expect(eventReceived, 'video muted event should be received').toBe(true) }) await test.step(`assert that the camera is ${ stopCameraWhileMuted ? 'off' : 'on' }`, async () => { - const cameraOn = await page.evaluate(() => { - // @ts-expect-error - const callObj: CallSession = window._callObj - const localStreams = callObj.localStream - return Boolean(localStreams?.getVideoTracks()[0]?.enabled) + await expectPageEvalToPass(page, { + evaluateFn: () => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + const localStreams = callObj.localStream + + if (!localStreams) { + throw new Error('Local streams not found') + } + + const videoTracks = localStreams.getVideoTracks() + + if (videoTracks.length === 0) { + return false // No video tracks means camera is off + } + + return Boolean(videoTracks[0]?.enabled) + }, + assertionFn: (result) => { + expect(typeof result, 'camera state should be boolean').toBe( + 'boolean' + ) + expect( + result, + `camera should be ${stopCameraWhileMuted ? 'off' : 'on'}` + ).toBe(!stopCameraWhileMuted) + }, + message: 'expect to get camera state from local video tracks', }) - expect(cameraOn).toBe(!stopCameraWhileMuted) }) }) }) @@ -94,57 +159,121 @@ test.describe('CallCall - Device State', () => { ? 'by stopping the device' : 'without stopping the device' }`, async ({ createCustomPage, resource }) => { - const page = await createCustomPage({ name: '[page]' }) - await page.goto(SERVER_URL) + let page = {} as CustomPage + let roomName = '' + let callSession = {} as CallJoinedEventParams + let memberId = '' - const roomName = `e2e_${uuid()}` - await resource.createVideoRoomResource(roomName) + await test.step('setup page and join video room', async () => { + page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) - await createCFClient(page) + roomName = `e2e_${uuid()}` + await resource.createVideoRoomResource(roomName) - // Dial an address and join a video room - const callSession = await dialAddress(page, { - address: `/public/${roomName}?channel=video`, - dialOptions: { - stopMicrophoneWhileMuted, - }, + await createCFClient(page) + + // Dial an address and join a video room + callSession = await dialAddress(page, { + address: `/public/${roomName}?channel=video`, + dialOptions: { + stopMicrophoneWhileMuted, + }, + }) + memberId = callSession.member_id + + await expectMCUVisible(page) }) - const memberId = callSession.member_id - await expectMCUVisible(page) + await test.step('mute the self audio', async () => { + let memberUpdatedMutedPromise: Promise - // --------------- Muting Audio (self) --------------- - await test.step('mute the self video', async () => { - await page.evaluate(async (memberId) => { - // @ts-expect-error - const callObj: CallSession = window._callObj - - const memberUpdatedMutedEvent = new Promise((res) => { - callObj.on('member.updated.audioMuted', (event) => { - if ( - event.member.member_id === memberId && - event.member.audio_muted === true - ) { - res(true) + // Step 1: Set up event listener for audio mute + memberUpdatedMutedPromise = expectPageEvalToPass(page, { + evaluateArgs: { memberId }, + evaluateFn: (params) => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') } + + callObj.on('member.updated.audioMuted', (event) => { + if ( + event.member.member_id === params.memberId && + event.member.audio_muted === true + ) { + resolve(true) + } + }) }) - }) + }, + assertionFn: (result) => { + expect(result, 'audio muted event should fire').toBe(true) + }, + message: 'expect to set up audio muted event listener', + }) + + // Step 2: Execute audio mute operation + await expectPageEvalToPass(page, { + evaluateFn: async () => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } - await callObj.audioMute() - await memberUpdatedMutedEvent - }, memberId) + await callObj.audioMute() + return true + }, + assertionFn: (result) => { + expect(result, 'audio mute operation should complete').toBe(true) + }, + message: 'expect to execute audio mute operation', + }) + + // Step 3: Wait for the member updated event + const eventReceived = await memberUpdatedMutedPromise + expect(eventReceived, 'audio muted event should be received').toBe(true) }) await test.step(`assert that the microphone is ${ stopMicrophoneWhileMuted ? 'off' : 'on' }`, async () => { - const micOn = await page.evaluate(() => { - // @ts-expect-error - const callObj: CallSession = window._callObj - const localStreams = callObj.localStream - return Boolean(localStreams?.getAudioTracks()[0]?.enabled) + await expectPageEvalToPass(page, { + evaluateFn: () => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + const localStreams = callObj.localStream + + if (!localStreams) { + throw new Error('Local streams not found') + } + + const audioTracks = localStreams.getAudioTracks() + + if (audioTracks.length === 0) { + return false // No audio tracks means microphone is off + } + + return Boolean(audioTracks[0]?.enabled) + }, + assertionFn: (result) => { + expect(typeof result, 'microphone state should be boolean').toBe( + 'boolean' + ) + expect( + result, + `microphone should be ${stopMicrophoneWhileMuted ? 'off' : 'on'}` + ).toBe(!stopMicrophoneWhileMuted) + }, + message: 'expect to get microphone state from local audio tracks', }) - expect(micOn).toBe(!stopMicrophoneWhileMuted) }) }) }) diff --git a/internal/e2e-client/tests/callfabric/relayApp.spec.ts b/internal/e2e-client/tests/callfabric/relayApp.spec.ts index 7e41f7fa7..0ee451f32 100644 --- a/internal/e2e-client/tests/callfabric/relayApp.spec.ts +++ b/internal/e2e-client/tests/callfabric/relayApp.spec.ts @@ -93,10 +93,7 @@ test.describe('CallFabric Relay Application', () => { console.log('Calculating audio stats') await expectPageReceiveAudio(page) - // stop relayApp playback - await playback!.stop() - - await page.evaluate(async () => { + const callPlayFinished = page.evaluate(async () => { // @ts-expect-error const callObj: Video.RoomSession = window._callObj return new Promise((resolve) => { @@ -106,6 +103,11 @@ test.describe('CallFabric Relay Application', () => { }) }) + // stop relayApp playback + await playback!.stop() + + await callPlayFinished + const expectFinalEvents = expectCFFinalEvents(page) // Hangup the call diff --git a/internal/e2e-client/tests/callfabric/swml.spec.ts b/internal/e2e-client/tests/callfabric/swml.spec.ts index 3071142b7..982cd1900 100644 --- a/internal/e2e-client/tests/callfabric/swml.spec.ts +++ b/internal/e2e-client/tests/callfabric/swml.spec.ts @@ -1,5 +1,5 @@ import { uuid } from '@signalwire/core' -import { test } from '../../fixtures' +import { test, expect, CustomPage } from '../../fixtures' import { SERVER_URL, createCFClient, @@ -7,6 +7,7 @@ import { expectCFFinalEvents, expectCFInitialEvents, expectPageReceiveAudio, + expectPageEvalToPass, } from '../../utils' test.describe('CallCall SWML', () => { @@ -46,48 +47,97 @@ test.describe('CallCall SWML', () => { createCustomPage, resource, }) => { - const page = await createCustomPage({ name: '[page]' }) - await page.goto(SERVER_URL) + // Scoped variables at test level + let page = {} as CustomPage + let resourceName = '' + let callPlayStartedPromise = {} as Promise + let expectInitialEvents = {} as Promise< + [[boolean, boolean, boolean], ...boolean[]] + > + let expectFinalEvents = {} as Promise<[boolean, ...boolean[]]> + + await test.step('setup page, resource, and client', async () => { + page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + + resourceName = `e2e_${uuid()}` + await resource.createSWMLAppResource({ + name: resourceName, + contents: swmlTTS, + }) - const resourceName = `e2e_${uuid()}` - await resource.createSWMLAppResource({ - name: resourceName, - contents: swmlTTS, + await createCFClient(page) }) - await createCFClient(page) + await test.step('dial address', async () => { + await dialAddress(page, { + address: `/private/${resourceName}`, + shouldWaitForJoin: false, + shouldStartCall: false, + }) + }) - // Dial an address and listen a TTS - await dialAddress(page, { - address: `/private/${resourceName}`, - shouldWaitForJoin: false, - shouldStartCall: false, + await test.step('setup call.play event listener', async () => { + callPlayStartedPromise = expectPageEvalToPass(page, { + evaluateFn: () => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + callObj.on('call.play', (params: { state: string }) => { + if (params.state === 'playing') resolve(true) + }) + }) + }, + assertionFn: (result) => { + expect( + result, + 'call.play event with playing state should be received' + ).toBe(true) + }, + message: 'expect to setup call.play event listener', + }) }) - const callPlayStarted = page.evaluate(async () => { - // @ts-expect-error - const callObj: Video.RoomSession = window._callObj - return new Promise((resolve) => { - callObj.on('call.play', (params: any) => { - if (params.state === 'playing') resolve(true) - }) + await test.step('setup event expectations', async () => { + expectInitialEvents = expectCFInitialEvents(page, [ + callPlayStartedPromise, + ]) + // NOTE: the timeout is extended from the default because this test takes longer to process + // do to the TTS audio and the time to resolve the expectPageReceiveAudio promise + expectFinalEvents = expectCFFinalEvents(page, undefined, { + timeoutMs: 40000, }) }) - const expectInitialEvents = expectCFInitialEvents(page, [callPlayStarted]) + await test.step('start the call', async () => { + await expectPageEvalToPass(page, { + evaluateFn: async () => { + const call = window._callObj - await page.evaluate(async () => { - // @ts-expect-error - const call = window._callObj + if (!call) { + throw new Error('Call object not found') + } - await call.start() + await call.start() + return true + }, + assertionFn: (result) => { + expect(result, 'call should start successfully').toBe(true) + }, + message: 'expect to start the call', + }) }) - await expectInitialEvents - - await expectPageReceiveAudio(page) + await test.step('wait for events and audio', async () => { + await expectInitialEvents + await expectPageReceiveAudio(page) - await expectCFFinalEvents(page) + await expectFinalEvents + }) }) test('should dial an address and expect a hangup', async ({ diff --git a/internal/e2e-client/tests/callfabric/utils.spec.ts b/internal/e2e-client/tests/callfabric/utils.spec.ts index 370964fa3..fc2ff9fe7 100644 --- a/internal/e2e-client/tests/callfabric/utils.spec.ts +++ b/internal/e2e-client/tests/callfabric/utils.spec.ts @@ -1,4 +1,9 @@ -import { expectPageEvalToPass, expectToPass, SERVER_URL } from '../../utils' +import { + expectPageEvalToPass, + expectToPass, + SERVER_URL, + waitForFunction, +} from '../../utils' import { test, expect } from '../../fixtures' test.describe('utils', () => { @@ -100,29 +105,54 @@ test.describe('utils', () => { // Pass on 3rd attempt }, { message: 'should use custom intervals' }, - { interval: [100, 200, 300], timeout: 10000 } + { intervals: [100, 200, 300], timeout: 10000 } ) expect(attemptCount).toBe(3) expect(attempts.length).toBe(3) }) - test('should use default options when none provided', async () => { + test('default behavior is not to poll/retry if timeout and intervals are same', async () => { + let attemptCount = 0 + await expect( + expectToPass( + async () => { + attemptCount++ + if (attemptCount < 2) { + // expected exception which will not trigger polling as timeout and intervals are the same + throw new Error('Test error') + } + }, + { + message: + 'Test error - should not retry (poll) if timeout and intervals are same', + } + ) + ).rejects.toThrow( + 'Test error - should not retry (poll) if timeout and intervals are same' + ) + + expect(attemptCount).toBe(1) + }) + + test('should retry until the predicate passes if intervals are provided', async () => { let attemptCount = 0 await expectToPass( async () => { attemptCount++ - if (attemptCount < 2) { - throw new Error('Not ready yet') + if (attemptCount < 10) { + throw new Error('Test error - should retry if polling is enabled') } - // Pass on 2nd attempt }, - { message: 'should use defaults' } - // No options parameter + { + message: + 'should retry until the predicate passes if intervals are provided', + }, + { intervals: [10] } ) - expect(attemptCount).toBe(2) + expect(attemptCount).toBe(10) }) test('should handle immediate success without retries', async () => { @@ -173,28 +203,164 @@ test.describe('utils', () => { expect(attemptCount).toBeGreaterThan(1) }, { message: 'should handle longer polling' }, - { timeout: 5000 } + { timeout: 5000, intervals: [1000] } + ) + + expect(attemptCount).toBeGreaterThan(1) + }) + + test('should only poll once if the intervals and timeout are the same', async () => { + let attemptCount = 0 + try { + await expectToPass( + async () => { + attemptCount++ + if (attemptCount < 2) { + // when rejected, expectToPass will poll again + return Promise.reject(new Error('Not ready yet')) + } + }, + { + message: + 'should only poll once if the intervals and timeout are the same', + }, + { timeout: 1000, intervals: [1000] } + ) + } catch (error) {} + + expect(attemptCount).toBe(1) + }) + + test('it should poll at least twice if the intervals are different from the timeout', async () => { + let attemptCount = 0 + await expectToPass( + async () => { + attemptCount++ + if (attemptCount < 3) { + // when rejected, expectToPass will poll again + return Promise.reject(new Error('Not ready yet')) + } + return + }, + { + message: + 'should poll at least twice if the intervals are different from the timeout', + }, + { timeout: 1000, intervals: [10, 10, 20] } ) expect(attemptCount).toBeGreaterThan(1) + expect(attemptCount).toBe(3) }) }) }) 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.setTimeout(5000) + test('should resolve when the function returns a truthy value', async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: '[page]' }) + const resultJSHandle = await waitForFunction(page, { + evaluateFn: async () => { + return await new Promise((resolve) => { + setTimeout(() => { + resolve(true) + }, 1000) + }) + }, + message: 'should resolve when the function returns a truthy value', + }) + expect(await resultJSHandle.jsonValue()).toBe(true) + }) + + test('should timeout if the function never returns truthy', async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: '[page]' }) + expect( + waitForFunction(page, { + evaluateFn: async () => { + return await new Promise((resolve) => { + setTimeout(() => { + resolve(false) + }, 1000) + }) + }, + }) + ).rejects.toThrow( + 'waitForFunction: Error: page.waitForFunction: Test ended.' ) }) - test('TODO: should timeout if the function never returns truthy', async () => { - test.skip(true, 'TODO: Implement test for waitForFunction timeout behavior') + test('should pass arguments to the page function', async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: '[page]' }) + const resultJSHandle = await waitForFunction(page, { + evaluateArgs: { param: 'test' }, + evaluateFn: (args: { param: string }) => { + return args.param + }, + message: 'should pass arguments to the page function', + }) + expect(await resultJSHandle.jsonValue()).toBe('test') + }) + + test('should poll with polling parameter until the function returns a truthy value', async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: '[page]' }) + const resultJSHandle = await waitForFunction(page, { + evaluateFn: () => { + interface Window extends Global { + attemptCount: number + } + const win = window as unknown as Window + if (win.attemptCount === undefined) { + win.attemptCount = 0 + } + if (win.attemptCount < 10) { + win.attemptCount += 1 + } + + // will poll until the attemptCount is 10 as the waitForFunction needs to return a truthy value + return win.attemptCount === 10 ? win.attemptCount : false + }, + message: 'should poll until the function returns a truthy value', + polling: 10, + timeoutMs: 1000, + }) + expect(await resultJSHandle.jsonValue()).toBe(10) }) - test('TODO: should pass arguments to the page function', async () => { - test.skip(true, 'TODO: Implement test for waitForFunction argument passing') + test('should not poll if the polling and timeout are the same', async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: '[page]' }) + try { + await waitForFunction(page, { + evaluateFn: () => { + interface Window extends Global { + attemptCount: number + } + const win = window as unknown as Window + if (win.attemptCount === undefined) { + win.attemptCount = 0 + } + win.attemptCount += 1 + return win.attemptCount === 10 ? win.attemptCount : false + }, + message: 'should not poll if the polling and timeout are the same', + polling: 1000, + timeoutMs: 1000, + }) + } catch (error) { + // should throw a timeout error as the truthy value is not returned within the timeout + expect(error.message).toContain( + 'TimeoutError: page.waitForFunction: Timeout 1000ms exceeded' + ) + } }) }) @@ -310,4 +476,149 @@ test.describe('expectPageEvalToPass', () => { 'timeout - should timeout when page evaluation takes too long' ) }) + + test('should poll at multiple times if the intervals are different from the timeout', async ({ + createCustomPage, + }) => { + let attemptCount = 0 + const page = await createCustomPage({ name: '[page]' }) + + const result = await expectPageEvalToPass(page, { + evaluateArgs: { + attemptCount, + }, + evaluateFn: () => { + return 'anything' + }, + assertionFn: () => { + // increment attemptCount when the assertionFn is called + attemptCount++ + // this assertion should fail and trigger the polling until it succeeds + expect(attemptCount).toBe(10) + }, + message: + 'should poll at least twice if the intervals are different from the timeout', + intervals: [10], + timeoutMs: 1000, + }) + + // should return the result of the evaluateFn + expect(result).toBe('anything') + }) + + test('it should poll only once if the intervals and timeout are the same', async ({ + createCustomPage, + }) => { + let attemptCount = 0 + const page = await createCustomPage({ name: '[page]' }) + try { + await expectPageEvalToPass(page, { + evaluateFn: () => { + return 'anything' + }, + assertionFn: (result) => { + // should only be called once + attemptCount++ + // this assertion should fail and trigger the polling + expect(result).toBe('should not resolve') + }, + message: + 'should poll only once if the intervals and timeout are the same', + intervals: [1000], + timeoutMs: 1000, + }) + } catch (error) { + // should throw an error due to the timeout + expect(error.message).toContain( + 'Timeout 1000ms exceeded while waiting on the predicate' + ) + } + expect(attemptCount).toBe(1) + }) + + test('should pass with serializable results', async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: '[page]' }) + + // Test various serializable return types + const serializableResults = [ + { value: 'string', expected: 'string' }, + { value: 42, expected: 42 }, + { value: true, expected: true }, + { value: null, expected: null }, + { value: { key: 'value' }, expected: { key: 'value' } }, + { value: [1, 2, 3], expected: [1, 2, 3] }, + { + value: { nested: { data: [1, 2] } }, + expected: { nested: { data: [1, 2] } }, + }, + ] + + for (const { value, expected } of serializableResults) { + const result = await expectPageEvalToPass(page, { + evaluateArgs: value, + evaluateFn: (val) => val, + assertionFn: (result) => { + expect(result).toEqual(expected) + }, + message: `should pass with serializable ${typeof value} result`, + }) + expect(result).toEqual(expected) + } + }) + + test.fixme('should throw error for non-serializable results', async ({}) => { + // TODO: add tests for non-serializable results + }) + + test('should accept types that Playwright converts', async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: '[page]' }) + + // Test function return (gets converted to undefined) + const functionResult = await expectPageEvalToPass(page, { + evaluateFn: () => () => 'I am a function', + assertionFn: (result) => { + expect(result).toBeUndefined() + }, + message: 'should accept function result', + }) + expect(functionResult).toBeUndefined() + + // Test Symbol return (gets converted to undefined) + const symbolResult = await expectPageEvalToPass(page, { + evaluateFn: () => Symbol('test'), + assertionFn: (result) => { + expect(result).toBeUndefined() + }, + message: 'should accept symbol result', + }) + expect(symbolResult).toBeUndefined() + + // Test Date return (gets returned as a Date object) + const dateResult = await expectPageEvalToPass(page, { + evaluateFn: () => new Date('2023-01-01'), + assertionFn: (result) => { + expect(typeof result).toBe('object') + expect(result).toBeInstanceOf(Date) + expect((result as Date).toISOString()).toBe('2023-01-01T00:00:00.000Z') + }, + message: 'should accept Date result', + }) + expect((dateResult as Date).toISOString()).toBe('2023-01-01T00:00:00.000Z') + + // Test RegExp return (gets returned as RegExp object) + const regExpResult = await expectPageEvalToPass(page, { + evaluateFn: () => /test/gi, + assertionFn: (result) => { + expect(typeof result).toBe('object') + expect(result).toBeInstanceOf(RegExp) + expect(String(result)).toBe('/test/gi') + }, + message: 'should accept RegExp result', + }) + expect(regExpResult).toBeInstanceOf(RegExp) + }) }) diff --git a/internal/e2e-client/utils.ts b/internal/e2e-client/utils.ts index 7f77adfb2..399307448 100644 --- a/internal/e2e-client/utils.ts +++ b/internal/e2e-client/utils.ts @@ -9,10 +9,11 @@ import type { MediaEventNames } from '@signalwire/webrtc' import { createServer } from 'vite' import path from 'path' import { expect } from './fixtures' -import type { Page } from '@playwright/test' +import type { Page, TestInfo } 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 { TestContext, SDKEvent, EventStats } from './TestContext' import { Server } from 'http' import { spawn, ChildProcessWithoutNullStreams } from 'child_process' import { EventEmitter } from 'events' @@ -366,10 +367,56 @@ interface CreateCFClientParams { reference?: string } +// Helper function to set up WebSocket monitoring for TestContext +export const setupWebSocketMonitoring = ( + page: Page, + testContext: TestContext +) => { + page.on('websocket', (ws) => { + console.log(`WebSocket connected: ${ws.url()}`) + + ws.on('framesent', (event) => { + try { + const payloadStr = + typeof event.payload === 'string' + ? event.payload + : event.payload.toString() + const payload = JSON.parse(payloadStr) + testContext.addSDKEvent(payload, 'send') + } catch (error) { + console.warn('Failed to parse sent frame:', error) + } + }) + + ws.on('framereceived', (event) => { + try { + const payloadStr = + typeof event.payload === 'string' + ? event.payload + : event.payload.toString() + const payload = JSON.parse(payloadStr) + testContext.addSDKEvent(payload, 'recv') + } catch (error) { + console.warn('Failed to parse received frame:', error) + } + }) + + ws.on('close', () => { + console.log('WebSocket disconnected') + testContext.addSDKEvent( + { + event: 'websocket_closed', + }, + 'recv' + ) + }) + }) +} + export const createCFClient = async ( page: Page, params?: CreateCFClientParams -) => { +): Promise => { const sat = await createTestSATToken(params?.reference) expect(sat, 'SAT token created').toBeDefined() return createCFClientWithToken(page, sat, params) @@ -379,7 +426,7 @@ export const createGuestCFClient = async ( page: Page, bodyData: GuestSATTokenRequest, params?: CreateCFClientParams -) => { +): Promise => { const sat = await createGuestSATToken(bodyData) return createCFClientWithToken(page, sat, params) } @@ -388,7 +435,7 @@ const createCFClientWithToken = async ( page: Page, sat: string | null, params?: CreateCFClientParams -) => { +): Promise => { if (!sat) { console.error('Invalid SAT. Exiting..') process.exit(4) @@ -445,6 +492,7 @@ const createCFClientWithToken = async ( host: options.RELAY_HOST, token: options.API_TOKEN, debug: { logWsTraffic: true }, + logLevel: 'debug', ...(options.attachSagaMonitor && { sagaMonitor }), }) @@ -470,7 +518,7 @@ interface DialAddressParams { timeoutMs?: number } -export const dialAddress = ( +export const dialAddress = async ( page: Page, params: DialAddressParams = { address: '', @@ -481,7 +529,7 @@ export const dialAddress = ( shouldWaitForJoin: true, timeoutMs: 15000, } -) => { +): Promise => { const defaultParams: DialAddressParams = { address: '', dialOptions: {}, @@ -497,66 +545,112 @@ export const dialAddress = ( ...params, } - type EvaluateArgs = Omit & { - dialOptions: string - } + let joinEventPromise: Promise | null = null - return expectPageEvalToPass(page, { + // Step 1: Create call object and assign to window._callObj + await expectPageEvalToPass(page, { evaluateArgs: { address: mergedParams.address, dialOptions: JSON.stringify(mergedParams.dialOptions), reattach: mergedParams.reattach, shouldPassRootElement: mergedParams.shouldPassRootElement, - shouldStartCall: mergedParams.shouldStartCall, - shouldWaitForJoin: mergedParams.shouldWaitForJoin, }, evaluateFn: async ({ address, dialOptions, reattach, shouldPassRootElement, - shouldStartCall, - shouldWaitForJoin, }) => { - return new Promise(async (resolve, _reject) => { - if (!window._client) { - throw new Error('Client is not defined') - } - const client: SignalWireContract = window._client + if (!window._client) { + throw new Error('Client is not defined') + } + const client = window._client - const dialer = reattach ? client.reattach : client.dial + const dialer = reattach ? client.reattach : client.dial - const call = await dialer({ - to: address, - ...(shouldPassRootElement && { - rootElement: document.getElementById('rootElement')!, - }), - ...JSON.parse(dialOptions), - }) + const call = await dialer({ + to: address, + ...(shouldPassRootElement && { + rootElement: document.getElementById('rootElement')!, + }), + ...JSON.parse(dialOptions), + }) - if (shouldWaitForJoin) { - call.on('room.joined', (params) => { - resolve(params) + window._callObj = call + return true + }, + assertionFn: (result) => { + expect(result, 'call object should be created and assigned').toBe(true) + }, + message: 'expect to create call object and assign to window._callObj', + }) + + // Step 2: Set up room.joined event listener (if shouldWaitForJoin) + if (mergedParams.shouldWaitForJoin) { + joinEventPromise = expectPageEvalToPass<{}, TReturn>(page, { + evaluateFn: () => { + return new Promise((resolve) => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') + } + + callObj.on('room.joined', (params) => { + resolve(params as TReturn) }) - } + }) + }, + assertionFn: (result) => { + expect(result, 'room.joined event should be received').toBeDefined() + }, + timeoutMs: mergedParams.timeoutMs, + message: 'expect to receive room.joined event', + }) + } - window._callObj = call + // Step 3: Start the call (if shouldStartCall) + if (mergedParams.shouldStartCall) { + await expectPageEvalToPass(page, { + evaluateFn: async () => { + const callObj = window._callObj - if (shouldStartCall) { - await call.start() + if (!callObj) { + throw new Error('Call object not found') } - if (!shouldWaitForJoin) { - resolve(call) + await callObj.start() + return true + }, + assertionFn: (result) => { + expect(result, 'call should start successfully').toBe(true) + }, + message: 'expect to start the call', + }) + } + + // Step 4: Return appropriate result + if (mergedParams.shouldWaitForJoin && joinEventPromise) { + // Wait for the room.joined event and return the params + return await joinEventPromise + } else { + // Return the call object + return await expectPageEvalToPass<{}, TReturn>(page, { + evaluateFn: () => { + const callObj = window._callObj + + if (!callObj) { + throw new Error('Call object not found') } - }) - }, - assertionFn: (result) => { - expect(result, 'dialAddress result should be defined').toBeDefined() - }, - timeoutMs: mergedParams.timeoutMs, - message: 'expect dialAddress to succeed', - }) + + return callObj as TReturn + }, + assertionFn: (result) => { + expect(result, 'call object should be returned').toBeDefined() + }, + message: 'expect to return call object', + }) + } } export const reloadAndReattachAddress = async ( @@ -1726,24 +1820,32 @@ export const expectCFInitialEvents = ( return Promise.all([initialEvents, ...extraEvents]) } -export const expectCFFinalEvents = ( +export const expectCFFinalEvents = async ( page: Page, - extraEvents: Promise[] = [] + extraEvents: Promise[] = [], + { timeoutMs }: { timeoutMs?: number } = {} ) => { - const finalEvents = page.evaluate(async () => { - const callObj = window._callObj - if (!callObj) { - throw new Error('Call object not found') - } - - const callLeft = new Promise((resolve) => { - callObj.on('destroy', () => resolve(true)) - }) + const finalEvents = expectPageEvalToPass(page, { + evaluateFn: () => { + return new Promise((resolve) => { + const callObj = window._callObj + if (!callObj) { + throw new Error('Call object not found') + } - return callLeft + callObj.on('destroy', () => { + resolve(true) + }) + }) + }, + assertionFn: (result) => { + expect(result, 'expect call to emit destroy event').toBe(true) + }, + message: 'expect call to emit destroy event', + ...(timeoutMs && { timeoutMs }), }) - return Promise.all([finalEvents, ...extraEvents]) + return await Promise.all([finalEvents, ...extraEvents]) } export const expectLayoutChanged = async (page: Page, layoutName: string) => { @@ -1860,10 +1962,10 @@ export const expectMemberId = async (page: Page, memberId: string) => { export const expectToPass = async ( assertion: () => Promise, assertionMessage: string | { message: string }, - options?: { interval?: number[]; timeout?: number } + options?: { intervals?: number[]; timeout?: number } ) => { const mergedOptions = { - interval: [10_000], // 10 seconds to avoid polling + intervals: [10_000], // 10 seconds to avoid polling timeout: 10_000, ...options, } @@ -1888,29 +1990,29 @@ export const waitForFunction = async ( { evaluateArgs, evaluateFn, + polling, message, - interval = [10_000], - timeoutMs = 10_000, + timeoutMs, }: { evaluateArgs?: TArgs evaluateFn: PageFunction - message: string - interval?: number[] + message?: string + polling?: number timeoutMs?: number } ) => { try { const mergedOptions = { - interval: interval ?? [10_000], // 10 seconds to avoid polling + polling: polling ?? 10_000, // 10 seconds to avoid polling timeout: timeoutMs ?? 10_000, - message, - } + } satisfies Parameters[2] if (evaluateArgs) { return await page.waitForFunction(evaluateFn, evaluateArgs, mergedOptions) } else { // FIXME: remove the type assertion return await page.waitForFunction( evaluateFn as PageFunction, + undefined, mergedOptions ) } @@ -1943,36 +2045,155 @@ export const expectPageEvalToPass = async ( evaluateArgs, evaluateFn, message, - interval = [10_000], + intervals = [10_000], timeoutMs = 10_000, }: { assertionFn: (result: TResult) => void evaluateArgs?: TArgs evaluateFn: PageFunction message: string - interval?: number[] + intervals?: 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) - } + result = await page.evaluate( + // TODO: check if the result is serializable in the browser context + evaluateFn as PageFunction, + evaluateArgs + ) assertionFn(result) }, { message: message }, - { timeout: timeoutMs, interval: interval } + { timeout: timeoutMs, intervals: intervals } ) return result } + +// #region TestContext Utilities + +export interface ContextData { + stats: EventStats + events: SDKEvent[] + testDuration: number +} + +// Helper function to register a test context (to be called from tests) +export const attachTestContext = async ( + testInfo: TestInfo, + testContext: TestContext +) => { + try { + const contextData = { + stats: testContext.getStats(), + events: testContext.getAllEvents(), + testDuration: testContext.getTestDuration(), + } + + // Log to console immediately for all environments + const dumpContent = createContextDumpText(contextData) + console.log('\n[INFO] SDK Test Context captured:') + console.log(dumpContent) + + // Attach as JSON data for Playwright UI + await testInfo.attach('SDK Test Context (JSON)', { + body: JSON.stringify(contextData, null, 2), + contentType: 'application/json', + }) + + // Attach as human-readable text dump for Playwright UI + await testInfo.attach('SDK Test Context', { + body: dumpContent, + contentType: 'text/plain', + }) + } catch (error) { + console.log(`Failed to attach test context: ${error}`) + } +} + +// Create human-readable context dump text +export const createContextDumpText = (contextData: ContextData) => { + const stats = contextData.stats + let dump = '' + + dump += '* '.repeat(40) + '\n' + dump += 'SDK TEST CONTEXT DUMP\n' + dump += '* '.repeat(40) + '\n\n' + + // Summary stats + dump += 'EVENT SUMMARY:\n' + dump += ` Total Events: ${stats.totalEvents}\n` + dump += ` Sent: ${stats.sentEvents} | Received: ${stats.receivedEvents}\n` + dump += ` Errors: ${stats.errorEvents} | Call: ${stats.callEvents} | Room: ${stats.roomEvents}\n` + dump += ` Connection: ${stats.connectionEvents}\n` + dump += ` Test Duration: ${contextData.testDuration}ms\n\n` + + // Show error events if any + const errorEvents = contextData.events.filter( + (event) => event.context.isError + ) + if (errorEvents.length > 0) { + dump += 'ERROR EVENTS:\n' + errorEvents.forEach((event, index) => { + const time = new Date(event.timestamp).toISOString().substring(11, 23) + dump += ` ${index + 1}. ${time} [${event.direction.toUpperCase()}] ${ + event.eventType + }\n` + dump += ` Error: ${ + event.payload.error?.message || 'Unknown error' + }\n` + if (event.payload.error?.code) { + dump += ` Code: ${event.payload.error.code}\n` + } + }) + dump += '\n' + } + + // Show recent events timeline + dump += 'RECENT EVENTS (Last 15):\n' + const recentEvents = contextData.events.slice(-15) + recentEvents.forEach((event, index) => { + const time = new Date(event.timestamp).toISOString().substring(11, 23) + const direction = event.direction === 'send' ? 'SEND' : 'RECV' + + dump += ` ${(index + 1).toString().padStart(2)}. ${time} [${direction}] ${ + event.eventType + }\n` + + if (event.context.isCallEvent && event.context.callId) { + dump += ` Call ID: ${event.context.callId}\n` + } + if (event.context.isRoomEvent && event.context.roomId) { + dump += ` Room ID: ${event.context.roomId}\n` + } + if (event.context.isError) { + dump += ` Error: ${ + event.payload.error?.message || 'Error occurred' + }\n` + } + }) + dump += '\n' + + // Show detailed payload for last few events + if (stats.totalEvents > 0) { + dump += 'LAST EVENT DETAILS:\n' + const lastEvents = contextData.events.slice(-3) + lastEvents.forEach((event, index) => { + dump += ` ${index + 1}. [${event.direction.toUpperCase()}] ${ + event.eventType + }:\n` + dump += ` ${JSON.stringify(event.payload, null, 6)}\n` + }) + } + + dump += '\n' + '* '.repeat(40) + '\n' + + return dump +} + +// #endregion