Skip to content
Merged
53 changes: 35 additions & 18 deletions src/main/userMessaging/calls/CallOverlayHost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,10 @@ import { closeCall, getCallOverlay, openCall } from './callOverlayStore';

const makeClient = () =>
Object.assign(new EventEmitter(), {
baseUrl: 'https://hs.example',
getUserId: () => '@me:hs.example',
getDeviceId: () => 'DEV1',
getAccessToken: () => 'access-token',
_unstable_sendDelayedStateEvent: vi.fn(async () => ({ delay_id: 'leave-1' })),
_unstable_updateDelayedEvent: vi.fn(async () => ({})),
http: { authedRequest: vi.fn(async () => ({})) },
});

const CALL_MEMBER = 'org.matrix.msc3401.call.member';
Expand Down Expand Up @@ -269,23 +267,42 @@ describe('CallOverlayHost', () => {
});

it('leaves the call at once when the call UI goes away before it could leave by itself', async () => {
const fetchMock = vi.fn(async () => new Response('{}'));
vi.stubGlobal('fetch', fetchMock);
const { rerender } = render(<CallOverlayHost />);
const api = await startCall();
await api.driver.sendDelayedEvent(18_000, CALL_MEMBER, {}, OWN_MEMBERSHIP_KEY);
const client = harness.client as ReturnType<typeof makeClient>;
harness.client = null;
rerender(<CallOverlayHost />);

expect(api.stop).toHaveBeenCalled();
expect(client.http.authedRequest).toHaveBeenCalledTimes(1);
expect(client.http.authedRequest).toHaveBeenCalledWith(
'POST',
'/delayed_events/leave-1',
undefined,
{ action: 'send' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140', keepAlive: true }
);
});

it('hides the widget library’s failed-download error while the call is open, and only that', async () => {
const original = console.error;
const sink = vi.fn();
console.error = sink;
try {
const { rerender } = render(<CallOverlayHost />);
const api = await startCall();
await api.driver.sendDelayedEvent(18_000, CALL_MEMBER, {}, OWN_MEMBERSHIP_KEY);
harness.client = null;
rerender(<CallOverlayHost />);

expect(api.stop).toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
'https://hs.example/_matrix/client/unstable/org.matrix.msc4140/delayed_events/leave-1',
expect.objectContaining({ method: 'POST', keepalive: true, body: JSON.stringify({ action: 'send' }) })
);
render(<CallOverlayHost />);
await startCall();
const failure = new Error('Downloading the media failed with status 500');
console.error('error while downloading a file', failure);
console.error('error updating delayed event: ', failure);
expect(sink.mock.calls).toEqual([['error updating delayed event: ', failure]]);

act(() => closeCurrentCall());
expect(console.error).toBe(sink);
console.error('error while downloading a file', failure);
expect(sink).toHaveBeenCalledTimes(2);
} finally {
vi.unstubAllGlobals();
console.error = original;
}
});

Expand Down
3 changes: 3 additions & 0 deletions src/main/userMessaging/calls/CallWidgetFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ClientWidgetApi, type IWidgetApiRequest, Widget } from 'matrix-widget-a
import { type RefObject, useEffect, useRef, useState } from 'react';
import { closeCall } from './callOverlayStore';
import { ElementCallWidgetDriver, toRoomEvent } from './ElementCallWidgetDriver';
import { quietDownloadErrors } from './quietDownloadErrors';

// matrix-js-sdk ClientEvent.Event and RoomStateEvent.Events, as strings so the SDK stays a lazily loaded chunk.
const CLIENT_EVENT = 'event';
Expand Down Expand Up @@ -61,6 +62,7 @@ export default function CallWidgetFrame({
});
const driver = new ElementCallWidgetDriver({ client, matrixRoomId, livekitServiceUrl });
const api = new ClientWidgetApi(widget, iframe, driver);
const releaseConsoleFilter = quietDownloadErrors();
api.setViewedRoomId(matrixRoomId);
const onPageHide = () => driver.sendPendingLeavesNow();
window.addEventListener('pagehide', onPageHide);
Expand Down Expand Up @@ -121,6 +123,7 @@ export default function CallWidgetFrame({
api.off(`action:${HANGUP_ACTION}`, onWidgetLeft);
api.off(`action:${CLOSE_ACTION}`, onWidgetLeft);
api.stop();
releaseConsoleFilter();
};
}, [client, matrixRoomId, livekitServiceUrl, widgetId, frameSrc, hangupRef]);

