From 4010411b81a24a2a10a6245676ff11a2717b88d5 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Wed, 7 Oct 2026 17:58:55 +0200 Subject: [PATCH 1/4] Add e2e tests for endpoint usage --- backend/dev_livekit-othersite.yaml | 5 ++ backend/dev_livekit.yaml | 5 ++ docker-compose-dev.yml | 6 +- playwright/url-transport.spec.ts | 135 +++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 playwright/url-transport.spec.ts diff --git a/backend/dev_livekit-othersite.yaml b/backend/dev_livekit-othersite.yaml index 53fc9ce94..476ecc45c 100644 --- a/backend/dev_livekit-othersite.yaml +++ b/backend/dev_livekit-othersite.yaml @@ -6,6 +6,11 @@ rtc: port_range_start: 50300 port_range_end: 50400 use_external_ip: false + # Advertise the loopback address instead of the container IP, which is not + # routable from the host when Docker runs in a VM (colima, Docker Desktop). + # The browser then reaches the SFU through the published ports. Firefox + # ignores loopback candidates, so this only works in Chromium-based browsers. + node_ip: 127.0.0.1 turn: enabled: false domain: localhost diff --git a/backend/dev_livekit.yaml b/backend/dev_livekit.yaml index 6cef4241a..576c67589 100644 --- a/backend/dev_livekit.yaml +++ b/backend/dev_livekit.yaml @@ -6,6 +6,11 @@ rtc: port_range_start: 50100 port_range_end: 50200 use_external_ip: false + # Advertise the loopback address instead of the container IP, which is not + # routable from the host when Docker runs in a VM (colima, Docker Desktop). + # The browser then reaches the SFU through the published ports. Firefox + # ignores loopback candidates, so this only works in Chromium-based browsers. + node_ip: 127.0.0.1 turn: enabled: false domain: localhost diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 3de89ec38..d77fad112 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -3,7 +3,8 @@ networks: services: auth-service: - image: ghcr.io/element-hq/lk-jwt-service:0.7.0 + # Need to use rolling main until the `member_id` change has been released. + image: ghcr.io/element-hq/lk-jwt-service:latest-ci pull_policy: always hostname: auth-server environment: @@ -33,7 +34,8 @@ services: - ecbackend auth-service-1: - image: ghcr.io/element-hq/lk-jwt-service:0.7.0 + # Need to use rolling main until the `member_id` change has been released. + image: ghcr.io/element-hq/lk-jwt-service:latest-ci pull_policy: always hostname: auth-server-1 environment: diff --git a/playwright/url-transport.spec.ts b/playwright/url-transport.spec.ts new file mode 100644 index 000000000..17ff1389b --- /dev/null +++ b/playwright/url-transport.spec.ts @@ -0,0 +1,135 @@ +/* +Copyright 2026 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 { expect, type Page, test } from "@playwright/test"; + +import { SpaHelpers } from "./spa-helpers"; + +const sfuUrl = "wss://matrix-rtc.m.localhost/livekit/sfu"; +const serviceUrl = "https://matrix-rtc.m.localhost/livekit/jwt"; + +interface Transport { + type: "livekit"; + url?: string; + livekit_service_url?: string; +} + +/** + * The transport shapes a homeserver may advertise while migrating to the + * latest MSC4195. `legacy` is what the e2e Synapse advertises on its own. + */ +const shapes = { + url: { type: "livekit", url: sfuUrl }, + both: { type: "livekit", url: sfuUrl, livekit_service_url: serviceUrl }, + legacy: null, +} satisfies Record; +type Shape = keyof typeof shapes; + +/** Which endpoint a page asks for its SFU tokens. */ +type Flow = "cs-api" | "service-legacy" | "service-default"; + +const pairs: [host: Shape, guest: Shape][] = [ + ["url", "url"], + ["both", "both"], + ["url", "legacy"], +]; + +for (const [hostShape, guestShape] of pairs) { + test(`One to one call with ${hostShape} host and ${guestShape} guest transport`, async ({ + browser, + page, + browserName, + }) => { + test.skip( + browserName === "firefox", + "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", + ); + + await advertiseTransport(page, shapes[hostShape]); + const hostRequests = recordAuthRequests(page); + await page.goto("/"); + await SpaHelpers.createCall(page, "Androl", "HelloCall", true, "2_0"); + const inviteLink = await SpaHelpers.getCallInviteLink(page); + + const guestContext = await browser.newContext({ reducedMotion: "reduce" }); + const guestPage = await guestContext.newPage(); + await advertiseTransport(guestPage, shapes[guestShape]); + const guestRequests = recordAuthRequests(guestPage); + await SpaHelpers.joinCallFromInviteLink( + guestPage, + inviteLink, + "Pevara", + "2_0", + ); + + await SpaHelpers.expectVideoTilesCount(page, 2); + await SpaHelpers.expectVideoTilesCount(guestPage, 2); + + expect(hostRequests.flows).toEqual(expectedFlows(hostShape, guestShape)); + expect(guestRequests.flows).toEqual(expectedFlows(guestShape, hostShape)); + for (const requests of [hostRequests, guestRequests]) { + for (const url of requests.csApiUrls) expect(url).toBe(sfuUrl); + } + }); +} + +/** + * A page publishes on its own transport and subscribes on every other + * transport in the call, so the flows it uses depend on both shapes. + */ +function expectedFlows(own: Shape, peer: Shape): Set { + const flows = new Set([publishFlow(own)]); + if (peer !== own) flows.add(subscribeFlow(peer)); + return flows; +} + +function publishFlow(shape: Shape): Flow { + return shape === "legacy" ? "service-legacy" : "cs-api"; +} + +function subscribeFlow(shape: Shape): Flow { + return shape === "legacy" ? "service-default" : "cs-api"; +} + +/** + * Replaces the transports the homeserver advertises to this page. The real + * response is fetched first so its CORS headers carry over. + */ +async function advertiseTransport( + page: Page, + transport: Transport | null, +): Promise { + if (transport === null) return; + await page.route("**/org.matrix.msc4143/rtc/transports", async (route) => { + if (route.request().method() !== "GET") return route.continue(); + const response = await route.fetch(); + await route.fulfill({ response, json: { rtc_transports: [transport] } }); + }); +} + +interface AuthRequests { + flows: Set; + /** The `url` field of every request to the MSC4195 `get_token` endpoint. */ + csApiUrls: string[]; +} + +function recordAuthRequests(page: Page): AuthRequests { + const requests: AuthRequests = { flows: new Set(), csApiUrls: [] }; + page.on("request", (request) => { + if (request.method() !== "POST") return; + const url = request.url(); + if (url.endsWith("/rtc/livekit/get_token")) { + requests.flows.add("cs-api"); + requests.csApiUrls.push((request.postDataJSON() as { url: string }).url); + } else if (url.endsWith("/livekit/jwt/sfu/get")) { + requests.flows.add("service-legacy"); + } else if (url.endsWith("/livekit/jwt/get_token")) { + requests.flows.add("service-default"); + } + }); + return requests; +} From f86c3f71141dcad967f28fb4471b596033dce3fa Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 8 Oct 2026 08:26:03 +0200 Subject: [PATCH 2/4] Add error tests --- playwright/url-transport.spec.ts | 186 ++++++++++++++++++++++++------- 1 file changed, 148 insertions(+), 38 deletions(-) diff --git a/playwright/url-transport.spec.ts b/playwright/url-transport.spec.ts index 17ff1389b..2c6cb963c 100644 --- a/playwright/url-transport.spec.ts +++ b/playwright/url-transport.spec.ts @@ -32,29 +32,48 @@ type Shape = keyof typeof shapes; /** Which endpoint a page asks for its SFU tokens. */ type Flow = "cs-api" | "service-legacy" | "service-default"; -const pairs: [host: Shape, guest: Shape][] = [ - ["url", "url"], - ["both", "both"], - ["url", "legacy"], +const cases: { + hostShape: Shape; + guestShape: Shape; + hostFlows: Flow[]; + guestFlows: Flow[]; +}[] = [ + { + hostShape: "url", + guestShape: "url", + hostFlows: ["cs-api"], + guestFlows: ["cs-api"], + }, + { + hostShape: "both", + guestShape: "both", + hostFlows: ["cs-api"], + guestFlows: ["cs-api"], + }, + { + hostShape: "url", + guestShape: "legacy", + hostFlows: ["cs-api", "service-default"], + guestFlows: ["service-legacy", "cs-api"], + }, ]; -for (const [hostShape, guestShape] of pairs) { +for (const { hostShape, guestShape, hostFlows, guestFlows } of cases) { test(`One to one call with ${hostShape} host and ${guestShape} guest transport`, async ({ browser, page, browserName, }) => { - test.skip( - browserName === "firefox", - "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", - ); + skipOnFirefox(browserName); + // The host starts the call. await advertiseTransport(page, shapes[hostShape]); const hostRequests = recordAuthRequests(page); await page.goto("/"); await SpaHelpers.createCall(page, "Androl", "HelloCall", true, "2_0"); const inviteLink = await SpaHelpers.getCallInviteLink(page); + // The guest joins. const guestContext = await browser.newContext({ reducedMotion: "reduce" }); const guestPage = await guestContext.newPage(); await advertiseTransport(guestPage, shapes[guestShape]); @@ -66,33 +85,104 @@ for (const [hostShape, guestShape] of pairs) { "2_0", ); + // Wait for the call to connect and render. await SpaHelpers.expectVideoTilesCount(page, 2); await SpaHelpers.expectVideoTilesCount(guestPage, 2); - expect(hostRequests.flows).toEqual(expectedFlows(hostShape, guestShape)); - expect(guestRequests.flows).toEqual(expectedFlows(guestShape, hostShape)); - for (const requests of [hostRequests, guestRequests]) { - for (const url of requests.csApiUrls) expect(url).toBe(sfuUrl); + // The host and guest ran through the expected flows. + expect(flowsOf(hostRequests)).toEqual(new Set(hostFlows)); + expect(flowsOf(guestRequests)).toEqual(new Set(guestFlows)); + + // All requests succeeded and the SFU URL was set on C-S requests. + for (const request of [...hostRequests, ...guestRequests]) { + expect(request.status).toBe(200); + if (request.flow === "cs-api") expect(request.sfuUrl).toBe(sfuUrl); } }); } +test("Subscriber falls back to the JWT service when the homeserver lacks MSC4195", async ({ + browser, + page, + browserName, +}) => { + skipOnFirefox(browserName); + + // The host starts the call with both transport shapes. + await advertiseTransport(page, shapes.both); + await page.goto("/"); + await SpaHelpers.createCall(page, "Androl", "HelloCall", true, "2_0"); + const inviteLink = await SpaHelpers.getCallInviteLink(page); + + // The guest joins but their C-S requests fail. + const guestContext = await browser.newContext({ reducedMotion: "reduce" }); + const guestPage = await guestContext.newPage(); + await rejectCsApiTokens(guestPage); + const guestRequests = recordAuthRequests(guestPage); + await SpaHelpers.joinCallFromInviteLink( + guestPage, + inviteLink, + "Pevara", + "2_0", + ); + // Wait for the call to connect and render. + await SpaHelpers.expectVideoTilesCount(page, 2); + await SpaHelpers.expectVideoTilesCount(guestPage, 2); + + // The guest ran through all flows. + expect(flowsOf(guestRequests)).toEqual( + new Set(["cs-api", "service-legacy", "service-default"]), + ); + + // The C-S token requests failed. + for (const request of guestRequests) { + expect(request.status).toBe(request.flow === "cs-api" ? 404 : 200); + } + + // The C-S token requests preceeded the fallback request. + const flows = guestRequests.map((r) => r.flow); + expect(flows.indexOf("service-default")).toBeGreaterThan( + flows.indexOf("cs-api"), + ); +}); + +test("Publisher shows an error when the homeserver lacks MSC4195", async ({ + page, + browserName, +}) => { + skipOnFirefox(browserName); + + // The host tries to start a call with only the C-S transport shape available but + // their C-S requests fail. + await advertiseTransport(page, shapes.url); + await rejectCsApiTokens(page); + await page.goto("/"); + await SpaHelpers.createCall(page, "Androl", "HelloCall", true, "2_0"); + + // We land on an error page. + await expect( + page.getByRole("heading", { name: "Something went wrong" }), + ).toBeVisible(); + await expect( + page.getByText( + "The authorization service for your media server (SFU) is out of date.", + ), + ).toBeVisible(); +}); + +function skipOnFirefox(browserName: string): void { + test.skip( + browserName === "firefox", + "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", + ); +} + /** - * A page publishes on its own transport and subscribes on every other - * transport in the call, so the flows it uses depend on both shapes. + * Maps recorded requests to a set of flows. Publisher and subscriber connections + * start concurrently so using arrays for comparison isn't convenient. */ -function expectedFlows(own: Shape, peer: Shape): Set { - const flows = new Set([publishFlow(own)]); - if (peer !== own) flows.add(subscribeFlow(peer)); - return flows; -} - -function publishFlow(shape: Shape): Flow { - return shape === "legacy" ? "service-legacy" : "cs-api"; -} - -function subscribeFlow(shape: Shape): Flow { - return shape === "legacy" ? "service-default" : "cs-api"; +function flowsOf(requests: AuthRequest[]): Set { + return new Set(requests.map((r) => r.flow)); } /** @@ -111,24 +201,44 @@ async function advertiseTransport( }); } -interface AuthRequests { - flows: Set; - /** The `url` field of every request to the MSC4195 `get_token` endpoint. */ - csApiUrls: string[]; +/** + * Makes the homeserver look like one without MSC4195: its `get_token` + * endpoint answers as Synapse does for an unknown route. + */ +async function rejectCsApiTokens(page: Page): Promise { + await page.route("**/rtc/livekit/get_token", async (route) => { + if (route.request().method() !== "POST") return route.continue(); + await route.fulfill({ + status: 404, + json: { errcode: "M_UNRECOGNIZED", error: "Unrecognized request" }, + headers: { "Access-Control-Allow-Origin": "*" }, + }); + }); } -function recordAuthRequests(page: Page): AuthRequests { - const requests: AuthRequests = { flows: new Set(), csApiUrls: [] }; - page.on("request", (request) => { +interface AuthRequest { + flow: Flow; + status: number; + /** The SFU a request to the MSC4195 `get_token` endpoint asked for. */ + sfuUrl?: string; +} + +function recordAuthRequests(page: Page): AuthRequest[] { + const requests: AuthRequest[] = []; + page.on("response", (response) => { + const request = response.request(); if (request.method() !== "POST") return; const url = request.url(); if (url.endsWith("/rtc/livekit/get_token")) { - requests.flows.add("cs-api"); - requests.csApiUrls.push((request.postDataJSON() as { url: string }).url); + requests.push({ + flow: "cs-api", + status: response.status(), + sfuUrl: (request.postDataJSON() as { url: string }).url, + }); } else if (url.endsWith("/livekit/jwt/sfu/get")) { - requests.flows.add("service-legacy"); + requests.push({ flow: "service-legacy", status: response.status() }); } else if (url.endsWith("/livekit/jwt/get_token")) { - requests.flows.add("service-default"); + requests.push({ flow: "service-default", status: response.status() }); } }); return requests; From f5d3ee08917556bf424937a1c0c5c6621c9577ac Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 8 Oct 2026 08:45:07 +0200 Subject: [PATCH 3/4] Add delegation test --- backend/app-service-othersite.yaml | 2 +- backend/app-service.yaml | 2 +- playwright/url-transport.spec.ts | 109 +++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/backend/app-service-othersite.yaml b/backend/app-service-othersite.yaml index ef2a7ada7..a80322baf 100644 --- a/backend/app-service-othersite.yaml +++ b/backend/app-service-othersite.yaml @@ -5,7 +5,7 @@ sender_localpart: "_lk_jwt_service" namespaces: users: - exclusive: false - regex: "@.*:homeserver-1" # Cover all local users + regex: "@.*:synapse\\.othersite\\.m\\.localhost" # Cover all local users # Required for Synapse to call POST /_matrix/app/v1/ping on this service. url: "http://auth-server-1:16080" # Enable membership look-ups via /is_joined. diff --git a/backend/app-service.yaml b/backend/app-service.yaml index b480df64e..75716ca59 100644 --- a/backend/app-service.yaml +++ b/backend/app-service.yaml @@ -5,7 +5,7 @@ sender_localpart: "_lk_jwt_service" namespaces: users: - exclusive: false - regex: "@.*:homeserver" # Cover all local users + regex: "@.*:synapse\\.m\\.localhost" # Cover all local users # Required for Synapse to call POST /_matrix/app/v1/ping on this service. url: "http://auth-server:6080" # Enable membership look-ups via /is_joined. diff --git a/playwright/url-transport.spec.ts b/playwright/url-transport.spec.ts index 2c6cb963c..155f618e6 100644 --- a/playwright/url-transport.spec.ts +++ b/playwright/url-transport.spec.ts @@ -125,6 +125,7 @@ test("Subscriber falls back to the JWT service when the homeserver lacks MSC4195 "Pevara", "2_0", ); + // Wait for the call to connect and render. await SpaHelpers.expectVideoTilesCount(page, 2); await SpaHelpers.expectVideoTilesCount(guestPage, 2); @@ -170,6 +171,58 @@ test("Publisher shows an error when the homeserver lacks MSC4195", async ({ ).toBeVisible(); }); +test("Delegated leave on a url transport ends the membership when the guest drops", async ({ + browser, + page, + browserName, +}) => { + skipOnFirefox(browserName); + + // The host starts the call. + await advertiseTransport(page, shapes.url); + await page.goto("/"); + await SpaHelpers.createCall(page, "Androl", "HelloCall", true, "2_0"); + const inviteLink = await SpaHelpers.getCallInviteLink(page); + + // The guest joins. + const guestContext = await browser.newContext({ reducedMotion: "reduce" }); + const guestPage = await guestContext.newPage(); + await advertiseTransport(guestPage, shapes.url); + const delayedLeave = recordDelayedLeave(guestPage); + const delegations = recordDelegations(guestPage); + await SpaHelpers.joinCallFromInviteLink( + guestPage, + inviteLink, + "Pevara", + "2_0", + ); + + // Wait for the call to connect and render. + await SpaHelpers.expectVideoTilesCount(page, 2); + await SpaHelpers.expectVideoTilesCount(guestPage, 2); + + // The guest handed its delayed leave to the homeserver, after probing it for + // delegation support with bodiless requests. + await expect + .poll(() => delegations.at(-1)) + .toEqual({ status: 200, sfuUrl, delayId: delayedLeave.delayId }); + const probes = delegations.slice(0, -1); + expect(probes.length).toBeGreaterThan(0); + for (const probe of probes) expect(probe).toEqual({ status: 401 }); + + // The delay is long enough that a leave within the window below cannot come + // from the delayed event expiring on its own. + expect(delayedLeave.delayMs).toBeGreaterThan(60_000); + + // The guest drops without leaving. + await guestPage.reload(); + + // The host eventually sees the guest go once the delayed event fires. + await expect(page.getByTestId("videoTile")).toHaveCount(1, { + timeout: 30_000, + }); +}); + function skipOnFirefox(browserName: string): void { test.skip( browserName === "firefox", @@ -216,6 +269,61 @@ async function rejectCsApiTokens(page: Page): Promise { }); } +/** A recorded delayed leave event. */ +interface DelayedLeave { + delayMs?: number; + delayId?: string; +} + +/** Records the delayed leave event the page schedules on joining. */ +function recordDelayedLeave(page: Page): DelayedLeave { + const delayedLeave: DelayedLeave = {}; + page.on("response", (response) => { + const delay = new URL(response.request().url()).searchParams.get( + "org.matrix.msc4140.delay", + ); + if (delay === null || response.status() !== 200) return; + delayedLeave.delayMs = Number(delay); + response + .json() + .then((body: { delay_id: string }) => { + delayedLeave.delayId = body.delay_id; + }) + .catch(() => {}); + }); + return delayedLeave; +} + +/** A recorded delegation request for a delayed leave event. */ +interface Delegation { + status: number; + sfuUrl?: string; + delayId?: string; +} + +/** Records requests to the MSC4195 `delegate_delayed_leave` endpoint. */ +function recordDelegations(page: Page): Delegation[] { + const delegations: Delegation[] = []; + page.on("response", (response) => { + const request = response.request(); + if ( + request.method() !== "POST" || + !request.url().endsWith("/rtc/livekit/delegate_delayed_leave") + ) + return; + const body = request.postDataJSON() as { + url: string; + delay_id: string; + } | null; + delegations.push({ + status: response.status(), + ...(body && { sfuUrl: body.url, delayId: body.delay_id }), + }); + }); + return delegations; +} + +/** A recorded token request. */ interface AuthRequest { flow: Flow; status: number; @@ -223,6 +331,7 @@ interface AuthRequest { sfuUrl?: string; } +/** Records token requests. */ function recordAuthRequests(page: Page): AuthRequest[] { const requests: AuthRequest[] = []; page.on("response", (response) => { From 56d6091b98794ea17747d059e0ba7109928d214f Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 8 Oct 2026 08:55:19 +0200 Subject: [PATCH 4/4] Add token federation test --- playwright/url-transport.spec.ts | 67 ++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/playwright/url-transport.spec.ts b/playwright/url-transport.spec.ts index 155f618e6..a263c532f 100644 --- a/playwright/url-transport.spec.ts +++ b/playwright/url-transport.spec.ts @@ -12,6 +12,12 @@ import { SpaHelpers } from "./spa-helpers"; const sfuUrl = "wss://matrix-rtc.m.localhost/livekit/sfu"; const serviceUrl = "https://matrix-rtc.m.localhost/livekit/jwt"; +const otherHomeserver = { + base_url: "https://synapse.othersite.m.localhost", + server_name: "synapse.othersite.m.localhost", +}; +const otherSfuUrl = "wss://matrix-rtc.othersite.m.localhost/livekit/sfu"; + interface Transport { type: "livekit"; url?: string; @@ -223,6 +229,49 @@ test("Delegated leave on a url transport ends the membership when the guest drop }); }); +test("Subscribers get tokens for a remote homeserver's SFU over federation", async ({ + browser, + page, + browserName, +}) => { + skipOnFirefox(browserName); + + // The host lives on the other homeserver and publishes on its SFU. + await useHomeserver(page, otherHomeserver); + await advertiseTransport(page, { type: "livekit", url: otherSfuUrl }); + const hostRequests = recordAuthRequests(page); + await page.goto("/"); + await SpaHelpers.createCall(page, "Androl", "HelloCall", true, "2_0"); + const inviteLink = await SpaHelpers.getCallInviteLink(page); + + // The guest lives on the default homeserver and joins over federation. + const guestContext = await browser.newContext({ reducedMotion: "reduce" }); + const guestPage = await guestContext.newPage(); + await advertiseTransport(guestPage, shapes.url); + const guestRequests = recordAuthRequests(guestPage); + await SpaHelpers.joinCallFromInviteLink( + guestPage, + `${inviteLink}&viaServers=${otherHomeserver.server_name}`, + "Pevara", + "2_0", + ); + + // Wait for the call to connect and render. + await SpaHelpers.expectVideoTilesCount(page, 2); + await SpaHelpers.expectVideoTilesCount(guestPage, 2); + + // Each side got a token for its own SFU and for the other side's SFU, all + // through its own homeserver. + for (const requests of [hostRequests, guestRequests]) { + expect(flowsOf(requests)).toEqual(new Set(["cs-api"])); + expect(new Set(requests.map((r) => r.sfuUrl))).toEqual( + new Set([sfuUrl, otherSfuUrl]), + ); + for (const request of requests) expect(request.status).toBe(200); + } +}); + +/** Skips the calling test if we're running on Firefox. */ function skipOnFirefox(browserName: string): void { test.skip( browserName === "firefox", @@ -230,6 +279,24 @@ function skipOnFirefox(browserName: string): void { ); } +/** Points this page's Element Call at another homeserver. */ +async function useHomeserver( + page: Page, + homeserver: typeof otherHomeserver, +): Promise { + await page.route("**/config.json", async (route) => { + const response = await route.fetch(); + const config = (await response.json()) as Record; + await route.fulfill({ + response, + json: { + ...config, + default_server_config: { "m.homeserver": homeserver }, + }, + }); + }); +} + /** * Maps recorded requests to a set of flows. Publisher and subscriber connections * start concurrently so using arrays for comparison isn't convenient.