From 0a6780be245b58bd174d2b11eaecc8da415508fb Mon Sep 17 00:00:00 2001 From: fkwp Date: Thu, 24 Sep 2026 17:46:55 +0200 Subject: [PATCH] Check the background effects stay inside the call everywhere - The standalone app at five window sizes, from a large desktop to a phone-shaped 360x640, on Chromium and Firefox: the camera menu stays inside the window, which is the call area there, and the last effect can still be scrolled to and chosen. - As a component in a host page, and as a widget in Element Web: a picture chosen reaches the preview, so the pipeline's model and WebAssembly load from those builds, and in a small container the last effect is reached and chosen. - Compound portals the menu to the page and takes no container for it, so there it has none of the component's scoped styles, as the microphone menu has none. - The colour checks the three share live in playwright/utils. Co-Authored-By: Claude Opus 5.5 (1M context) --- playwright/background-effects.spec.ts | 92 +++++++++++-------- .../component/background-effects.spec.ts | 85 +++++++++++++++++ playwright/utils/colour.ts | 55 +++++++++++ playwright/widget/background-effects.spec.ts | 43 +++++++++ 4 files changed, 235 insertions(+), 40 deletions(-) create mode 100644 playwright/component/background-effects.spec.ts create mode 100644 playwright/utils/colour.ts create mode 100644 playwright/widget/background-effects.spec.ts diff --git a/playwright/background-effects.spec.ts b/playwright/background-effects.spec.ts index 39bfb5990..4be24c1a5 100644 --- a/playwright/background-effects.spec.ts +++ b/playwright/background-effects.spec.ts @@ -5,15 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { - expect, - test, - type Browser, - type Locator, - type Page, -} from "@playwright/test"; +import { expect, test, type Browser, type Page } from "@playwright/test"; import { SpaHelpers } from "./spa-helpers.ts"; +import { + averageColour, + cameraColour, + type Colour, + distance, +} from "./utils/colour.ts"; // Background effects need WebGL2, and headless Firefox on a CI runner has none, // so it rightly offers none of them. @@ -22,8 +22,6 @@ test.skip( "Background effects need WebGL2, which headless Firefox on CI does not have", ); -type Colour = [number, number, number]; - test.describe("background effects", () => { test("pre-join preview shows the chosen effect", async ({ page }) => { test.slow(); @@ -108,6 +106,51 @@ async function drawsPerSecond(page: Page): Promise { }); } +test.describe("background effects section", () => { + test("section stays within the call area at every size", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("/"); + await SpaHelpers.createCall(page, "Sizes user", "Background effect sizes"); + + for (const size of [ + { width: 1280, height: 800 }, + { width: 800, height: 600 }, + { width: 600, height: 420 }, + { width: 480, height: 360 }, + { width: 360, height: 640 }, + ]) { + await page.setViewportSize(size); + await page.getByRole("button", { name: "Camera", exact: true }).click(); + const menu = page.getByRole("menu"); + await expect(menu).toBeVisible(); + + const box = (await menu.boundingBox())!; + const where = `at ${size.width}x${size.height}`; + expect(box.x, where).toBeGreaterThanOrEqual(0); + expect(box.y, where).toBeGreaterThanOrEqual(0); + expect(box.x + box.width, where).toBeLessThanOrEqual(size.width); + expect(box.y + box.height, where).toBeLessThanOrEqual(size.height); + + const last = page + .getByRole("group", { name: "Background effects" }) + .getByRole("menuitemradio") + .last(); + await last.scrollIntoViewIfNeeded(); + await expect(last, where).toBeInViewport({ ratio: 1 }); + await last.click(); + await expect(last, where).toHaveAttribute("aria-checked", "true"); + + // Back to no effect for the next size, and close. + await page + .getByRole("group", { name: "Background effects" }) + .getByRole("menuitemradio", { name: "None" }) + .click(); + await page.keyboard.press("Escape"); + await expect(menu).toBeHidden(); + } + }); +}); + test.describe("joining with a background chosen", () => { test("publishes no frame of the room", async ({ browser }) => { // Two first builds of the pipeline, one of them held back. @@ -427,34 +470,3 @@ async function framesOf(page: Page, trackId: string): Promise { trackId, ); } - -/** The camera's colour, once the preview is showing it rather than nothing. */ -async function cameraColour(preview: Locator): Promise { - await expect - .poll(async () => Math.max(...(await averageColour(preview)))) - .toBeGreaterThan(40); - return averageColour(preview); -} - -/** The mean colour of a video's current frame or an image, drawn small. */ -async function averageColour(element: Locator): Promise { - return element.evaluate( - async (source: HTMLVideoElement | HTMLImageElement) => { - if (source instanceof HTMLImageElement) await source.decode(); - const canvas = document.createElement("canvas"); - canvas.width = canvas.height = 32; - const context = canvas.getContext("2d")!; - context.drawImage(source, 0, 0, 32, 32); - const { data } = context.getImageData(0, 0, 32, 32); - const sum = [0, 0, 0]; - for (let i = 0; i < data.length; i += 4) - for (let c = 0; c < 3; c++) sum[c] += data[i + c]; - const pixels = data.length / 4; - return sum.map((s) => s / pixels) as Colour; - }, - ); -} - -function distance(a: Colour, b: Colour): number { - return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); -} diff --git a/playwright/component/background-effects.spec.ts b/playwright/component/background-effects.spec.ts new file mode 100644 index 000000000..9e1a0c5c1 --- /dev/null +++ b/playwright/component/background-effects.spec.ts @@ -0,0 +1,85 @@ +/* +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 Locator, type Page, test } from "@playwright/test"; + +import { createUserAndRoom, resizeContainer, startHarness } from "./harness.ts"; +import { averageColour, cameraColour, expectWearing } from "../utils/colour.ts"; + +// Background effects need WebGL2, and headless Firefox on a CI runner has none, +// so it rightly offers none of them. +test.skip( + ({ browserName }) => browserName === "firefox", + "Background effects need WebGL2, which headless Firefox on CI does not have", +); + +/** + * Background effects with Element Call as a component in a host page: the + * pipeline's model and WebAssembly have to load from the component's bundle, + * and the portalled menu has to be sized against the call, not the window. + * Driven from the lobby, which builds the same menu and pipeline. + */ + +// Sign-in, crypto setup and sync happen twice before anything shows. +test.describe.configure({ timeout: 180_000 }); + +test("puts a picture on the call's preview", async ({ page }) => { + const { username, roomId } = await createUserAndRoom("effects"); + const panes = await startHarness(page, username, roomId); + const pane = panes.first(); + await resizeContainer(pane.getByTestId("call-container"), { + width: 900, + height: 480, + }); + await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({ + timeout: 60_000, + }); + const preview = pane.locator("video").first(); + const camera = await cameraColour(preview); + + const tile = await openEffect(page, pane, "Background 1"); + const picture = await averageColour(tile.locator("img")); + await tile.click(); + await expect(tile).toHaveAttribute("aria-checked", "true"); + await expectWearing(preview, picture, camera); +}); + +test("keeps every effect reachable in a small container", async ({ page }) => { + const { username, roomId } = await createUserAndRoom("effectsreach"); + const panes = await startHarness(page, username, roomId); + const pane = panes.first(); + const container = pane.getByTestId("call-container"); + await resizeContainer(container, { width: 400, height: 360 }); + await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({ + timeout: 60_000, + }); + + const last = await openEffect(page, pane, "Background 2"); + const list = page.locator("[role='menu'] div[role='none']").first(); + expect((await list.boundingBox())!.height).toBeLessThanOrEqual( + (await container.boundingBox())!.height, + ); + await last.scrollIntoViewIfNeeded(); + await expect(last).toBeInViewport({ ratio: 1 }); + await last.click(); + await expect(last).toHaveAttribute("aria-checked", "true"); +}); + +/** Opens a component's camera menu and returns one of its effects. */ +async function openEffect( + page: Page, + pane: Locator, + name: string, +): Promise { + await pane + .getByRole("button", { name: "Camera", exact: true }) + .click({ timeout: 60_000 }); + // Portalled out of the pane, so found on the page. + return page + .getByRole("group", { name: "Background effects" }) + .getByRole("menuitemradio", { name }); +} diff --git a/playwright/utils/colour.ts b/playwright/utils/colour.ts new file mode 100644 index 000000000..cb096e15c --- /dev/null +++ b/playwright/utils/colour.ts @@ -0,0 +1,55 @@ +/* +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 Locator } from "@playwright/test"; + +export type Colour = [number, number, number]; + +/** The camera's colour, once the preview is showing it rather than nothing. */ +export async function cameraColour(preview: Locator): Promise { + await expect + .poll(async () => Math.max(...(await averageColour(preview)))) + .toBeGreaterThan(40); + return averageColour(preview); +} + +/** The mean colour of a video's current frame or an image, drawn small. */ +export async function averageColour(element: Locator): Promise { + return element.evaluate( + async (source: HTMLVideoElement | HTMLImageElement) => { + if (source instanceof HTMLImageElement) await source.decode(); + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 32; + const context = canvas.getContext("2d")!; + context.drawImage(source, 0, 0, 32, 32); + const { data } = context.getImageData(0, 0, 32, 32); + const sum = [0, 0, 0]; + for (let i = 0; i < data.length; i += 4) + for (let c = 0; c < 3; c++) sum[c] += data[i + c]; + const pixels = data.length / 4; + return sum.map((s) => s / pixels) as Colour; + }, + ); +} + +export function distance(a: Colour, b: Colour): number { + return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); +} + +/** Waits until a preview shows a picture rather than the camera. */ +export async function expectWearing( + preview: Locator, + picture: Colour, + camera: Colour, +): Promise { + // Long enough for the first effect of a session to be built. + await expect + .poll(async () => distance(await averageColour(preview), picture), { + timeout: 60_000, + }) + .toBeLessThan(distance(camera, picture) / 2); +} diff --git a/playwright/widget/background-effects.spec.ts b/playwright/widget/background-effects.spec.ts new file mode 100644 index 000000000..32c499bc0 --- /dev/null +++ b/playwright/widget/background-effects.spec.ts @@ -0,0 +1,43 @@ +/* +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 { widgetTest } from "../fixtures/widget-user.ts"; +import { TestHelpers } from "./test-helpers.ts"; +import { averageColour, cameraColour, expectWearing } from "../utils/colour.ts"; + +// Background effects need WebGL2, and headless Firefox on a CI runner has none, +// so it rightly offers none of them. +test.skip( + ({ browserName }) => browserName === "firefox", + "Background effects need WebGL2, which headless Firefox on CI does not have", +); + +widgetTest( + "puts a picture on the preview inside Element Web", + async ({ asWidget }) => { + test.slow(); + const { brooks } = asWidget; + await TestHelpers.startCallInCurrentRoom(brooks.page, false); + const frame = brooks.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + await expect(frame.getByTestId("lobby_joinCall")).toBeVisible(); + const preview = frame.locator("video").first(); + const camera = await cameraColour(preview); + + await frame.getByRole("button", { name: "Camera", exact: true }).click(); + const tile = frame + .getByRole("group", { name: "Background effects" }) + .getByRole("menuitemradio", { name: "Background 1" }); + const picture = await averageColour(tile.locator("img")); + await tile.click(); + await expect(tile).toHaveAttribute("aria-checked", "true"); + await expectWearing(preview, picture, camera); + }, +);