Expand Down
227 changes: 198 additions & 29 deletions src/main/userMessaging/calls/ElementCallWidgetDriver.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getHttpUriForMxc } from 'matrix-js-sdk';
import { createClient, getHttpUriForMxc } from 'matrix-js-sdk';
import { EventDirection, OpenIDRequestState, WidgetEventCapability } from 'matrix-widget-api';
import { describe, expect, it, vi } from 'vitest';
import { allowedCapabilitiesFor, ElementCallWidgetDriver } from './ElementCallWidgetDriver';
Expand Down Expand Up @@ -132,10 +132,15 @@ const makeClient = (timeline: FakeEvent[] = [], state: FakeEvent[] = []) => {
: (state.find(event => event.getType() === type && event.getStateKey() === stateKey) ?? null),
},
};
// The chat SDK's own request layer, sending through whatever `fetch` the test stubs.
const { http } = createClient({
baseUrl: 'https://matrix.example.org',
accessToken: 'access-token',
fetchFn: ((...args: Parameters<typeof fetch>) => globalThis.fetch(...args)) as typeof fetch,
});
vi.spyOn(http, 'authedRequest');
return {
room,
baseUrl: 'https://matrix.example.org',
getAccessToken: () => 'access-token',
getUserId: () => USER,
getDeviceId: () => DEVICE,
getRoom: vi.fn((roomId: string) => (roomId === ROOM ? room : null)),
Expand All @@ -153,7 +158,7 @@ const makeClient = (timeline: FakeEvent[] = [], state: FakeEvent[] = []) => {
redactEvent: vi.fn(async () => ({ event_id: '$redaction' })),
_unstable_sendDelayedStateEvent: vi.fn(async () => ({ delay_id: 'delay-state' })),
_unstable_sendDelayedEvent: vi.fn(async () => ({ delay_id: 'delay-event' })),
_unstable_updateDelayedEvent: vi.fn(async () => ({})),
http,
};
};

Expand Down Expand Up @@ -260,36 +265,85 @@ describe('ElementCallWidgetDriver — host behaviour', () => {
});

it('maps delayed-event updates to the body-form update the pinned chat server serves', async () => {
const client = makeClient();
const driver = driverFor(client);
await driver.cancelScheduledDelayedEvent('d1');
await driver.restartScheduledDelayedEvent('d2');
await driver.sendScheduledDelayedEvent('d3');
expect(client._unstable_updateDelayedEvent.mock.calls).toEqual([
['d1', 'cancel'],
['d2', 'restart'],
['d3', 'send'],
]);
vi.stubGlobal(
'fetch',
vi.fn(async () => Response.json({}))
);
try {
const client = makeClient();
const driver = driverFor(client);
await driver.cancelScheduledDelayedEvent('d1');
await driver.restartScheduledDelayedEvent('d2');
await driver.sendScheduledDelayedEvent('d/3');
const opts = { prefix: '/_matrix/client/unstable/org.matrix.msc4140', keepAlive: false };
expect(vi.mocked(client.http.authedRequest).mock.calls).toEqual([
['POST', '/delayed_events/d1', undefined, { action: 'cancel' }, opts],
['POST', '/delayed_events/d2', undefined, { action: 'restart' }, opts],
['POST', '/delayed_events/d%2F3', undefined, { action: 'send' }, opts],
]);
} finally {
vi.unstubAllGlobals();
}
});

it.each([
['cancel', 'cancelScheduledDelayedEvent'],
['restart', 'restartScheduledDelayedEvent'],
['send', 'sendScheduledDelayedEvent'],
] as const)('sends the %s update of a delayed event once, with the session token', async (action, method) => {
// The real chat client, so a request it sends without the token is visible here.
const fetchFn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).includes('/_matrix/client/versions')) {
return Response.json({ versions: ['v1.11'], unstable_features: { 'org.matrix.msc4140': true } });
}
// The pinned chat server refuses an update without the token.
return new Headers(init?.headers).has('Authorization')
? Response.json({})
: Response.json({ errcode: 'M_MISSING_TOKEN', error: 'Missing access token' }, { status: 401 });
});
const client = createClient({
baseUrl: 'https://matrix.example.org',
accessToken: 'access-token',
userId: USER,
deviceId: DEVICE,
fetchFn: fetchFn as unknown as typeof fetch,
});
const driver = new ElementCallWidgetDriver({ client, matrixRoomId: ROOM, livekitServiceUrl: TOKEN_SERVICE });

