fix playwright tests

This commit is contained in:
Timo K
2025-12-10 18:50:19 +01:00
parent ef2f53c38a
commit 6efce232f8
4 changed files with 129 additions and 80 deletions

View File

@@ -99,6 +99,7 @@ import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts
import { import {
createLocalMembership$, createLocalMembership$,
enterRTCSession, enterRTCSession,
TransportState,
} from "./localMember/LocalMember.ts"; } from "./localMember/LocalMember.ts";
import { createLocalTransport$ } from "./localMember/LocalTransport.ts"; import { createLocalTransport$ } from "./localMember/LocalTransport.ts";
import { import {
@@ -577,17 +578,6 @@ export function createCallViewModel$(
), ),
); );
/**
* Whether various media/event sources should pretend to be disconnected from
* all network input, even if their connection still technically works.
*/
// We do this when the app is in the 'reconnecting' state, because it might be
// that the LiveKit connection is still functional while the homeserver is
// down, for example, and we want to avoid making people worry that the app is
// in a split-brained state.
// DISCUSSION own membership manager ALSO this probably can be simplifis
const reconnecting$ = localMembership.reconnecting$;
const audioParticipants$ = scope.behavior( const audioParticipants$ = scope.behavior(
matrixLivekitMembers$.pipe( matrixLivekitMembers$.pipe(
switchMap((membersWithEpoch) => { switchMap((membersWithEpoch) => {
@@ -635,7 +625,7 @@ export function createCallViewModel$(
); );
const handsRaised$ = scope.behavior( const handsRaised$ = scope.behavior(
handsRaisedSubject$.pipe(pauseWhen(reconnecting$)), handsRaisedSubject$.pipe(pauseWhen(localMembership.reconnecting$)),
); );
const reactions$ = scope.behavior( const reactions$ = scope.behavior(
@@ -648,7 +638,7 @@ export function createCallViewModel$(
]), ]),
), ),
), ),
pauseWhen(reconnecting$), pauseWhen(localMembership.reconnecting$),
), ),
); );
@@ -739,7 +729,7 @@ export function createCallViewModel$(
livekitRoom$, livekitRoom$,
focusUrl$, focusUrl$,
mediaDevices, mediaDevices,
reconnecting$, localMembership.reconnecting$,
displayName$, displayName$,
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId), matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
handsRaised$.pipe(map((v) => v[participantId]?.time ?? null)), handsRaised$.pipe(map((v) => v[participantId]?.time ?? null)),
@@ -1422,6 +1412,37 @@ export function createCallViewModel$(
// reassigned here to make it publicly accessible // reassigned here to make it publicly accessible
const toggleScreenSharing = localMembership.toggleScreenSharing; const toggleScreenSharing = localMembership.toggleScreenSharing;
const errors$ = scope.behavior<{
transportError?: ElementCallError;
matrixError?: ElementCallError;
connectionError?: ElementCallError;
publishError?: ElementCallError;
} | null>(
localMembership.localMemberState$.pipe(
map((value) => {
const returnObject: {
transportError?: ElementCallError;
matrixError?: ElementCallError;
connectionError?: ElementCallError;
publishError?: ElementCallError;
} = {};
if (value instanceof ElementCallError) return { transportError: value };
if (value === TransportState.Waiting) return null;
if (value.matrix instanceof ElementCallError)
returnObject.matrixError = value.matrix;
if (value.media instanceof ElementCallError)
returnObject.publishError = value.media;
else if (
typeof value.media === "object" &&
value.media.connection instanceof ElementCallError
)
returnObject.connectionError = value.media.connection;
return returnObject;
}),
),
null,
);
return { return {
autoLeave$: autoLeave$, autoLeave$: autoLeave$,
callPickupState$: callPickupState$, callPickupState$: callPickupState$,
@@ -1438,8 +1459,16 @@ export function createCallViewModel$(
unhoverScreen: (): void => screenUnhover$.next(), unhoverScreen: (): void => screenUnhover$.next(),
fatalError$: scope.behavior( fatalError$: scope.behavior(
localMembership.localMemberState$.pipe( errors$.pipe(
filter((v) => v instanceof ElementCallError), map((errors) => {
return (
errors?.transportError ??
errors?.matrixError ??
errors?.connectionError ??
null
);
}),
filter((error) => error !== null),
), ),
null, null,
), ),
@@ -1472,7 +1501,7 @@ export function createCallViewModel$(
showFooter$: showFooter$, showFooter$: showFooter$,
earpieceMode$: earpieceMode$, earpieceMode$: earpieceMode$,
audioOutputSwitcher$: audioOutputSwitcher$, audioOutputSwitcher$: audioOutputSwitcher$,
reconnecting$: reconnecting$, reconnecting$: localMembership.reconnecting$,
}; };
} }

View File

@@ -42,6 +42,7 @@ import { type Publisher } from "./Publisher.ts";
import { type MuteStates } from "../../MuteStates.ts"; import { type MuteStates } from "../../MuteStates.ts";
import { import {
ElementCallError, ElementCallError,
FailToStartLivekitConnection,
MembershipManagerError, MembershipManagerError,
UnknownCallError, UnknownCallError,
} from "../../../utils/errors.ts"; } from "../../../utils/errors.ts";
@@ -56,6 +57,7 @@ import {
type FailedToStartError, type FailedToStartError,
} from "../remoteMembers/Connection.ts"; } from "../remoteMembers/Connection.ts";
import { type HomeserverConnected } from "./HomeserverConnected.ts"; import { type HomeserverConnected } from "./HomeserverConnected.ts";
import { and$ } from "../../../utils/observable.ts";
export enum TransportState { export enum TransportState {
/** Not even a transport is available to the LocalMembership */ /** Not even a transport is available to the LocalMembership */
@@ -86,13 +88,12 @@ export type LocalMemberMediaState =
} }
| PublishState | PublishState
| ElementCallError; | ElementCallError;
export type LocalMemberMatrixState = Error | RTCSessionStatus;
export type LocalMemberState = export type LocalMemberState =
| ElementCallError | ElementCallError
| TransportState.Waiting | TransportState.Waiting
| { | {
media: LocalMemberMediaState; media: LocalMemberMediaState;
matrix: LocalMemberMatrixState; matrix: ElementCallError | RTCSessionStatus;
}; };
/* /*
@@ -220,10 +221,6 @@ export const createLocalMembership$ = ({
), ),
); );
const localConnectionState$ = localConnection$.pipe(
switchMap((connection) => (connection ? connection.state$ : of(null))),
);
// MATRIX RELATED // MATRIX RELATED
// This should be used in a combineLatest with publisher$ to connect. // This should be used in a combineLatest with publisher$ to connect.
@@ -308,23 +305,27 @@ export const createLocalMembership$ = ({
try { try {
await publisher?.startPublishing(); await publisher?.startPublishing();
} catch (error) { } catch (error) {
setMediaError(error as ElementCallError); const message =
error instanceof Error ? error.message : String(error);
setPublishError(new FailToStartLivekitConnection(message));
} }
} else if (tracks.length !== 0 && !shouldJoinAndPublish) { } else if (tracks.length !== 0 && !shouldJoinAndPublish) {
try { try {
await publisher?.stopPublishing(); await publisher?.stopPublishing();
} catch (error) { } catch (error) {
setMediaError(new UnknownCallError(error as Error)); setPublishError(new UnknownCallError(error as Error));
} }
} }
}, },
); );
const fatalMediaError$ = new BehaviorSubject<ElementCallError | null>(null); // STATE COMPUTATION
const setMediaError = (e: ElementCallError): void => {
if (fatalMediaError$.value !== null) // These are non fatal since we can join a room and concume media even though publishing failed.
logger.error("Multiple Media Errors:", e); const publishError$ = new BehaviorSubject<ElementCallError | null>(null);
else fatalMediaError$.next(e); const setPublishError = (e: ElementCallError): void => {
if (publishError$.value !== null) logger.error("Multiple Media Errors:", e);
else publishError$.next(e);
}; };
const fatalTransportError$ = new BehaviorSubject<ElementCallError | null>( const fatalTransportError$ = new BehaviorSubject<ElementCallError | null>(
@@ -336,6 +337,10 @@ export const createLocalMembership$ = ({
else fatalTransportError$.next(e); else fatalTransportError$.next(e);
}; };
const localConnectionState$ = localConnection$.pipe(
switchMap((connection) => (connection ? connection.state$ : of(null))),
);
const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior( const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior(
combineLatest([ combineLatest([
localConnectionState$, localConnectionState$,
@@ -392,22 +397,22 @@ export const createLocalMembership$ = ({
homeserverConnected.rtsSession$, homeserverConnected.rtsSession$,
fatalMatrixError$, fatalMatrixError$,
fatalTransportError$, fatalTransportError$,
fatalMediaError$, publishError$,
]).pipe( ]).pipe(
map( map(
([ ([
mediaState, mediaState,
rtcSessionStatus, rtcSessionStatus,
matrixError, fatalMatrixError,
transportError, fatalTransportError,
mediaError, publishError,
]) => { ]) => {
if (transportError !== null) return transportError; if (fatalTransportError !== null) return fatalTransportError;
// `mediaState` will be 'null' until the transport appears. // `mediaState` will be 'null' until the transport/connection appears.
if (mediaState && rtcSessionStatus) if (mediaState && rtcSessionStatus)
return { return {
matrix: matrixError ?? rtcSessionStatus, matrix: fatalMatrixError ?? rtcSessionStatus,
media: mediaError ?? mediaState, media: publishError ?? mediaState,
}; };
return TransportState.Waiting; return TransportState.Waiting;
}, },
@@ -415,6 +420,31 @@ export const createLocalMembership$ = ({
), ),
); );
/**
* Whether we are "fully" connected to the call. Accounts for both the
* connection to the MatrixRTC session and the LiveKit publish connection.
*/
const matrixAndLivekitConnected$ = scope.behavior(
and$(
homeserverConnected.combined$,
localConnectionState$.pipe(
map((state) => state === ConnectionState.LivekitConnected),
),
).pipe(
tap((v) => logger.debug("livekit+matrix: Connected state changed", v)),
),
);
/**
* Whether we should tell the user that we're reconnecting to the call.
*/
const reconnecting$ = scope.behavior(
matrixAndLivekitConnected$.pipe(
pairwise(),
map(([prev, current]) => prev === true && current === false),
),
);
// inform the widget about the connect and disconnect intent from the user. // inform the widget about the connect and disconnect intent from the user.
scope scope
.behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [ .behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [
@@ -576,15 +606,7 @@ export const createLocalMembership$ = ({
localMemberState$, localMemberState$,
tracks$, tracks$,
participant$, participant$,
reconnecting$: scope.behavior( reconnecting$,
localMemberState$.pipe(
map((state) => {
if (typeof state === "object" && "matrix" in state)
return state.matrix === RTCSessionStatus.Reconnecting;
return false;
}),
),
),
disconnected$: scope.behavior( disconnected$: scope.behavior(
homeserverConnected.rtsSession$.pipe( homeserverConnected.rtsSession$.pipe(
map((state) => state === RTCSessionStatus.Disconnected), map((state) => state === RTCSessionStatus.Disconnected),

View File

@@ -32,15 +32,8 @@ import {
} from "../../../livekit/TrackProcessorContext.tsx"; } from "../../../livekit/TrackProcessorContext.tsx";
import { getUrlParams } from "../../../UrlParams.ts"; import { getUrlParams } from "../../../UrlParams.ts";
import { observeTrackReference$ } from "../../MediaViewModel.ts"; import { observeTrackReference$ } from "../../MediaViewModel.ts";
import { import { type Connection } from "../remoteMembers/Connection.ts";
ConnectionState,
type Connection,
} from "../remoteMembers/Connection.ts";
import { type ObservableScope } from "../../ObservableScope.ts"; import { type ObservableScope } from "../../ObservableScope.ts";
import {
ElementCallError,
FailToStartLivekitConnection,
} from "../../../utils/errors.ts";
/** /**
* A wrapper for a Connection object. * A wrapper for a Connection object.
@@ -160,27 +153,29 @@ export class Publisher {
public async startPublishing(): Promise<LocalTrack[]> { public async startPublishing(): Promise<LocalTrack[]> {
this.logger.debug("startPublishing called"); this.logger.debug("startPublishing called");
const lkRoom = this.connection.livekitRoom; const lkRoom = this.connection.livekitRoom;
const { promise, resolve, reject } = Promise.withResolvers<void>();
const sub = this.connection.state$.subscribe((state) => { // we do not need to do this since lk will wait in `localParticipant.publishTrack`
if (state instanceof Error) { // const { promise, resolve, reject } = Promise.withResolvers<void>();
const error = // const sub = this.connection.state$.subscribe((state) => {
state instanceof ElementCallError // if (state instanceof Error) {
? state // const error =
: new FailToStartLivekitConnection(state.message); // state instanceof ElementCallError
reject(error); // ? state
} else if (state === ConnectionState.LivekitConnected) { // : new FailToStartLivekitConnection(state.message);
resolve(); // reject(error);
} else { // } else if (state === ConnectionState.LivekitConnected) {
this.logger.info("waiting for connection: ", state); // resolve();
} // } else {
}); // this.logger.info("waiting for connection: ", state);
try { // }
await promise; // });
} catch (e) { // try {
throw e; // await promise;
} finally { // } catch (e) {
sub.unsubscribe(); // throw e;
} // } finally {
// sub.unsubscribe();
// }
for (const track of this.tracks$.value) { for (const track of this.tracks$.value) {
this.logger.info("publish ", this.tracks$.value.length, "tracks"); this.logger.info("publish ", this.tracks$.value.length, "tracks");
@@ -188,9 +183,10 @@ export class Publisher {
// with a timeout. // with a timeout.
await lkRoom.localParticipant.publishTrack(track).catch((error) => { await lkRoom.localParticipant.publishTrack(track).catch((error) => {
this.logger.error("Failed to publish track", error); this.logger.error("Failed to publish track", error);
throw new FailToStartLivekitConnection( // throw new FailToStartLivekitConnection(
error instanceof Error ? error.message : error, // error instanceof Error ? error.message : error,
); // );
throw error;
}); });
this.logger.info("published track ", track.kind, track.id); this.logger.info("published track ", track.kind, track.id);

View File

@@ -150,7 +150,8 @@ export class Connection {
throw new InsufficientCapacityError(); throw new InsufficientCapacityError();
} }
if (e.status === 404) { if (e.status === 404) {
// error msg is "Could not establish signal connection: requested room does not exist" // error msg is "Failed to create call"
// error description is "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists."
// The room does not exist. There are two different modes of operation for the SFU: // The room does not exist. There are two different modes of operation for the SFU:
// - the room is created on the fly when connecting (livekit `auto_create` option) // - the room is created on the fly when connecting (livekit `auto_create` option)
// - Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service) // - Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service)
@@ -172,6 +173,7 @@ export class Connection {
} catch (error) { } catch (error) {
this.logger.debug(`Failed to connect to LiveKit room: ${error}`); this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
this._state$.next(error instanceof Error ? error : new Error(`${error}`)); this._state$.next(error instanceof Error ? error : new Error(`${error}`));
// Its okay to ignore the throw. The error is part of the state.
throw error; throw error;
} }
} }