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; +}