await driver[method]('delay-1');

const updates = fetchFn.mock.calls.filter(([input]) => String(input).includes('/delayed_events/'));
expect(updates).toHaveLength(1);
const [input, init] = updates[0];
expect(String(input)).toBe(
'https://matrix.example.org/_matrix/client/unstable/org.matrix.msc4140/delayed_events/delay-1'
);
expect(init?.method).toBe('POST');
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer access-token');
expect(JSON.parse(String(init?.body))).toEqual({ action });
});

it('fires the scheduled leave at once, in a request that outlives the page, when the page goes away', async () => {
const client = makeClient();
const fetchMock = vi.fn(async () => new Response('{}'));
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => Response.json({}));
vi.stubGlobal('fetch', fetchMock);
try {
const driver = driverFor(client);
await driver.sendDelayedEvent(3600000, CALL_MEMBER, {}, `_${USER}_${DEVICE}_m.call`, ROOM);
driver.sendPendingLeavesNow();
// The request leaves within the page-exit task: only already-settled steps run before it, no timer.
for (let step = 0; step < 10; step++) {
await Promise.resolve();
}
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
'https://matrix.example.org/_matrix/client/unstable/org.matrix.msc4140/delayed_events/delay-state',
{
method: 'POST',
keepalive: true,
headers: { Authorization: 'Bearer access-token', 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'send' }),
}
const [input, init] = fetchMock.mock.calls[0];
expect(String(input)).toBe(
'https://matrix.example.org/_matrix/client/unstable/org.matrix.msc4140/delayed_events/delay-state'
);
expect(init?.method).toBe('POST');
expect(init?.keepalive).toBe(true);
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer access-token');
expect(JSON.parse(String(init?.body))).toEqual({ action: 'send' });
} finally {
vi.unstubAllGlobals();
}
Expand All @@ -308,7 +362,9 @@ describe('ElementCallWidgetDriver — host behaviour', () => {
await driver.sendDelayedEvent(8000, CALL_MEMBER, {}, `_${USER}_${DEVICE}_m.call`, ROOM);
await driver.sendScheduledDelayedEvent('sent');
await driver.sendDelayedEvent(500, 'm.reaction', { key: 'x' });
fetchMock.mockClear();
driver.sendPendingLeavesNow();
await new Promise(resolve => setTimeout(resolve, 0));
expect(fetchMock).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
Expand Down Expand Up @@ -440,16 +496,17 @@ describe('ElementCallWidgetDriver — host behaviour', () => {
});

it('downloads media of the chat server through its authenticated media endpoint, as the platform session', async () => {
const fetchMock = vi.fn(async () => new Response('avatar-bytes'));
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response('avatar-bytes'));
vi.stubGlobal('fetch', fetchMock);
try {
const { file } = await driverFor(makeClient()).downloadFile('mxc://matrix.example.org/abc123');
expect(fetchMock).toHaveBeenCalledWith(
'https://matrix.example.org/_matrix/client/v1/media/download/matrix.example.org/abc123?allow_redirect=true',
{
headers: { Authorization: 'Bearer access-token' },
}
expect(fetchMock).toHaveBeenCalledTimes(1);
const [input, init] = fetchMock.mock.calls[0];
expect(String(input)).toBe(
'https://matrix.example.org/_matrix/client/v1/media/download/matrix.example.org/abc123?allow_redirect=true'
);
expect(init?.method).toBe('GET');
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer access-token');
// fetch's Blob and jsdom's global Blob can come from different realms, so
// `instanceof Blob` depends on the runtime; check the brand instead.
expect(Object.prototype.toString.call(file)).toBe('[object Blob]');
Expand All @@ -472,6 +529,118 @@ describe('ElementCallWidgetDriver — host behaviour', () => {
}
});

it.each([
['refused', () => Promise.resolve(new Response('{}', { status: 500 }))],
['unreachable', () => Promise.reject(new TypeError('Failed to fetch'))],
[
'cut off while reading',
() =>
Promise.resolve(
new Response(
new ReadableStream({
start(controller) {
controller.error(new TypeError('network error'));
},
})
)
),
],
])('fails a download that was %s again for a while, without a request', async (_, answer) => {
const fetchMock = vi.fn(answer);
vi.stubGlobal('fetch', fetchMock);
try {
const driver = driverFor(makeClient());
await expect(driver.downloadFile('mxc://matrix.example.org/broken')).rejects.toThrow();
await expect(driver.downloadFile('mxc://matrix.example.org/broken')).rejects.toThrow();
expect(fetchMock).toHaveBeenCalledTimes(1);

// Other media of the call, and the same media in another call, are still fetched.
fetchMock.mockImplementation(async () => new Response('avatar-bytes'));
await expect(driver.downloadFile('mxc://matrix.example.org/fine')).resolves.toBeDefined();
await expect(driverFor(makeClient()).downloadFile('mxc://matrix.example.org/broken')).resolves.toBeDefined();
expect(fetchMock).toHaveBeenCalledTimes(3);
} finally {
vi.unstubAllGlobals();
}
});

it('shares one request among downloads of the same media that overlap, and fetches it afresh after a success', async () => {
let answer: (response: Response) => void = () => undefined;
const fetchMock = vi.fn(() => new Promise<Response>(resolve => (answer = resolve)));
vi.stubGlobal('fetch', fetchMock);
try {
const driver = driverFor(makeClient());
const first = driver.downloadFile('mxc://matrix.example.org/broken');
const second = driver.downloadFile('mxc://matrix.example.org/broken');
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
answer(new Response('{}', { status: 500 }));
await expect(first).rejects.toMatchObject({ httpStatus: 500 });
await expect(second).rejects.toMatchObject({ httpStatus: 500 });
expect(fetchMock).toHaveBeenCalledTimes(1);

const third = driver.downloadFile('mxc://matrix.example.org/fine');
const fourth = driver.downloadFile('mxc://matrix.example.org/fine');
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
answer(new Response('avatar-bytes'));
await expect(third).resolves.toBeDefined();
await expect(fourth).resolves.toBeDefined();
expect(fetchMock).toHaveBeenCalledTimes(2);

const fifth = driver.downloadFile('mxc://matrix.example.org/fine');
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
answer(new Response('avatar-bytes'));
await expect(fifth).resolves.toBeDefined();
expect(fetchMock).toHaveBeenCalledTimes(3);
} finally {
vi.unstubAllGlobals();
}
});

it('fetches a failed download again once the hold has passed', async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] });
const fetchMock = vi.fn(async () => new Response('{}', { status: 500 }));
vi.stubGlobal('fetch', fetchMock);
try {
const driver = driverFor(makeClient());
await expect(driver.downloadFile('mxc://matrix.example.org/flaky')).rejects.toThrow();
vi.advanceTimersByTime(59_000);
await expect(driver.downloadFile('mxc://matrix.example.org/flaky')).rejects.toThrow();
expect(fetchMock).toHaveBeenCalledTimes(1);

vi.advanceTimersByTime(1_000);
fetchMock.mockImplementation(async () => new Response('avatar-bytes'));
await expect(driver.downloadFile('mxc://matrix.example.org/flaky')).resolves.toBeDefined();
expect(fetchMock).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
vi.unstubAllGlobals();
}
});

it('gives up a download that hangs, so later asks are not held behind it', async () => {
vi.useFakeTimers({ toFake: ['setTimeout'] });
const fetchMock = vi.fn(
(_: RequestInfo | URL, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
})
);
vi.stubGlobal('fetch', fetchMock);
try {
const driver = driverFor(makeClient());
const pending = driver.downloadFile('mxc://matrix.example.org/stuck');
const settled = vi.fn();
pending.then(settled, settled);
await vi.advanceTimersByTimeAsync(19_999);
expect(settled).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await expect(pending).rejects.toThrow();
} finally {
vi.useRealTimers();
vi.unstubAllGlobals();
}
});

it('fails the download when the chat server refuses it, so the call UI falls back to initials', async () => {
vi.stubGlobal(
'fetch',
Expand Down
Loading
Loading