From d41d2dececd354e2ec12b1a70d19a81905b040d3 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 21 Jan 2026 09:25:12 +0100 Subject: [PATCH] playwright: Test call running without some permissions --- playwright/permissions.spec.ts | 124 ++++++++++++++++++ .../CallViewModel/localMember/LocalMember.ts | 1 + 2 files changed, 125 insertions(+) create mode 100644 playwright/permissions.spec.ts diff --git a/playwright/permissions.spec.ts b/playwright/permissions.spec.ts new file mode 100644 index 00000000..561f1c23 --- /dev/null +++ b/playwright/permissions.spec.ts @@ -0,0 +1,124 @@ +/* +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, test } from "@playwright/test"; + +import { SpaHelpers } from "./spa-helpers.ts"; + +test("Bug: Unmuting camera when camera permission was not granted was closing the call with an error", async ({ + page, +}) => { + // =============== + // We cannot use only the clearPermissions API because it doesn't deny the + // permission, it just resets it to the default state which is "ask". + // Instead, we override the getUserMedia method to simulate a denial of camera permission. + // 1. Override getUserMedia to deny video by throwing NotAllowedError + // 2. Override enumerateDevices to return videoinput devices with empty labels/deviceIds + await page.addInitScript(() => { + const mediaDevices = window.navigator.mediaDevices; + const originalMediaDevice = mediaDevices.getUserMedia.bind(mediaDevices); + const originalEnumerateDevices = + mediaDevices.enumerateDevices.bind(mediaDevices); + window.navigator.mediaDevices.getUserMedia = async ( + constraints?: MediaStreamConstraints, + ): Promise => { + if (constraints?.video) { + throw new DOMException("Permission denied", "NotAllowedError"); + } + return originalMediaDevice(constraints); + }; + + window.navigator.mediaDevices.enumerateDevices = async (): Promise< + MediaDeviceInfo[] + > => { + const devices = await originalEnumerateDevices(); + // Filter out video input devices to simulate no camera permission + return devices.map((device) => { + if (device.kind === "videoinput") { + // When we have no permission, the label/deviceId/groupId are empty strings. + return { + kind: "videoinput", + label: "", + deviceId: "", + groupId: "", + } as InputDeviceInfo; + } + return device; + }); + }; + }); + + // =============== + // Start a call then try to unmute camera + await page.goto("/"); + + // Start the call without camera permissio + await SpaHelpers.createCall(page, "John Doe", "HelloCall", false); + + await page.pause(); + // Video should be muted initially as we have no permission. + // When muted the aria-label indicates we can start video + await expect(page.getByRole("button", { name: "Start video" })).toBeVisible(); + + await page.getByTestId("lobby_joinCall").click(); + + // the test is a bit flaky here, wait for the tile to appear + await expect(page.getByTestId("videoTile")).toBeVisible(); + await expect(page.getByRole("button", { name: "Start video" })).toBeVisible(); + // await page.pause(); + + // Try to unmute camera without granting permission + await page.getByRole("button", { name: "Start video" }).click(); + + // There used to have a bug where the call would end with an error here. + // This was fixed and is not anymore a fatal error. + await page.waitForTimeout(1000); + // The call should still be ongoing, but currently the call is ended with an error + await expect(page.getByText("Something went wrong")).not.toBeVisible(); + + // The video button should still indicate that it is in a muted state. + // TODO Improve this UI/UX to better inform the user that camera permission is denied. + await expect(page.getByRole("button", { name: "Start video" })).toBeVisible(); +}); + +test("Should not end call if screen share is cancelled", async ({ page }) => { + // Mock getDisplayMedia to simulate user cancelling the screen share permission dialog + await page.addInitScript(() => { + window.navigator.mediaDevices.getDisplayMedia = async ( + options?: DisplayMediaStreamOptions, + ): Promise => { + await new Promise((resolve) => setTimeout(resolve, 100)); + // simulate the user clicking cancel on the native window to share selection dialog + throw new DOMException("Permission denied", "NotAllowedError"); + }; + }); + + await page.goto("/"); + + await SpaHelpers.createCall(page, "John Doe", "HelloCall", false); + + await page.getByTestId("lobby_joinCall").click(); + await expect(page.getByTestId("videoTile")).toBeVisible(); + + await expect( + page.getByRole("button", { name: "Share screen" }), + ).toBeVisible(); + + // Start screen sharing which will be denied + await page.getByRole("button", { name: "Share screen" }).click(); + + // There used to have a bug where the call would end with an error here. + // This was fixed and is not anymore a fatal error. + await page.waitForTimeout(1000); + // The call should still be ongoing, but currently the call is ended with an error + await expect(page.getByText("Something went wrong")).not.toBeVisible(); + + // The screen share button should still indicate that screen sharing is not active. + await expect( + page.getByRole("button", { name: "Share screen" }), + ).toBeVisible(); +}); diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 59074da6..f7f5be4f 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -267,6 +267,7 @@ export const createLocalMembership$ = ({ mediaErrors$.pipe(scope.bind()).subscribe((error) => { if (error) { + // setMatrixError(new UnknownCallError(error)); // This is a MediaDevice error, can be PermissionDenied, NotFound, DeviceInUse, Other. // Will also occurs if you cancel screen sharing browser prompt. // This is not necessarily fatal, since the user might be able to join without media.