Fix widgets getting stuck in loading states (#31314)

* Refer to ClientWidgetApi as "widget API" rather than "messaging"

* Rename StopGapWidgetDriver to ElementWidgetDriver

* Rename StopGapWidget to WidgetMessaging

* Fix WidgetMessaging's lifetime by storing it in WidgetMessagingStore

(Rather than storing just the raw ClientWidgetApi objects.)

* Unfail test

* use an error

* cleanup start

* Add docs

* Prettier

* link to store

* remove a let

* More logging, split up loop

* Add a test demonstrating a regression in Call.start

* Restore Call.start to a single, robust event loop

* Fix test failure by resetting the messaging store

* Expand on the WidgetMessaging doc comment

* Add additional tests to buff up coverage

* Add a test for the sticker picker opening the IM.

* reduce copy paste

---------

Co-authored-by: Half-Shot <will@half-shot.uk>
Co-authored-by: Timo K <toger5@hotmail.de>
This commit is contained in:
Robin
2025-12-05 04:19:06 -05:00
committed by GitHub
parent f4e74c8dd2
commit 71895a3891
26 changed files with 1242 additions and 806 deletions

View File

@@ -48,36 +48,37 @@ import { Anonymity, PosthogAnalytics } from "../../../src/PosthogAnalytics";
import { type SettingKey } from "../../../src/settings/Settings.tsx";
import SdkConfig from "../../../src/SdkConfig.ts";
import DMRoomMap from "../../../src/utils/DMRoomMap.ts";
import { WidgetMessagingEvent, type WidgetMessaging } from "../../../src/stores/widgets/WidgetMessaging.ts";
const { enabledSettings } = enableCalls();
const setUpWidget = (call: Call): { widget: Widget; messaging: Mocked<ClientWidgetApi> } => {
const setUpWidget = (
call: Call,
): { widget: Widget; messaging: Mocked<WidgetMessaging>; widgetApi: Mocked<ClientWidgetApi> } => {
call.widget.data = { ...call.widget, skipLobby: true };
const widget = new Widget(call.widget);
const eventEmitter = new EventEmitter();
const messaging = {
on: eventEmitter.on.bind(eventEmitter),
off: eventEmitter.off.bind(eventEmitter),
once: eventEmitter.once.bind(eventEmitter),
emit: eventEmitter.emit.bind(eventEmitter),
stop: jest.fn(),
transport: {
const widgetApi = new (class extends EventEmitter {
transport = {
send: jest.fn(),
reply: jest.fn(),
},
} as unknown as Mocked<ClientWidgetApi>;
};
})() as unknown as Mocked<ClientWidgetApi>;
const messaging = new (class extends EventEmitter {
stop = jest.fn();
widgetApi = widgetApi;
})() as unknown as Mocked<WidgetMessaging>;
WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging);
return { widget, messaging };
return { widget, messaging, widgetApi };
};
async function connect(call: Call, messaging: Mocked<ClientWidgetApi>, startWidget = true): Promise<void> {
async function connect(call: Call, widgetApi: Mocked<ClientWidgetApi>, startWidget = true): Promise<void> {
async function sessionConnect() {
await new Promise<void>((r) => {
setTimeout(() => r(), 400);
});
messaging.emit(`action:${ElementWidgetActions.JoinCall}`, new CustomEvent("widgetapirequest", {}));
widgetApi.emit(`action:${ElementWidgetActions.JoinCall}`, new CustomEvent("widgetapirequest", {}));
}
async function runTimers() {
jest.advanceTimersByTime(500);
@@ -87,12 +88,12 @@ async function connect(call: Call, messaging: Mocked<ClientWidgetApi>, startWidg
await Promise.all([...(startWidget ? [call.start()] : []), runTimers()]);
}
async function disconnect(call: Call, messaging: Mocked<ClientWidgetApi>): Promise<void> {
async function disconnect(call: Call, widgetApi: Mocked<ClientWidgetApi>): Promise<void> {
async function sessionDisconnect() {
await new Promise<void>((r) => {
setTimeout(() => r(), 400);
});
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
widgetApi.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
}
async function runTimers() {
jest.advanceTimersByTime(500);
@@ -150,7 +151,8 @@ describe("JitsiCall", () => {
describe("instance in a video room", () => {
let call: JitsiCall;
let widget: Widget;
let messaging: Mocked<ClientWidgetApi>;
let messaging: Mocked<WidgetMessaging>;
let widgetApi: Mocked<ClientWidgetApi>;
beforeEach(async () => {
jest.useFakeTimers();
@@ -161,16 +163,16 @@ describe("JitsiCall", () => {
if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall;
({ widget, messaging } = setUpWidget(call));
({ widget, messaging, widgetApi } = setUpWidget(call));
mocked(messaging.transport).send.mockImplementation(async (action, data): Promise<any> => {
mocked(widgetApi.transport).send.mockImplementation(async (action, data): Promise<any> => {
if (action === ElementWidgetActions.JoinCall) {
messaging.emit(
widgetApi.emit(
`action:${ElementWidgetActions.JoinCall}`,
new CustomEvent("widgetapirequest", { detail: { data } }),
);
} else if (action === ElementWidgetActions.HangupCall) {
messaging.emit(
widgetApi.emit(
`action:${ElementWidgetActions.HangupCall}`,
new CustomEvent("widgetapirequest", { detail: { data } }),
);
@@ -183,7 +185,7 @@ describe("JitsiCall", () => {
it("connects", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await connect(call, messaging);
await connect(call, widgetApi);
expect(call.connectionState).toBe(ConnectionState.Connected);
});
@@ -196,27 +198,27 @@ describe("JitsiCall", () => {
const startup = call.start();
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
await startup;
await connect(call, messaging, false);
await connect(call, widgetApi, false);
expect(call.connectionState).toBe(ConnectionState.Connected);
});
it("fails to disconnect if the widget returns an error", async () => {
await connect(call, messaging);
mocked(messaging.transport).send.mockRejectedValue(new Error("never!"));
await connect(call, widgetApi);
mocked(widgetApi.transport).send.mockRejectedValue(new Error("never!"));
await expect(call.disconnect()).rejects.toBeDefined();
});
it("handles remote disconnection", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await connect(call, messaging);
await connect(call, widgetApi);
expect(call.connectionState).toBe(ConnectionState.Connected);
const callback = jest.fn();
call.on(CallEvent.ConnectionState, callback);
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
widgetApi.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
await waitFor(() => {
expect(callback).toHaveBeenNthCalledWith(1, ConnectionState.Disconnected, ConnectionState.Connected);
});
@@ -226,14 +228,14 @@ describe("JitsiCall", () => {
it("disconnects", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await connect(call, messaging);
await connect(call, widgetApi);
expect(call.connectionState).toBe(ConnectionState.Connected);
await call.disconnect();
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("disconnects when we leave the room", async () => {
await connect(call, messaging);
await connect(call, widgetApi);
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
@@ -241,14 +243,14 @@ describe("JitsiCall", () => {
it("reconnects after disconnect in video rooms", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await connect(call, messaging);
await connect(call, widgetApi);
expect(call.connectionState).toBe(ConnectionState.Connected);
await call.disconnect();
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("remains connected if we stay in the room", async () => {
await connect(call, messaging);
await connect(call, widgetApi);
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, KnownMembership.Join);
expect(call.connectionState).toBe(ConnectionState.Connected);
@@ -274,7 +276,7 @@ describe("JitsiCall", () => {
// Now, stub out client.sendStateEvent so we can test our local echo
client.sendStateEvent.mockReset();
await connect(call, messaging);
await connect(call, widgetApi);
expect(call.participants).toEqual(
new Map([
[alice, new Set(["alices_device"])],
@@ -287,7 +289,7 @@ describe("JitsiCall", () => {
});
it("updates room state when connecting and disconnecting", async () => {
await connect(call, messaging);
await connect(call, widgetApi);
const now1 = Date.now();
await waitFor(
() =>
@@ -315,7 +317,7 @@ describe("JitsiCall", () => {
});
it("repeatedly updates room state while connected", async () => {
await connect(call, messaging);
await connect(call, widgetApi);
await waitFor(
() =>
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
@@ -345,7 +347,7 @@ describe("JitsiCall", () => {
const onConnectionState = jest.fn();
call.on(CallEvent.ConnectionState, onConnectionState);
await connect(call, messaging);
await connect(call, widgetApi);
await call.disconnect();
expect(onConnectionState.mock.calls).toEqual([
[ConnectionState.Connected, ConnectionState.Disconnected],
@@ -360,7 +362,7 @@ describe("JitsiCall", () => {
const onParticipants = jest.fn();
call.on(CallEvent.Participants, onParticipants);
await connect(call, messaging);
await connect(call, widgetApi);
await call.disconnect();
expect(onParticipants.mock.calls).toEqual([
[new Map([[alice, new Set(["alices_device"])]]), new Map()],
@@ -373,11 +375,11 @@ describe("JitsiCall", () => {
});
it("switches to spotlight layout when the widget becomes a PiP", async () => {
await connect(call, messaging);
await connect(call, widgetApi);
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
expect(widgetApi.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {});
expect(widgetApi.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {});
});
describe("clean", () => {
@@ -417,7 +419,7 @@ describe("JitsiCall", () => {
});
it("doesn't clean up valid devices", async () => {
await connect(call, messaging);
await connect(call, widgetApi);
await client.sendStateEvent(
room.roomId,
JitsiCall.MEMBER_EVENT_TYPE,
@@ -796,7 +798,8 @@ describe("ElementCall", () => {
describe("instance in a non-video room", () => {
let call: ElementCall;
let widget: Widget;
let messaging: Mocked<ClientWidgetApi>;
let messaging: Mocked<WidgetMessaging>;
let widgetApi: Mocked<ClientWidgetApi>;
beforeEach(async () => {
jest.useFakeTimers();
@@ -807,81 +810,128 @@ describe("ElementCall", () => {
if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall;
({ widget, messaging } = setUpWidget(call));
({ widget, messaging, widgetApi } = setUpWidget(call));
});
afterEach(() => cleanUpCallAndWidget(call, widget));
// TODO refactor initial device configuration to use the EW settings.
// Add tests for passing EW device configuration to the widget.
it("waits for messaging when starting", async () => {
it("waits for messaging when starting (widget API available immediately)", async () => {
// Temporarily remove the messaging to simulate connecting while the
// widget is still initializing
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
const startup = call.start({});
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
await startup;
await connect(call, messaging, false);
await connect(call, widgetApi, false);
expect(call.connectionState).toBe(ConnectionState.Connected);
});
it("waits for messaging when starting (widget API started asynchronously)", async () => {
// Temporarily remove the messaging to simulate connecting while the
// widget is still initializing
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
// Also remove the widget API from said messaging until later
let storedWidgetApi: Mocked<ClientWidgetApi> | null = null;
Object.defineProperty(messaging, "widgetApi", {
get() {
return storedWidgetApi;
},
});
expect(call.connectionState).toBe(ConnectionState.Disconnected);
const startup = call.start({});
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
// Yield the event loop to the Call.start promise, then simulate the
// widget API being started asynchronously
await Promise.resolve();
storedWidgetApi = widgetApi;
messaging.emit(WidgetMessagingEvent.Start, storedWidgetApi);
await startup;
await connect(call, widgetApi, false);
expect(call.connectionState).toBe(ConnectionState.Connected);
});
it("waits for messaging when starting (even if messaging is replaced during startup)", async () => {
const firstMessaging = messaging;
// Entirely remove the widget API from this first messaging
Object.defineProperty(firstMessaging, "widgetApi", {
get() {
return null;
},
});
expect(call.connectionState).toBe(ConnectionState.Disconnected);
const startup = call.start({});
// Now imagine that the messaging gets abandoned and replaced by an
// entirely new messaging object
({ widget, messaging, widgetApi } = setUpWidget(call));
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
await startup;
await connect(call, widgetApi, false);
expect(call.connectionState).toBe(ConnectionState.Connected);
expect(firstMessaging.listenerCount(WidgetMessagingEvent.Start)).toBe(0); // No leaks
});
it("fails to disconnect if the widget returns an error", async () => {
await connect(call, messaging);
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
await connect(call, widgetApi);
mocked(widgetApi.transport).send.mockRejectedValue(new Error("never!!1! >:("));
await expect(call.disconnect()).rejects.toBeDefined();
});
it("handles remote disconnection", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await connect(call, messaging);
await connect(call, widgetApi);
expect(call.connectionState).toBe(ConnectionState.Connected);
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
messaging.emit(`action:${ElementWidgetActions.Close}`, new CustomEvent("widgetapirequest", {}));
widgetApi.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
widgetApi.emit(`action:${ElementWidgetActions.Close}`, new CustomEvent("widgetapirequest", {}));
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 });
});
it("disconnects", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await connect(call, messaging);
await connect(call, widgetApi);
expect(call.connectionState).toBe(ConnectionState.Connected);
await disconnect(call, messaging);
await disconnect(call, widgetApi);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("disconnects when we leave the room", async () => {
await connect(call, messaging);
await connect(call, widgetApi);
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("remains connected if we stay in the room", async () => {
await connect(call, messaging);
await connect(call, widgetApi);
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, KnownMembership.Join);
expect(call.connectionState).toBe(ConnectionState.Connected);
});
it("disconnects if the widget dies", async () => {
await connect(call, messaging);
await connect(call, widgetApi);
expect(call.connectionState).toBe(ConnectionState.Connected);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("acknowledges mute_device widget action", async () => {
await connect(call, messaging);
await connect(call, widgetApi);
const preventDefault = jest.fn();
const mockEv = {
preventDefault,
detail: { video_enabled: false },
};
messaging.emit(`action:${ElementWidgetActions.DeviceMute}`, mockEv);
expect(messaging.transport.reply).toHaveBeenCalledWith({ video_enabled: false }, {});
widgetApi.emit(`action:${ElementWidgetActions.DeviceMute}`, mockEv);
expect(widgetApi.transport.reply).toHaveBeenCalledWith({ video_enabled: false }, {});
expect(preventDefault).toHaveBeenCalled();
});
@@ -890,8 +940,8 @@ describe("ElementCall", () => {
const onConnectionState = jest.fn();
call.on(CallEvent.ConnectionState, onConnectionState);
await connect(call, messaging);
await disconnect(call, messaging);
await connect(call, widgetApi);
await disconnect(call, widgetApi);
expect(onConnectionState.mock.calls).toEqual([
[ConnectionState.Connected, ConnectionState.Disconnected],
[ConnectionState.Disconnecting, ConnectionState.Connected],
@@ -913,10 +963,10 @@ describe("ElementCall", () => {
});
it("ends the call immediately if the session ended", async () => {
await connect(call, messaging);
await connect(call, widgetApi);
const onDestroy = jest.fn();
call.on(CallEvent.Destroy, onDestroy);
await disconnect(call, messaging);
await disconnect(call, widgetApi);
// this will be called automatically
// disconnect -> widget sends state event -> session manager notices no-one left
client.matrixRTC.emit(
@@ -957,7 +1007,7 @@ describe("ElementCall", () => {
describe("instance in a video room", () => {
let call: ElementCall;
let widget: Widget;
let messaging: Mocked<ClientWidgetApi>;
let widgetApi: Mocked<ClientWidgetApi>;
beforeEach(async () => {
jest.useFakeTimers();
@@ -970,29 +1020,29 @@ describe("ElementCall", () => {
if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall;
({ widget, messaging } = setUpWidget(call));
({ widget, widgetApi } = setUpWidget(call));
});
afterEach(() => cleanUpCallAndWidget(call, widget));
it("doesn't end the call when the last participant leaves", async () => {
await connect(call, messaging);
await connect(call, widgetApi);
const onDestroy = jest.fn();
call.on(CallEvent.Destroy, onDestroy);
await disconnect(call, messaging);
await disconnect(call, widgetApi);
expect(onDestroy).not.toHaveBeenCalled();
call.off(CallEvent.Destroy, onDestroy);
});
it("handles remote disconnection and reconnect right after", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await connect(call, messaging);
await connect(call, widgetApi);
expect(call.connectionState).toBe(ConnectionState.Connected);
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
widgetApi.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
// We should now be able to reconnect without manually starting the widget
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await connect(call, messaging, false);
await connect(call, widgetApi, false);
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connected), { interval: 5 });
});
});