diff --git a/playwright/errors.spec.ts b/playwright/errors.spec.ts index 0dc9fa38..0d36f7ab 100644 --- a/playwright/errors.spec.ts +++ b/playwright/errors.spec.ts @@ -79,7 +79,7 @@ test("Should show error screen if call creation is restricted", async ({ }) => { test.skip( browserName === "firefox", - "The test to check the video visibility is not working in Firefox CI environment. looks like video is disabled?", + "The is test is not working on firefox CI environment.", ); await page.goto("/"); diff --git a/src/state/CallViewModel/localMember/Publisher.test.ts b/src/state/CallViewModel/localMember/Publisher.test.ts new file mode 100644 index 00000000..f45f7abe --- /dev/null +++ b/src/state/CallViewModel/localMember/Publisher.test.ts @@ -0,0 +1,138 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from "vitest"; +import { ConnectionState as LivekitConenctionState } from "livekit-client"; +import { type BehaviorSubject } from "rxjs"; + +import { ObservableScope } from "../../ObservableScope"; +import { constant } from "../../Behavior"; +import { + mockLivekitRoom, + mockLocalParticipant, + mockMediaDevices, +} from "../../../utils/test"; +import { Publisher } from "./Publisher"; +import { + type Connection, + type ConnectionState, +} from "../remoteMembers/Connection"; +import { type MuteStates } from "../../MuteStates"; +import { FailToStartLivekitConnection } from "../../../utils/errors"; + +describe("Publisher", () => { + let scope: ObservableScope; + let connection: Connection; + let muteStates: MuteStates; + beforeEach(() => { + muteStates = { + audio: { + enabled$: constant(false), + unsetHandler: vi.fn(), + setHandler: vi.fn(), + }, + video: { + enabled$: constant(false), + unsetHandler: vi.fn(), + setHandler: vi.fn(), + }, + } as unknown as MuteStates; + scope = new ObservableScope(); + connection = { + state$: constant({ + state: "ConnectedToLkRoom", + livekitConnectionState$: constant(LivekitConenctionState.Connected), + }), + livekitRoom: mockLivekitRoom({ + localParticipant: mockLocalParticipant({}), + }), + } as unknown as Connection; + }); + + afterEach(() => scope.end()); + + it("throws if livekit room could not publish", async () => { + const publisher = new Publisher( + scope, + connection, + mockMediaDevices({}), + muteStates, + constant({ supported: false, processor: undefined }), + ); + + // should do nothing if no tracks have been created yet. + await publisher.startPublishing(); + expect( + connection.livekitRoom.localParticipant.publishTrack, + ).not.toHaveBeenCalled(); + + await expect(publisher.createAndSetupTracks()).rejects.toThrow( + Error("audio and video is false"), + ); + + (muteStates.audio.enabled$ as BehaviorSubject).next(true); + + ( + connection.livekitRoom.localParticipant.createTracks as Mock + ).mockResolvedValue([{}, {}]); + + await expect(publisher.createAndSetupTracks()).resolves.not.toThrow(); + expect( + connection.livekitRoom.localParticipant.createTracks, + ).toHaveBeenCalledOnce(); + + // failiour due to localParticipant.publishTrack + ( + connection.livekitRoom.localParticipant.publishTrack as Mock + ).mockRejectedValue(Error("testError")); + + await expect(publisher.startPublishing()).rejects.toThrow( + new FailToStartLivekitConnection("testError"), + ); + + // does not try other conenction after the first one failed + expect( + connection.livekitRoom.localParticipant.publishTrack, + ).toHaveBeenCalledTimes(1); + + // failiour due to connection.state$ + const beforeState = connection.state$.value; + (connection.state$ as BehaviorSubject).next({ + state: "FailedToStart", + error: Error("testStartError"), + }); + + await expect(publisher.startPublishing()).rejects.toThrow( + new FailToStartLivekitConnection("testStartError"), + ); + (connection.state$ as BehaviorSubject).next(beforeState); + + // does not try other conenction after the first one failed + expect( + connection.livekitRoom.localParticipant.publishTrack, + ).toHaveBeenCalledTimes(1); + + // success case + ( + connection.livekitRoom.localParticipant.publishTrack as Mock + ).mockResolvedValue({}); + + await expect(publisher.startPublishing()).resolves.not.toThrow(); + + expect( + connection.livekitRoom.localParticipant.publishTrack, + ).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index a93ef392..3f3192d1 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -56,7 +56,7 @@ export class Publisher { */ public constructor( private scope: ObservableScope, - private connection: Connection, + private connection: Pick, //setE2EEEnabled, devices: MediaDevices, private readonly muteStates: MuteStates, trackerProcessorState$: Behavior, @@ -160,7 +160,7 @@ export class Publisher { reject( s.error instanceof ElementCallError ? s.error - : new FailToStartLivekitConnection(), + : new FailToStartLivekitConnection(s.error.message), ); break; default: @@ -180,17 +180,16 @@ export class Publisher { // with a timeout. await lkRoom.localParticipant.publishTrack(track).catch((error) => { this.logger?.error("Failed to publish track", error); + throw new FailToStartLivekitConnection( + error instanceof Error ? error.message : error, + ); }); - - // TODO: check if the connection is still active? and break the loop if not? } this._publishing$.next(true); return this.tracks$.value; } public async stopPublishing(): Promise { - // TODO-MULTI-SFU: Move these calls back to ObservableScope.onEnd once scope - // actually has the right lifetime this.muteStates.audio.unsetHandler(); this.muteStates.video.unsetHandler(); @@ -246,6 +245,9 @@ export class Publisher { // the process of being restarted. activeMicTrack.mediaStreamTrack.readyState !== "ended" ) { + this.logger?.info( + "Restarting audio device track due to active media device changed (workaroundRestartAudioInputTrackChrome)", + ); // Restart the track, which will cause Livekit to do another // getUserMedia() call with deviceId: default to get the *new* default device. // Note that room.switchActiveDevice() won't work: Livekit will ignore it because diff --git a/src/utils/errors.ts b/src/utils/errors.ts index cdd0e75c..bb37754a 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -136,12 +136,12 @@ export class FailToGetOpenIdToken extends ElementCallError { } export class FailToStartLivekitConnection extends ElementCallError { - public constructor() { + public constructor(e?: string) { super( t("error.failed_to_start_livekit"), ErrorCode.FAILED_TO_START_LIVEKIT, ErrorCategory.NETWORK_CONNECTIVITY, - undefined, + e, ); } } diff --git a/src/utils/test.ts b/src/utils/test.ts index 471d35d8..d243b343 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -284,6 +284,8 @@ export function mockLivekitRoom( ): LivekitRoom { const livekitRoom = { options: {}, + setE2EEEnabled: vi.fn(), + ...mockEmitter(), ...room, } as Partial as LivekitRoom; @@ -306,7 +308,9 @@ export function mockLocalParticipant( return { isLocal: true, trackPublications: new Map(), - unpublishTracks: async () => Promise.resolve(), + publishTrack: vi.fn(), + unpublishTracks: vi.fn(), + createTracks: vi.fn(), getTrackPublication: () => ({}) as Partial as LocalTrackPublication, ...mockEmitter(),