diff --git a/internal/e2e-js/SDKReporter.ts b/internal/e2e-js/SDKReporter.ts new file mode 100644 index 000000000..c4c1936db --- /dev/null +++ b/internal/e2e-js/SDKReporter.ts @@ -0,0 +1,150 @@ +import type { + Reporter, + FullConfig, + Suite, + TestCase, + TestResult, + FullResult, + TestError, + TestStep, +} from '@playwright/test/reporter' + +/** + * A custom SDK reporter that implements Playwright Reporter interface methods. + */ +export default class SDKReporter implements Reporter { + private totalTests = 0 + private completedTests = 0 + private failedTests = 0 + private passedTests = 0 + private skippedTests = 0 + private timedOutTests = 0 + + /** + * Called once before running tests. + */ + onBegin(_config: FullConfig, suite: Suite): void { + this.totalTests = suite.allTests().length + console.log('============================================') + console.log(`Starting the run with ${this.totalTests} tests...`) + console.log('============================================') + } + + /** + * Called after all tests have run, or the run was interrupted. + */ + async onEnd(result: FullResult): Promise { + console.log('\n\n') + console.log('============================================') + console.log(`Test run finished with status: ${result.status.toUpperCase()}`) + console.log('--------------------------------------------') + console.log(`Total Tests: ${this.totalTests}`) + console.log(`Passed: ${this.passedTests}`) + console.log(`Failed: ${this.failedTests}`) + console.log(`Skipped: ${this.skippedTests}`) + console.log(`Timed Out: ${this.timedOutTests}`) + console.log('============================================') + console.log('\n\n') + } + + /** + * Called on a global error, for example an unhandled exception in the test. + */ + onError(error: TestError): void { + console.log('============================================') + console.log(`Global Error: ${error.message}`) + console.log(error) + console.log('============================================') + } + + /** + * Called immediately before the test runner exits, after onEnd() and all + * reporters have finished. + * If required: upload logs to a server here. + */ + async onExit(): Promise { + console.log('[SDKReporter] Exit') + } + + /** + * Called when a test step (i.e., `test.step(...)`) begins in the worker. + */ + onStepBegin(_test: TestCase, _result: TestResult, step: TestStep): void { + /** + * Playwright creates some internal steps as well. + * We do not care about those steps. + * We only log our own custom test steps. + */ + if (step.category === 'test.step') { + console.log(`--- STEP BEGIN: "${step.title}"`) + } + } + + /** + * Called when a test step finishes. + */ + onStepEnd(_test: TestCase, _result: TestResult, step: TestStep): void { + if (step.category === 'test.step') { + if (step.error) { + console.log(`--- STEP FAILED: "${step.title}"`) + console.log(step.error) + } else { + console.log(`--- STEP FINISHED: "${step.title}"`) + } + } + } + + /** + * Called when a test begins in the worker process. + */ + onTestBegin(test: TestCase, _result: TestResult): void { + console.log('--------------------------------------------') + console.log(`⏯ī¸ Test Started: ${test.title}`) + console.log('--------------------------------------------') + } + + /** + * Called when a test ends (pass, fail, timeout, etc.). + */ + onTestEnd(test: TestCase, result: TestResult): void { + console.log('--------------------------------------------') + this.completedTests += 1 + switch (result.status) { + case 'passed': + this.passedTests += 1 + console.log(`✅ Test Passed: ${test.title}`) + break + case 'failed': + this.failedTests += 1 + console.log(`❌ Test Failed: ${test.title}`) + if (result.error) { + console.log(`📧 Error: ${result.error.message}`) + if (result.error.stack) { + console.log(`📚 Stack: ${result.error.stack}`) + } + } + break + case 'timedOut': + this.timedOutTests += 1 + console.log(`⏰ Test Timed Out: ${test.title}`) + break + case 'skipped': + this.skippedTests += 1 + console.log(`↩ī¸ Test Skipped: ${test.title}`) + break + default: + console.log(`Test Ended with status "${result.status}": ${test.title}`) + break + } + console.log('--------------------------------------------') + console.log('\n\n') + } + + /** + * Indicates this reporter does not handle stdout and stderr printing. + * So that Playwright print those logs. + */ + printsToStdio(): boolean { + return false + } +} diff --git a/internal/e2e-js/fixtures.ts b/internal/e2e-js/fixtures.ts index 05d72a8e6..5d93cdedb 100644 --- a/internal/e2e-js/fixtures.ts +++ b/internal/e2e-js/fixtures.ts @@ -57,7 +57,10 @@ const test = baseTest.extend({ try { await use(maker) } finally { + console.log('====================================') console.log('Cleaning up pages..') + console.log('====================================') + /** * If we have a __roomObj in the page means we tested the Video/Fabric APIs * so we must leave the room. @@ -75,6 +78,8 @@ const test = baseTest.extend({ * Make sure we cleanup the client as well. */ await Promise.all(context.pages().map(disconnectClient)) + + await context.close() } }, createCustomVanillaPage: async ({ context }, use) => { @@ -112,7 +117,10 @@ const test = baseTest.extend({ try { await use(resource) } finally { + console.log('====================================') console.log('Cleaning up resources..') + console.log('====================================') + // Clean up resources after use const deleteResources = resources.map(async (resource) => { try { diff --git a/internal/e2e-js/playwright.config.ts b/internal/e2e-js/playwright.config.ts index 605887cc5..64396b672 100644 --- a/internal/e2e-js/playwright.config.ts +++ b/internal/e2e-js/playwright.config.ts @@ -65,18 +65,19 @@ const useDesktopChrome = { const config: PlaywrightTestConfig = { testDir: 'tests', - reporter: process.env.CI ? 'github' : 'list', + reporter: [[process.env.CI ? 'github' : 'list'], ['./SDKReporter.ts']], globalSetup: require.resolve('./global-setup'), testMatch: undefined, testIgnore: undefined, timeout: 120_000, + workers: 1, + maxFailures: 1, expect: { // Default is 5000 timeout: 10_000, }, // Forbid test.only on CI forbidOnly: !!process.env.CI, - workers: 1, projects: [ { name: 'default', diff --git a/internal/e2e-js/tests/roomSessionDemotePromote.spec.ts b/internal/e2e-js/tests/roomSessionDemotePromote.spec.ts index fd945d49d..7d80bdea1 100644 --- a/internal/e2e-js/tests/roomSessionDemotePromote.spec.ts +++ b/internal/e2e-js/tests/roomSessionDemotePromote.spec.ts @@ -137,7 +137,7 @@ test.describe('RoomSession demote participant and then promote again', () => { await pageTwo.waitForTimeout(1000) - // --------------- Promote audience from pageOne and resolve on `member.joined` --------------- + // --------------- Promote audience from pageOne and resolve on `member.joined` and `room.joined` --------------- const promiseMemberWaitingForMemberJoin = pageOne.evaluate( async ({ promoteMemberId }) => { // @ts-expect-error diff --git a/packages/js/src/utils/interfaces/base.ts b/packages/js/src/utils/interfaces/base.ts index db83ce30e..b36375d08 100644 --- a/packages/js/src/utils/interfaces/base.ts +++ b/packages/js/src/utils/interfaces/base.ts @@ -16,9 +16,9 @@ export interface BaseRoomSessionContract { */ screenShareList: RoomSessionScreenShare[] /** - * Leaves the room. This detaches all the locally originating streams from the room. + * Leaves the room immediately. This detaches all the locally originating streams from the room. */ - leave(): Promise + leave(): void /** * Return the member overlay on top of the root element */ diff --git a/packages/webrtc/src/BaseConnection.ts b/packages/webrtc/src/BaseConnection.ts index a431c6817..e7e06e40c 100644 --- a/packages/webrtc/src/BaseConnection.ts +++ b/packages/webrtc/src/BaseConnection.ts @@ -195,19 +195,34 @@ export class BaseConnection< this.logger.debug('Set RTCPeer', rtcPeer.uuid, rtcPeer) this.rtcPeerMap.set(rtcPeer.uuid, rtcPeer) + const setActivePeer = (peerId: string) => { + this.logger.debug('>>> Replace active RTCPeer with', peerId) + this.activeRTCPeerId = peerId + } + + /** + * In case of the promote/demote, a new peer is created. + * Hence, we hangup the old peer. + */ if (this.peer && this.peer.instance && this.callId !== rtcPeer.uuid) { const oldPeerId = this.peer.uuid this.logger.debug('>>> Stop old RTCPeer', oldPeerId) - // Hangup the previous RTCPeer - this.hangup(oldPeerId).catch(console.error) + + // Stop transceivers and then the Peer this.peer.detachAndStop() + // Set the new peer as active peer + setActivePeer(rtcPeer.uuid) + + // Send "verto.bye" to the server + this.hangup(oldPeerId) + // Remove RTCPeer from local cache to stop answering to ping/pong // this.rtcPeerMap.delete(oldPeerId) + } else { + // Set the new peer as active peer + setActivePeer(rtcPeer.uuid) } - - this.logger.debug('>>> Replace RTCPeer with', rtcPeer.uuid) - this.activeRTCPeerId = rtcPeer.uuid } // Overload for BaseConnection events @@ -357,8 +372,18 @@ export class BaseConnection< try { this.logger.debug('Build a new RTCPeer') const rtcPeer = this._buildPeer('offer') - this.logger.debug('Trigger start for the new RTCPeer!') + this.logger.debug('Trigger start for the new RTCPeer!', rtcPeer.uuid) await rtcPeer.start() + + /** + * Ideally, the SDK set the active peer when the `room.subscribed` or + * `verto.display` event is received. However, in some cases, while + * promoting/demoting, the RTC Peer negotiates successfully but then + * starts the negotiation again. + * So, without waiting for the events, we can safely set the active + * Peer once the initial negotiation succeeds. + */ + this.setActiveRTCPeer(rtcPeer.uuid) } catch (error) { this.logger.error('Error building new RTCPeer to promote/demote', error) } @@ -814,10 +839,19 @@ export class BaseConnection< } this.logger.debug('UpdateMedia response', response) - if (!this.peer) { + + /** + * At a time, there can be multiple RTC Peers. + * The {@link executeUpdateMedia} is called with a Peer ID + * We need to make sure we set the remote SDP coming from the server + * on the appropriate Peer. + * The appropriate Peer may or may not be the current/active (this.peer) one. + */ + const peer = this.getRTCPeerById(rtcPeerId) + if (!peer) { return this.logger.error('Invalid RTCPeer to updateMedia') } - await this.peer.onRemoteSdp(response.sdp) + await peer.onRemoteSdp(response.sdp) } catch (error) { this.logger.error('UpdateMedia error', error) // this.setState('hangup') @@ -825,7 +859,7 @@ export class BaseConnection< } } - async hangup(id?: string) { + hangup(id?: string) { const rtcPeerId = id ?? this.callId if (!rtcPeerId) { throw new Error('Invalid RTCPeer ID to hangup') @@ -833,7 +867,11 @@ export class BaseConnection< try { const message = VertoBye(this.dialogParams(rtcPeerId)) - await this.vertoExecute({ + /** + * Fire-and-Forget + * For privacy reasons, the user should be allowed to leave the call immediately. + */ + this.vertoExecute({ message, callID: rtcPeerId, node_id: this.nodeId,