From 914dbcad5c922b1ad4346aee7d84ed2f5eab0e48 Mon Sep 17 00:00:00 2001 From: Daniel Phillip Date: Wed, 12 Aug 2026 08:42:44 +0000 Subject: [PATCH 1/4] add test cases and apply fixes --- eslint.config.cjs | 7 +- mail_livekit/__manifest__.py | 6 +- .../static/src/discuss/livekit_service.js | 15 +- .../static/src/discuss/rtc_livekit_patch.js | 73 ++++- .../static/tests/livekit_adapter.test.js | 13 +- .../tests/livekit_call_lifecycle.test.js | 165 +++++++++++ .../static/tests/livekit_focus_view.test.js | 106 +++++++ .../static/tests/livekit_remote_audio.test.js | 99 +++++++ .../static/tests/livekit_test_helpers.js | 275 ++++++++++++++++++ odoo.conf | 25 ++ 10 files changed, 754 insertions(+), 30 deletions(-) create mode 100644 mail_livekit/static/tests/livekit_call_lifecycle.test.js create mode 100644 mail_livekit/static/tests/livekit_focus_view.test.js create mode 100644 mail_livekit/static/tests/livekit_remote_audio.test.js create mode 100644 mail_livekit/static/tests/livekit_test_helpers.js create mode 100644 odoo.conf diff --git a/eslint.config.cjs b/eslint.config.cjs index 19b39b19..965c06e1 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -195,7 +195,12 @@ const config = [{ }, }, { - files: ["**/*.esm.js", "**/*test.js", "**/static/src/**/*.js"], + files: [ + "**/*.esm.js", + "**/*test.js", + "**/static/src/**/*.js", + "**/static/tests/**/*.js", + ], languageOptions: { ecmaVersion: 2024, diff --git a/mail_livekit/__manifest__.py b/mail_livekit/__manifest__.py index 2938f2ff..c3577b3d 100644 --- a/mail_livekit/__manifest__.py +++ b/mail_livekit/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Discuss - Livekit Integration", "summary": "Integrate LiveKit video conferencing with Odoo Discuss", - "version": "18.0.1.0.2", + "version": "18.0.1.0.3", "author": "Nitrokey GmbH, Solvti Sp. z o.o.", "license": "LGPL-3", "category": "Discuss", @@ -25,6 +25,10 @@ "mail_livekit/static/lib/livekit/livekit-client.umd.js", "mail_livekit/static/src/discuss/livekit_service.js", "mail_livekit/static/src/discuss/livekit_adapter.js", + "mail_livekit/static/src/discuss/rtc_livekit_patch.js", + "mail_livekit/static/src/discuss/thread_actions_patch.js", + "mail_livekit/static/src/discuss/call_participant_video_patch.js", + "mail_livekit/static/src/discuss/call_context_menu_patch.js", "mail_livekit/static/tests/**/*", ], }, diff --git a/mail_livekit/static/src/discuss/livekit_service.js b/mail_livekit/static/src/discuss/livekit_service.js index 34754404..ea2bf3a3 100644 --- a/mail_livekit/static/src/discuss/livekit_service.js +++ b/mail_livekit/static/src/discuss/livekit_service.js @@ -130,6 +130,15 @@ class LivekitService { const audioElementId = this._formAudioElementId(participant.identity); const audioElement = document.getElementById(audioElementId); audioElement?.remove(); + + // A graceful disconnection unsubscribes every track first, but an + // abrupt one does not: report all sources as gone so that nothing keeps + // showing (or focusing on) the tracks of a participant who left. + for (const source of [Source.CAMERA, Source.SCREEN, Source.MICROPHONE]) { + for (const listener of this.trackMutedListeners.values()) { + listener(participant.identity, source, null, true); + } + } } // Requires functions that accept info as parameter @@ -351,13 +360,13 @@ class LivekitService { log("Publishing new track for source:", source); await this.room?.localParticipant.publishTrack(mediaStreamTrack, { source, - simulcast: source !== Source.Microphone, + simulcast: source !== Source.MICROPHONE, }); } else if (publication.track) { log("Replacing track for source:", source); await publication.track.replaceTrack(mediaStreamTrack); if ( - publication.track.source !== Source.Microphone || + publication.track.source !== Source.MICROPHONE || mediaStreamTrack?.enabled ) { publication?.track?.unmute(); @@ -389,7 +398,7 @@ class LivekitService { async setMicrophoneMuted(muted) { log("Setting microphone mute to:", muted); const publication = this.room?.localParticipant.getTrackPublication( - Source.Microphone + Source.MICROPHONE ); if (publication?.track && publication.track.isMuted !== muted) { if (muted) { diff --git a/mail_livekit/static/src/discuss/rtc_livekit_patch.js b/mail_livekit/static/src/discuss/rtc_livekit_patch.js index 317f8731..1234fddd 100644 --- a/mail_livekit/static/src/discuss/rtc_livekit_patch.js +++ b/mail_livekit/static/src/discuss/rtc_livekit_patch.js @@ -95,18 +95,26 @@ patch(Rtc.prototype, { }, async setAudioVolume(sessionId, element = null) { - const rtcSession = await this.store.RtcSession.getWhenReady(sessionId); - if (element) { - rtcSession.audioElement = element; - } - const volumeSetting = this.store.Volume.getForPartnerId(rtcSession.partnerId); - const volume = volumeSetting ? volumeSetting.volume / 100 : 1.0; - if (rtcSession.audioElement) { - rtcSession.audioElement.volume = volume; + if (!element) { + return; } + const rtcSession = await this.store.RtcSession.getWhenReady(sessionId); + // `RtcSession.volume` reads back from `audioElement`, so the saved + // volume has to be resolved before the element is bound to the session, + // otherwise it resolves to the element's own default. + element.volume = this.store.settings.getVolume(rtcSession); + // LiveKit attaches the element on its own, after the fact, so it has to + // catch up with a deafening that already happened. + element.muted = Boolean(this.selfSession?.isDeaf); + rtcSession.audioElement = element; }, async handleSetAudioVolume(eventdata) { + // The adapter notifies every listener of every event, so the ones that + // are not meant for this handler have to be filtered out. + if (eventdata.detail.name !== "setAudioVolume") { + return; + } console.debug("LIVEKIT: Set audio volume event received", eventdata); this.fixEventIds(eventdata); return this.setAudioVolume( @@ -136,6 +144,10 @@ patch(Rtc.prototype, { rtcSession.videoStreams.set(type, dummyStream); await rtcSession.updateStreamState(type, true); + // Raise the focus view on an incoming screen share, the way the + // standard `handleRemoteTrack` does for the other connection types. + this.updateActiveSession(rtcSession, type, {addVideo: true}); + // Trigger bus event to notify CallParticipantVideo to attach track this.store.env.bus.trigger("LIVEKIT:TRACK:REBIND", { sessionId: rtcSession.id, @@ -145,6 +157,42 @@ patch(Rtc.prototype, { } }, + /** + * LiveKit reports the end of a track (screen share stopped, camera turned + * off, participant gone) as an inactive track instead of an actual + * MediaStreamTrack, so the standard handler cannot be used to remove it. + */ + async handleRemoteTrack({session, type, active = true}) { + if (active) { + return super.handleRemoteTrack(...arguments); + } + session.updateStreamState(type, false); + if (type === "camera" || type === "screen") { + session.livekitTracks?.delete(type); + this.removeVideoFromSession(session, {type, cleanup: false}); + this.releaseActiveSession(session); + } + }, + + /** + * Leaves the focus view when the session it focuses on has no video left, + * otherwise the focus view keeps showing an empty tile. + */ + releaseActiveSession(session) { + const channel = this.state.channel; + if (!channel || session.notEq(channel.activeRtcSession)) { + return; + } + if (session.hasVideo) { + session.mainVideoStreamType = session.isScreenSharingOn + ? "screen" + : "camera"; + return; + } + channel.activeRtcSession = undefined; + session.mainVideoStreamType = undefined; + }, + async _initConnection() { this.selfSession.connectionState = "selecting network type"; await this.network?.disconnect(); @@ -156,6 +204,10 @@ patch(Rtc.prototype, { "updateTrack", this.handleTrackSubscribed.bind(this) ); + this.network.addEventListener( + "setAudioVolume", + this.handleSetAudioVolume.bind(this) + ); if (this.state.channel) { await this.call(); @@ -217,11 +269,6 @@ patch(Rtc.prototype, { // No-op }, - async leaveCall(...args) { - this.network?.disconnect(); - return super.leaveCall(...args); - }, - updateActiveSession(session, videoType, {addVideo = false} = {}) { this.state.channel ??= session.channel; return super.updateActiveSession(session, videoType, {addVideo}); diff --git a/mail_livekit/static/tests/livekit_adapter.test.js b/mail_livekit/static/tests/livekit_adapter.test.js index dd6fce27..549a10b0 100644 --- a/mail_livekit/static/tests/livekit_adapter.test.js +++ b/mail_livekit/static/tests/livekit_adapter.test.js @@ -1,21 +1,10 @@ import {after, afterEach, describe, expect, test} from "@odoo/hoot"; import {Source, livekitService} from "@mail_livekit/discuss/livekit_service"; import {LiveKitAdapter} from "@mail_livekit/discuss/livekit_adapter"; +import {cleanupLivekitService} from "./livekit_test_helpers"; const originalLivekitClient = window.LivekitClient; -function cleanupLivekitService() { - livekitService.infoChangeListeners.clear(); - livekitService.trackSubscribedListeners.clear(); - livekitService.trackMutedListeners.clear(); - livekitService.room = null; - livekitService.connected = false; - livekitService.initiated = false; - document - .querySelectorAll(`.${livekitService.audioElementClass}`) - .forEach((element) => element.remove()); -} - function makeRemoteAudioTrack(identity) { const audioElement = document.createElement("audio"); const track = { diff --git a/mail_livekit/static/tests/livekit_call_lifecycle.test.js b/mail_livekit/static/tests/livekit_call_lifecycle.test.js new file mode 100644 index 00000000..e3d58cc7 --- /dev/null +++ b/mail_livekit/static/tests/livekit_call_lifecycle.test.js @@ -0,0 +1,165 @@ +/** @odoo-module */ + +import {Command, serverState} from "@web/../tests/web_test_helpers"; +import {Source, mockLivekit} from "./livekit_test_helpers"; +import { + click, + contains, + defineMailModels, + mockGetMedia, + openDiscuss, + start, + startServer, +} from "@mail/../tests/mail_test_helpers"; +import {describe, expect, test, waitUntil} from "@odoo/hoot"; +import {livekitService} from "@mail_livekit/discuss/livekit_service"; +import {mailDataHelpers} from "@mail/../tests/mock_server/mail_mock_server"; + +describe.current.tags("desktop"); +defineMailModels(); + +/** + * Makes the partner `partnerId` ring the browser session under test on + * `channelId`, the same way the server does when someone starts a call in a + * channel we are a member of. + */ +function ringIncomingCall(pyEnv, {channelId, partnerId}) { + const [memberId] = pyEnv["discuss.channel.member"].search([ + ["channel_id", "=", channelId], + ["partner_id", "=", partnerId], + ]); + const sessionId = pyEnv["discuss.channel.rtc.session"].create({ + channel_member_id: memberId, + channel_id: channelId, + }); + const [self] = pyEnv["res.partner"].read(serverState.partnerId); + pyEnv["bus.bus"]._sendone( + self, + "mail.record/insert", + new mailDataHelpers.Store( + pyEnv["discuss.channel.rtc.session"].browse(sessionId), + {channelMember: {id: memberId}} + ) + .add(pyEnv["discuss.channel.member"].browse(memberId), { + persona: {id: partnerId, type: "partner"}, + thread: {id: channelId, model: "discuss.channel"}, + }) + .add(pyEnv["discuss.channel"].browse(channelId), { + rtcInvitingSession: {id: sessionId}, + }) + .get_result() + ); +} + +test("a voice-only call starts unmuted and mute changes reach the room", async () => { + mockGetMedia(); + const pyEnv = await startServer(); + const partnerId = pyEnv["res.partner"].create({name: "Bob"}); + const channelId = pyEnv["discuss.channel"].create({ + name: "Bob", + channel_member_ids: [ + Command.create({partner_id: serverState.partnerId}), + Command.create({partner_id: partnerId}), + ], + }); + const livekit = mockLivekit({identity: `partner:${serverState.partnerId}`}); + await start(); + ringIncomingCall(pyEnv, {channelId, partnerId}); + await contains(".o-discuss-CallInvitation"); + // Voice-only: accepted without the camera. + await click(".o-discuss-CallInvitation [title='Accept']"); + await contains(".o-discuss-Call"); + // The receiver is not muted: the action offered is "Mute", not "Unmute". + await contains(".o-discuss-CallActionList button[aria-label='Mute']"); + // ... and the microphone is published to the room, unmuted. + await waitUntil(() => livekit.microphonePublication, { + timeout: 2000, + message: "the microphone should be published to the LiveKit room", + }); + const publication = livekit.microphonePublication; + expect(publication.track.isMuted).toBe(false); + + // Muting from Odoo reaches the publication the other attendees receive... + await click(".o-discuss-CallActionList button[aria-label='Mute']"); + await contains(".o-discuss-CallActionList button[aria-label='Unmute']"); + await waitUntil(() => publication.track.isMuted, { + timeout: 2000, + message: "muting should mute the microphone published to the room", + }); + + // ... and so does unmuting. + await click(".o-discuss-CallActionList button[aria-label='Unmute']"); + await contains(".o-discuss-CallActionList button[aria-label='Mute']"); + await waitUntil(() => !publication.track.isMuted, { + timeout: 2000, + message: "unmuting should unmute the microphone published to the room", + }); +}); + +test("setMicrophoneMuted unmutes the microphone published to the room", async () => { + const livekit = mockLivekit(); + await livekitService.connect("wss://livekit.example", "token"); + // Publish a microphone track, then mute it (as Odoo does when joining). + await livekitService.setTrackEnabled(Source.MICROPHONE, true, { + kind: "audio", + enabled: false, + }); + const publication = livekit.microphonePublication; + await publication.track.mute(); + expect(publication.track.isMuted).toBe(true); + + // Unmuting from Odoo must reach the LiveKit publication. + await livekitService.setMicrophoneMuted(false); + + expect(publication.track.isMuted).toBe(false); +}); + +test("refusing a second incoming call keeps the ongoing call connected", async () => { + mockGetMedia(); + const pyEnv = await startServer(); + const bobId = pyEnv["res.partner"].create({name: "Bob"}); + const carolId = pyEnv["res.partner"].create({name: "Carol"}); + const conferenceId = pyEnv["discuss.channel"].create({ + name: "Conference", + channel_member_ids: [ + Command.create({partner_id: serverState.partnerId}), + Command.create({partner_id: bobId}), + ], + }); + const directId = pyEnv["discuss.channel"].create({ + name: "Carol", + channel_member_ids: [ + Command.create({partner_id: serverState.partnerId}), + Command.create({partner_id: carolId}), + ], + }); + const [bobMemberId] = pyEnv["discuss.channel.member"].search([ + ["channel_id", "=", conferenceId], + ["partner_id", "=", bobId], + ]); + pyEnv["discuss.channel.rtc.session"].create({ + channel_member_id: bobMemberId, + channel_id: conferenceId, + }); + const livekit = mockLivekit({identity: `partner:${serverState.partnerId}`}); + await start(); + await openDiscuss(conferenceId); + await click("[title='Start a Call']"); + await contains(".o-discuss-Call"); + await waitUntil(() => livekit.isConnected, { + timeout: 2000, + message: "the conference should be connected to the LiveKit room", + }); + const disconnectsBeforeRefusal = livekit.disconnectCount; + + // Carol calls directly while the conference is ongoing, and we refuse. + ringIncomingCall(pyEnv, {channelId: directId, partnerId: carolId}); + await contains(".o-discuss-CallInvitation"); + await click(".o-discuss-CallInvitation [title='Refuse']"); + await contains(".o-discuss-CallInvitation", {count: 0}); + + // Refusing Carol must leave the conference untouched. + await contains(".o-discuss-Call"); + expect(livekit.disconnectCount).toBe(disconnectsBeforeRefusal); + expect(livekit.isConnected).toBe(true); +}); diff --git a/mail_livekit/static/tests/livekit_focus_view.test.js b/mail_livekit/static/tests/livekit_focus_view.test.js new file mode 100644 index 00000000..5ea5c765 --- /dev/null +++ b/mail_livekit/static/tests/livekit_focus_view.test.js @@ -0,0 +1,106 @@ +/** @odoo-module */ + +import {Command, serverState} from "@web/../tests/web_test_helpers"; +import { + click, + contains, + defineMailModels, + mockGetMedia, + openDiscuss, + start, + startServer, +} from "@mail/../tests/mail_test_helpers"; +import {describe, queryAll, test, waitUntil} from "@odoo/hoot"; +import {mockLivekit} from "./livekit_test_helpers"; + +describe.current.tags("desktop"); +defineMailModels(); + +/** + * Cards of the call grid. In tile view it holds one card per participant (plus + * one extra card per shared screen); in focus view it holds exactly one card, + * the focused one (the small "inset" card is rendered outside of the grid). + */ +const MAIN_CARD = ".o-discuss-Call-mainCards .o-discuss-CallParticipantCard"; + +/** + * Starts a call in a channel shared with a second user, and returns a handle on + * that user as seen from the LiveKit room of the browser session under test. + * + * The peer is named "Zoe" so that they are sorted after "Mitchell Admin" + * (cards are sorted by participant name). + */ +async function startCallWithPeer() { + mockGetMedia(); + const pyEnv = await startServer(); + const partnerId = pyEnv["res.partner"].create({name: "Zoe"}); + const channelId = pyEnv["discuss.channel"].create({ + name: "General", + channel_member_ids: [ + Command.create({partner_id: serverState.partnerId}), + Command.create({partner_id: partnerId}), + ], + }); + const [memberId] = pyEnv["discuss.channel.member"].search([ + ["channel_id", "=", channelId], + ["partner_id", "=", partnerId], + ]); + pyEnv["discuss.channel.rtc.session"].create({ + channel_member_id: memberId, + channel_id: channelId, + }); + const livekit = mockLivekit({identity: `partner:${serverState.partnerId}`}); + await start(); + await openDiscuss(channelId); + await click("[title='Start a Call']"); + await contains(".o-discuss-Call"); + // Tile view: one card for each of the two participants. + await contains(MAIN_CARD, {count: 2}); + await waitUntil(() => livekit.isConnected, { + timeout: 2000, + message: "the call should be connected to the LiveKit room", + }); + return {livekit, peer: livekit.addRemotePeer(`partner:${partnerId}`), pyEnv}; +} + +/** + * Makes sure the call is in focus view on the only card showing a video. Once + * a remote screen share puts the other attendees in focus view on its own, this + * becomes a no-op. + */ +async function focusVideoCard() { + if (queryAll(MAIN_CARD).length > 1) { + await click(`${MAIN_CARD}:has(video)`); + } + await contains(MAIN_CARD, {count: 1}); +} + +test("a remote screen share puts the other attendees in focus view", async () => { + const {peer} = await startCallWithPeer(); + peer.startScreenShare(); + // The shared screen is received... + await contains(`${MAIN_CARD} video`); + // ... and displayed alone, in focus view. + await contains(MAIN_CARD, {count: 1}); +}); + +test("focus view falls back to tile view when the focused screen share stops", async () => { + const {peer} = await startCallWithPeer(); + peer.startScreenShare(); + await contains(`${MAIN_CARD} video`); + await focusVideoCard(); + peer.stopScreenShare(); + // The screen is gone: staying in focus view would only show an empty tile. + await contains(MAIN_CARD, {count: 2}); +}); + +test("focus view falls back to tile view when the focused participant leaves", async () => { + const {peer} = await startCallWithPeer(); + peer.startCamera(); + await contains(`${MAIN_CARD} video`); + await focusVideoCard(); + // Zoe leaves the LiveKit room (hangs up, closes the tab, loses connection). + peer.leave(); + // Her video is gone: staying in focus view would only show an empty tile. + await contains(MAIN_CARD, {count: 2}); +}); diff --git a/mail_livekit/static/tests/livekit_remote_audio.test.js b/mail_livekit/static/tests/livekit_remote_audio.test.js new file mode 100644 index 00000000..76b85cf8 --- /dev/null +++ b/mail_livekit/static/tests/livekit_remote_audio.test.js @@ -0,0 +1,99 @@ +/** @odoo-module */ + +import {Command, serverState} from "@web/../tests/web_test_helpers"; +import { + click, + contains, + defineMailModels, + mockGetMedia, + openDiscuss, + start, + startServer, +} from "@mail/../tests/mail_test_helpers"; +import {describe, expect, test, waitUntil} from "@odoo/hoot"; +import {mockLivekit} from "./livekit_test_helpers"; + +describe.current.tags("desktop"); +defineMailModels(); + +/** + * Starts a call in a channel shared with "Zoe", who is already talking: the + * `