diff --git a/.changeset/red-turkeys-rest.md b/.changeset/red-turkeys-rest.md new file mode 100644 index 000000000..2a4d220e8 --- /dev/null +++ b/.changeset/red-turkeys-rest.md @@ -0,0 +1,7 @@ +--- +'@signalwire/realtime-api': minor +'@signalwire/core': minor +'@signalwire/js': minor +--- + +Introduce the hand raise API for the Video SDKs (browser and realtime-api) diff --git a/internal/e2e-js/tests/roomSessionRaiseHand.spec.ts b/internal/e2e-js/tests/roomSessionRaiseHand.spec.ts new file mode 100644 index 000000000..2d4795b73 --- /dev/null +++ b/internal/e2e-js/tests/roomSessionRaiseHand.spec.ts @@ -0,0 +1,146 @@ +import type { Video } from '@signalwire/js' +import { test, expect } from '../fixtures' +import { + SERVER_URL, + createTestRoomSession, + randomizeRoomName, + expectRoomJoined, + expectMCUVisible, +} from '../utils' + +test.describe('RoomSession Raise/Lower hand', () => { + test('should join a room and be able to set hand prioritization', async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: 'raise-lower' }) + await page.goto(SERVER_URL) + + const roomName = randomizeRoomName('raise-lower-e2e') + const memberSettings = { + vrt: { + room_name: roomName, + user_name: 'e2e_participant_meta', + auto_create_room: true, + permissions: ['room.prioritize_handraise'], + }, + initialEvents: ['room.updated'], + } + + await createTestRoomSession(page, memberSettings) + + // --------------- Joining the room --------------- + const joinParams = await expectRoomJoined(page) + + expect(joinParams.room).toBeDefined() + expect(joinParams.room_session).toBeDefined() + expect(joinParams.room.name).toBe(roomName) + expect(joinParams.room.prioritize_handraise).toBe(false) + + // Checks that the video is visible + await expectMCUVisible(page) + + // --------------- Set hand raise priority --------------- + await page.evaluate( + async ({ roomSessionId }) => { + // @ts-expect-error + const roomObj: Video.RoomSession = window._roomObj + + const roomUpdated = new Promise((resolve) => { + roomObj.on('room.updated', (params) => { + if ( + params.room_session.id === roomSessionId && + params.room_session.prioritize_handraise == true + ) { + resolve(true) + } + }) + }) + + await roomObj.setPrioritizeHandraise(true) + + return roomUpdated + }, + { roomSessionId: joinParams.room_session.id } + ) + }) + + test("should join a room and be able to raise/lower member's hand", async ({ + createCustomPage, + }) => { + const page = await createCustomPage({ name: 'raise-lower' }) + await page.goto(SERVER_URL) + + const roomName = randomizeRoomName('raise-lower-e2e') + const memberSettings = { + vrt: { + room_name: roomName, + user_name: 'e2e_participant_meta', + auto_create_room: true, + permissions: ['room.member.raisehand', 'room.member.lowerhand'], + }, + initialEvents: ['member.joined', 'member.updated', 'member.left'], + } + + await createTestRoomSession(page, memberSettings) + + // --------------- Joining the room --------------- + const joinParams = await expectRoomJoined(page) + + expect(joinParams.room).toBeDefined() + expect(joinParams.room_session).toBeDefined() + expect(joinParams.room.name).toBe(roomName) + + // Checks that the video is visible + await expectMCUVisible(page) + + // --------------- Raise a member's hand --------------- + await page.evaluate( + async ({ roomSessionId }) => { + // @ts-expect-error + const roomObj: Video.RoomSession = window._roomObj + + const memberUpdated = new Promise((resolve) => { + roomObj.on('member.updated', (params) => { + if ( + params.room_session_id === roomSessionId && + params.member.handraised == true + ) { + resolve(true) + } + }) + }) + + await roomObj.setRaisedHand() + + return memberUpdated + }, + { roomSessionId: joinParams.room_session.id } + ) + + await page.waitForTimeout(1000) + + // --------------- Lower a member's hand --------------- + await page.evaluate( + async ({ roomSessionId }) => { + // @ts-expect-error + const roomObj: Video.RoomSession = window._roomObj + + const memberUpdated = new Promise((resolve) => { + roomObj.on('member.updated', (params) => { + if ( + params.room_session_id === roomSessionId && + params.member.handraised == false + ) { + resolve(true) + } + }) + }) + + await roomObj.setRaisedHand({ raised: false }) + + return memberUpdated + }, + { roomSessionId: joinParams.room_session.id } + ) + }) +}) diff --git a/internal/e2e-realtime-api/src/playwright/video.test.ts b/internal/e2e-realtime-api/src/playwright/video.test.ts index f4fdc16fc..e052d0fdf 100644 --- a/internal/e2e-realtime-api/src/playwright/video.test.ts +++ b/internal/e2e-realtime-api/src/playwright/video.test.ts @@ -1,7 +1,12 @@ import { test, expect } from '@playwright/test' import { uuid } from '@signalwire/core' import { Video } from '@signalwire/realtime-api' -import { createNewTabRoomSession } from './videoUtils' +import { + createRoomAndRecordPlay, + createRoomSession, + enablePageLogs, +} from './videoUtils' +import { SERVER_URL } from '../../utils' test.describe('Video', () => { test('should join the room and listen for events', async ({ browser }) => { @@ -40,7 +45,7 @@ test.describe('Video', () => { let roomSessionPromises: Promise[] = [] for (let index = 0; index < roomCount; index++) { roomSessionPromises.push( - createNewTabRoomSession({ + createRoomAndRecordPlay({ browser, pageName: `[page-${index}]`, room_name: `${prefix}-${index}`, @@ -123,4 +128,90 @@ test.describe('Video', () => { expect(roomSessionCreated.size).toBe(roomCount) expect(roomSessionsAtEnd).toHaveLength(roomCount) }) + + test('should join the room and set hand raise priority', async ({ + browser, + }) => { + const page = await browser.newPage() + await page.goto(SERVER_URL) + enablePageLogs(page, '[pageOne]') + + // Create a realtime-api Video client + const videoClient = new Video.Client({ + // @ts-expect-error + host: process.env.RELAY_HOST, + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { logWsTraffic: true }, + }) + + const prefix = uuid() + const roomName = `${prefix}-hand-raise-priority-e2e` + + const findRoomSession = async () => { + const { roomSessions } = await videoClient.getRoomSessions() + return roomSessions.filter((r) => r.name.startsWith(prefix)) + } + + // Listen for realtime-api event + videoClient.on('room.started', (room) => { + room.on('room.updated', (room) => { + console.log('>> room.updated', room.name) + }) + }) + + // Room length should be 0 before start + const roomSessionsBeforeStart = await findRoomSession() + expect(roomSessionsBeforeStart).toHaveLength(0) + + // Create and join room on the web using JS SDK + await createRoomSession({ + page, + room_name: roomName, + user_name: `${prefix}-member`, + initialEvents: ['room.updated'], + }) + + // Room length should be 1 after start + const roomSessionsAfterStart = await findRoomSession() + expect(roomSessionsAfterStart).toHaveLength(1) + + const roomSessionNode = roomSessionsAfterStart[0] + + const roomSessionWeb = await page.evaluate(() => { + // @ts-expect-error + const roomSession = window._roomOnJoined + + return roomSession.room_session + }) + + // Hand raise is not prioritize on both Node & Web room session object + expect(roomSessionNode.prioritizeHandraise).toBe(false) + expect(roomSessionWeb.prioritize_handraise).toBe(false) + + const roomSessionWebUpdated = page.evaluate(() => { + return new Promise((resolve, _reject) => { + // @ts-expect-error + const roomSessionWeb = window._roomObj + + roomSessionWeb.on('room.updated', (room) => { + resolve(room.room_session) + }) + }) + }) + + // Set the hand raise prioritization via Node SDK + const roomSessionNodeUpdated = await new Promise( + async (resolve, _reject) => { + roomSessionNode.on('room.updated', (room) => { + resolve(room) + }) + await roomSessionNode.setPrioritizeHandraise(true) + } + ) + + // Expect hand raise prioritization to be true on both Node & Web SDK objects + expect(roomSessionNodeUpdated.prioritizeHandraise).toBe(true) + expect((await roomSessionWebUpdated).prioritize_handraise).toBe(true) + }) }) diff --git a/internal/e2e-realtime-api/src/playwright/videoHandRaise.test.ts b/internal/e2e-realtime-api/src/playwright/videoHandRaise.test.ts new file mode 100644 index 000000000..1964d8a3a --- /dev/null +++ b/internal/e2e-realtime-api/src/playwright/videoHandRaise.test.ts @@ -0,0 +1,141 @@ +import { test, expect, BrowserContext, Page } from '@playwright/test' +import { Video } from '@signalwire/realtime-api' +import { createRoomAndJoinTwoMembers, expectMemberUpdated } from './videoUtils' + +test.describe('Video room hand raise/lower', () => { + let pageOne: Page + let pageTwo: Page + let memberOne: Video.RoomSessionMember + let memberTwo: Video.RoomSessionMember + let roomSession: Video.RoomSession + + test.beforeAll(async ({ browser }) => { + const data = await createRoomAndJoinTwoMembers(browser) + pageOne = data.pageOne + pageTwo = data.pageTwo + memberOne = data.memberOne + memberTwo = data.memberTwo + roomSession = data.roomSession + }) + + test('should raise memberOne hand using room session instance via Node SDK', async () => { + // Expect no hand raise from both members + expect(memberOne.handraised).toBe(false) + expect(memberTwo.handraised).toBe(false) + + // Expect member.updated event on pageOne via Web SDK for memberOne + const memberOnePageOne = expectMemberUpdated({ + page: pageOne, + memberName: memberOne.name, + }) + + // Expect member.updated event on pageTwo via Web SDK for memberOne + const memberOnePageTwo = expectMemberUpdated({ + page: pageTwo, + memberName: memberOne.name, + }) + + // Raise a hand of memberOne using Node SDK + const memberOneUpdatedNode = await new Promise( + async (resolve, _reject) => { + roomSession.on('member.updated', (member) => { + if (member.name === memberOne.name) { + resolve(member) + } + }) + await roomSession.setRaisedHand({ memberId: memberOne.id }) + } + ) + + // Wait for member.updated events to be received on the Web SDK for both pages + const memberOnePageOneUpdatedWeb = await memberOnePageOne + const memberOnePageTwoUpdatedWeb = await memberOnePageTwo + + // Expect a hand raise to be true on both Node & Web SDKs for memberOne only + expect(memberOneUpdatedNode.handraised).toBe(true) + expect(memberOnePageOneUpdatedWeb.handraised).toBe(true) + expect(memberOnePageTwoUpdatedWeb.handraised).toBe(true) + + expect(memberTwo.handraised).toBe(false) + }) + + test('should raise memberTwo hand using member instance via Node SDK', async () => { + // Expect member.updated event on pageOne via Web SDK for memberTwo + const memberTwoPageOne = expectMemberUpdated({ + page: pageOne, + memberName: memberTwo.name, + }) + + // Expect member.updated event on pageTwo via Web SDK for memberTwo + const memberTwoPageTwo = expectMemberUpdated({ + page: pageTwo, + memberName: memberTwo.name, + }) + + // Raise memberTwo hand using a member object via Node SDK + const memberTwoUpdatedNode = await new Promise( + async (resolve, _reject) => { + roomSession.on('member.updated', (member) => { + if (member.name === memberTwo.name) { + resolve(member) + } + }) + await memberTwo.setRaisedHand() + } + ) + + // Wait for member.updated events to be received on the Web SDK for both pages + const memberTwoPageOneUpdatedWeb = await memberTwoPageOne + const memberTwoPageTwoUpdatedWeb = await memberTwoPageTwo + + // Expect a hand raise to be true on both Node & Web SDKs for memberTwo only + expect(memberTwoUpdatedNode.handraised).toBe(true) + expect(memberTwoPageOneUpdatedWeb.handraised).toBe(true) + expect(memberTwoPageTwoUpdatedWeb.handraised).toBe(true) + }) + + test('should lower memberOne hand using room session instance via Web SDK', async () => { + // Expect member.updated event on pageOne via Web SDK for memberOne + const memberOnePageOne = expectMemberUpdated({ + page: pageOne, + memberName: memberOne.name, + }) + + // Expect member.updated event on pageTwo via Web SDK for memberOne + const memberOnePageTwo = expectMemberUpdated({ + page: pageTwo, + memberName: memberOne.name, + }) + + // Expect member.updated event via Node SDK for memberOne + const memberOneNode = new Promise( + async (resolve, _reject) => { + roomSession.on('member.updated', (member) => { + if (member.name === memberOne.name) { + resolve(member) + } + }) + } + ) + + await pageOne.evaluate(async () => { + // @ts-expect-error + const roomSession = window._roomObj + + // MemberId is not needed here since roomSession on pageOne refers to memberOne's roomSession + await roomSession.setRaisedHand({ raised: false }) + }) + + // Wait for member.updated events to be received on the Web SDK for both pages + const memberOnePageOneUpdatedWeb = await memberOnePageOne + const memberOnePageTwoUpdatedWeb = await memberOnePageTwo + + // Wait for member.updated events to be received on the Node SDK + const memberOneUpdatedNode = await memberOneNode + + // Expect a hand raise to be false on both Node & Web SDKs for memberOne only + expect(memberOneUpdatedNode.handraised).toBe(false) + expect(memberOnePageOneUpdatedWeb.handraised).toBe(false) + expect(memberOnePageTwoUpdatedWeb.handraised).toBe(false) + }) +}) diff --git a/internal/e2e-realtime-api/src/playwright/videoUtils.ts b/internal/e2e-realtime-api/src/playwright/videoUtils.ts index 586f6983e..ac436ae43 100644 --- a/internal/e2e-realtime-api/src/playwright/videoUtils.ts +++ b/internal/e2e-realtime-api/src/playwright/videoUtils.ts @@ -1,4 +1,6 @@ -import { Page, Browser } from '@playwright/test' +import { Page, Browser, expect } from '@playwright/test' +import { uuid } from '@signalwire/core' +import { Video } from '@signalwire/realtime-api' import { SERVER_URL } from '../../utils' const PERMISSIONS = [ @@ -19,6 +21,10 @@ const PERMISSIONS = [ 'room.recording', 'room.playback', 'room.playback_seek', + 'room.member.raisehand', + 'room.member.lowerhand', + 'room.self.raisehand', + 'room.self.lowerhand', ] type CreateVRTParams = { @@ -48,22 +54,78 @@ export const createTestVRTToken = async (body: CreateVRTParams) => { } type CreateRoomSessionParams = CreateVRTParams & { + page: Page + initialEvents?: string[] +} + +export const createRoomSession = async (params: CreateRoomSessionParams) => { + try { + const { page, initialEvents, ...auth } = params + + const vrt = await createTestVRTToken(auth) + + return page.evaluate( + (options) => { + return new Promise(async (resolve, reject) => { + // @ts-expect-error + const VideoSWJS = window._SWJS.Video + const roomSession = new VideoSWJS.RoomSession({ + host: options.RELAY_HOST, + token: options.API_TOKEN, + audio: true, + video: true, + debug: { logWsTraffic: true }, + }) + + // @ts-expect-error + window._roomObj = roomSession + + roomSession.on('room.joined', async (room) => { + // @ts-expect-error + window._roomOnJoined = room + + resolve(room) + }) + + options.initialEvents?.forEach((event) => { + roomSession.once(event, () => {}) + }) + + await roomSession.join().catch((error) => { + console.log('Error joining room', error) + reject(error) + }) + }) + }, + { + RELAY_HOST: process.env.RELAY_HOST || 'relay.signalwire.com', + API_TOKEN: vrt, + initialEvents, + } + ) + } catch (error) { + console.error('CreateRoomSession Error', error) + } +} + +type CreateNewTabRoomSessionParams = CreateVRTParams & { browser: Browser pageName: string } -export const createNewTabRoomSession = async ( - params: CreateRoomSessionParams + +export const createRoomAndRecordPlay = async ( + params: CreateNewTabRoomSessionParams ): Promise => { try { const { browser, pageName, ...auth } = params - const tab = await browser.newPage() - await tab.goto(SERVER_URL) - enablePageLogs(tab, pageName) + const page = await browser.newPage() + await page.goto(SERVER_URL) + enablePageLogs(page, pageName) const vrt = await createTestVRTToken(auth) - return tab.evaluate( + return page.evaluate( (options) => { return new Promise(async (resolve, reject) => { // @ts-expect-error @@ -123,3 +185,116 @@ export const createNewTabRoomSession = async ( console.error('CreateRoomSession Error', error) } } + +export const expectMemberUpdated = async ({ page, memberName }) => { + return page.evaluate( + ({ memberName }) => { + return new Promise((resolve, _reject) => { + // @ts-expect-error + const roomSession = window._roomObj + + roomSession.on('member.updated', (room) => { + if (room.member.name === memberName) { + resolve(room.member) + } + }) + }) + }, + { + memberName, + } + ) +} + +interface FindRoomSessionByPrefixParams { + client: Video.Client + prefix: string +} + +export const findRoomSessionByPrefix = async ({ + client, + prefix, +}: FindRoomSessionByPrefixParams) => { + const { roomSessions } = await client.getRoomSessions() + return roomSessions.filter((r) => r.name.startsWith(prefix)) +} + +export const createRoomAndJoinTwoMembers = async (browser: Browser) => { + const pageOne = await browser.newPage() + const pageTwo = await browser.newPage() + + await Promise.all([pageOne.goto(SERVER_URL), pageTwo.goto(SERVER_URL)]) + + enablePageLogs(pageOne, '[pageOne]') + enablePageLogs(pageTwo, '[pageTwo]') + + // Create a realtime-api Video client + const videoClient = new Video.Client({ + // @ts-expect-error + host: process.env.RELAY_HOST, + project: process.env.RELAY_PROJECT as string, + token: process.env.RELAY_TOKEN as string, + debug: { logWsTraffic: true }, + }) + + const prefix = uuid() + const roomName = `${prefix}-hand-raise-lower-e2e` + const memberOneName = `${prefix}-member-one` + const memberTwoName = `${prefix}-member-two` + + // TODO: This is not needed with new interface due to listen method + videoClient.on('room.started', (room) => { + room.on('member.updated', () => {}) + }) + + // Room length should be 0 before start + const roomSessionsBeforeStart = await findRoomSessionByPrefix({ + client: videoClient, + prefix, + }) + expect(roomSessionsBeforeStart).toHaveLength(0) + + // Create a room and join two members + await Promise.all([ + createRoomSession({ + page: pageOne, + room_name: roomName, + user_name: memberOneName, + initialEvents: ['member.updated'], + }), + createRoomSession({ + page: pageTwo, + room_name: roomName, + user_name: memberTwoName, + initialEvents: ['member.updated'], + }), + ]) + + // Room length should be 1 after start + const roomSessionsAfterStart = await findRoomSessionByPrefix({ + client: videoClient, + prefix, + }) + expect(roomSessionsAfterStart).toHaveLength(1) + + const roomSession = roomSessionsAfterStart[0] + + // There should be 2 members in the room + const { members } = await roomSession.getMembers() + expect(members).toHaveLength(2) + + const memberOne = members.find((member) => member.name === memberOneName)! + const memberTwo = members.find((member) => member.name === memberTwoName)! + + // Expect both members instances to be defined + expect(memberOne).toBeDefined() + expect(memberTwo).toBeDefined() + + return { + pageOne, + pageTwo, + memberOne, + memberTwo, + roomSession, + } +} diff --git a/internal/playground-js/src/heroku/index.html b/internal/playground-js/src/heroku/index.html index 005405ad7..2b70cdb96 100644 --- a/internal/playground-js/src/heroku/index.html +++ b/internal/playground-js/src/heroku/index.html @@ -207,6 +207,25 @@
Controls
+
+ + +
+
Controls