From 46e29df7c360fce8074d13f0bd242f5282875fcb Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 6 Mar 2026 11:02:11 +0000 Subject: [PATCH 01/15] Add widget toggles module * API: https://github.com/element-hq/element-modules/pull/219 * Element Web API impl: https://github.com/element-hq/element-web/pull/32734 --- modules/widget-toggles/README.md | 16 ++++ .../element-web/external_all.diff | 28 ++++++ .../widget-toggles/element-web/package.json | 34 +++++++ .../widget-toggles/element-web/src/config.ts | 27 ++++++ .../widget-toggles/element-web/src/index.tsx | 64 +++++++++++++ .../widget-toggles/element-web/src/toggle.tsx | 93 +++++++++++++++++++ .../widget-toggles/element-web/tsconfig.json | 8 ++ .../widget-toggles/element-web/vite.config.ts | 51 ++++++++++ yarn.lock | 19 +++- 9 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 modules/widget-toggles/README.md create mode 100644 modules/widget-toggles/element-web/external_all.diff create mode 100644 modules/widget-toggles/element-web/package.json create mode 100644 modules/widget-toggles/element-web/src/config.ts create mode 100644 modules/widget-toggles/element-web/src/index.tsx create mode 100644 modules/widget-toggles/element-web/src/toggle.tsx create mode 100644 modules/widget-toggles/element-web/tsconfig.json create mode 100644 modules/widget-toggles/element-web/vite.config.ts diff --git a/modules/widget-toggles/README.md b/modules/widget-toggles/README.md new file mode 100644 index 0000000..e117e70 --- /dev/null +++ b/modules/widget-toggles/README.md @@ -0,0 +1,16 @@ +# Widget Toggles Module + +Adds room header buttons for widgets in the room. + +This module needs to be configured to control what widget types get buttons added for them. +The following config snippet enables the module and configures it to add buttons for both +custom and jitsi widgets: + +``` +"modules": [ + "/modules/widget-toggles/lib/index.js" +], +"io.element.element-web-modules.widget-toggles": { + "types": ["m.custom", "jitsi"] +} +``` diff --git a/modules/widget-toggles/element-web/external_all.diff b/modules/widget-toggles/element-web/external_all.diff new file mode 100644 index 0000000..ed1bc76 --- /dev/null +++ b/modules/widget-toggles/element-web/external_all.diff @@ -0,0 +1,28 @@ +diff --git a/modules/widget-toggles/element-web/vite.config.ts b/modules/widget-toggles/element-web/vite.config.ts +index b65e3ec..9d1ba49 100644 +--- a/modules/widget-toggles/element-web/vite.config.ts ++++ b/modules/widget-toggles/element-web/vite.config.ts +@@ -28,7 +28,14 @@ export default defineConfig({ + target: "esnext", + sourcemap: true, + rollupOptions: { +- external: ["react"], ++ // make sure to externalize deps that shouldn't be bundled ++ // into your library ++ external: [ ++ "react", ++ "react-dom", ++ "@vector-im/compound-design-tokens", ++ "@vector-im/compound-web" ++ ], + }, + }, + plugins: [ +@@ -41,6 +48,7 @@ export default defineConfig({ + externalGlobals({ + // Reuse React from the host app + react: "window.React", ++ "@vector-im/compound-web": "window.CompoundWeb", + }), + ], + define: { diff --git a/modules/widget-toggles/element-web/package.json b/modules/widget-toggles/element-web/package.json new file mode 100644 index 0000000..2a9c15e --- /dev/null +++ b/modules/widget-toggles/element-web/package.json @@ -0,0 +1,34 @@ +{ + "name": "@element-hq/element-web-module-widget-toggle", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "lib/index.js", + "license": "SEE LICENSE IN README.md", + "scripts": { + "prepare": "vite build", + "lint:types": "tsc --noEmit", + "lint:codestyle": "echo 'handled by lint:eslint'", + "test": "echo no tests yet" + }, + "devDependencies": { + "@arcmantle/vite-plugin-import-css-sheet": "^1.0.12", + "@element-hq/element-web-module-api": "^1.0.0", + "@types/node": "^22.10.7", + "@types/react": "^19", + "@vitejs/plugin-react": "^5.0.0", + "react": "^19", + "rollup-plugin-external-globals": "^0.13.0", + "typescript": "^5.7.3", + "vite": "^7.1.11", + "vite-plugin-node-polyfills": "^0.25.0", + "vite-plugin-svgr": "^4.3.0" + }, + "dependencies": { + "@vector-im/compound-design-tokens": "^6.0.0", + "@vector-im/compound-web": "^8.0.0", + "matrix-widget-api": "^1.17.0", + "styled-components": "^6.3.11", + "zod": "^4.3.6" + } +} diff --git a/modules/widget-toggles/element-web/src/config.ts b/modules/widget-toggles/element-web/src/config.ts new file mode 100644 index 0000000..54e2d07 --- /dev/null +++ b/modules/widget-toggles/element-web/src/config.ts @@ -0,0 +1,27 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { z, type input } from "zod/mini"; + +z.config(z.locales.en()); + +export const WidgetTogglesConfig = z.object({ + /** + * The widget types to show a toggle for. + */ + types: z.array(z.string()), +}); + +export type WidgetTogglesConfig = z.infer; + +export const CONFIG_KEY = "io.element.element-web-modules.widget-toggles"; + +declare module "@element-hq/element-web-module-api" { + export interface Config { + [CONFIG_KEY]: input; + } +} diff --git a/modules/widget-toggles/element-web/src/index.tsx b/modules/widget-toggles/element-web/src/index.tsx new file mode 100644 index 0000000..d9a8d99 --- /dev/null +++ b/modules/widget-toggles/element-web/src/index.tsx @@ -0,0 +1,64 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type JSX } from "react"; +import { TooltipProvider } from "@vector-im/compound-web"; + +import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api"; +import { CONFIG_KEY, WidgetTogglesConfig } from "./config"; +import { WidgetToggle } from "./toggle"; + +class WidgetToggleModule implements Module { + public static readonly moduleApiVersion = "^1.0.0"; + private config?: WidgetTogglesConfig; + + public constructor(private api: Api) {} + + public async load(): Promise { + try { + this.config = WidgetTogglesConfig.parse(this.api.config.get(CONFIG_KEY)); + } catch (e) { + console.error("Failed to init module", e); + throw new Error(`Errors in module configuration for widget toggles module`); + } + + this.api.extras.setRoomHeaderButtonCallback((roomId: string) => { + const widgets = this.api.widget.getWidgetsInRoom(roomId); + const toggleElements: JSX.Element[] = []; + + for (const widget of widgets) { + if (this.config?.types.includes(widget.type)) { + toggleElements.push( + , + ); + } + } + + if (toggleElements.length === 0) return undefined; + + // XXX: We shouldn't have to add another TooltipProvider here, it should + // be using the one in MatrixChat in Element Web, but thanks to the fact + // that contexts are "magically" identified by class, it doesn't pick up the + // context because we use a different copy of compound-web. We'll probably + // need to fix this at some point, possibly by moving compound's tooltip stuff + // out to its own mini-module that can be provided at runtime by Element Web, + // unless React make contexts more sensible. + // Annoyingly this does actually cause the tooltips to behave a bit weirdly: + // there's a delay before the tooltip appears when yo move between these buttons + // and the rest of the header buttons, whereas moving between the other header + // buttons, the tooltips appear straight away once one has appeared. + return {toggleElements}; + }); + } +} + +export default WidgetToggleModule satisfies ModuleFactory; diff --git a/modules/widget-toggles/element-web/src/toggle.tsx b/modules/widget-toggles/element-web/src/toggle.tsx new file mode 100644 index 0000000..741904c --- /dev/null +++ b/modules/widget-toggles/element-web/src/toggle.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2024 Nordeck IT + Consulting GmbH + * Copyright 2026 Element Creations Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { type I18nApi, type WidgetApi } from "@element-hq/element-web-module-api"; +import { IconButton, Tooltip } from "@vector-im/compound-web"; +import { type IWidget } from "matrix-widget-api"; +import React, { type JSX } from "react"; +import styled from "styled-components"; + +type Props = { $isInContainer: boolean }; + +const Img = styled.img` + border-color: ${(): string => "var(--cpd-color-text-action-accent)"}; + border-radius: 50%; + border-style: solid; + border-width: ${({ $isInContainer }): string => ($isInContainer ? "2px" : "0px")}; + box-sizing: border-box; + height: 24px; + width: 24px; +`; + +const Svg = styled.svg` + height: 24px; + fill: ${({ $isInContainer }): string => ($isInContainer ? "var(--cpd-color-text-action-accent)" : "currentColor")}; + width: 24px; +`; + +function avatarUrl(app: IWidget, widgetApi: WidgetApi): string | null { + if (app.type.match(/jitsi/i)) { + return "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxyZWN0IHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgcng9IjQiIGZpbGw9IiM1QUJGRjIiLz4KICAgIDxwYXRoIGQ9Ik0zIDcuODc1QzMgNi44Mzk0NyAzLjgzOTQ3IDYgNC44NzUgNkgxMS4xODc1QzEyLjIyMyA2IDEzLjA2MjUgNi44Mzk0NyAxMy4wNjI1IDcuODc1VjEyLjg3NUMxMy4wNjI1IDEzLjkxMDUgMTIuMjIzIDE0Ljc1IDExLjE4NzUgMTQuNzVINC44NzVDMy44Mzk0NyAxNC43NSAzIDEzLjkxMDUgMyAxMi44NzVWNy44NzVaIiBmaWxsPSJ3aGl0ZSIvPgogICAgPHBhdGggZD0iTTE0LjM3NSA4LjQ0NjQ0TDE2LjEyMDggNy4xMTAzOUMxNi40ODA2IDYuODM1MDIgMTcgNy4wOTE1OCAxNyA3LjU0NDY4VjEzLjAzOTZDMTcgMTMuNTE5OSAxNi40MjUxIDEzLjc2NjkgMTYuMDc2NyAxMy40MzYzTDE0LjM3NSAxMS44MjE0VjguNDQ2NDRaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K"; + } + + return widgetApi.getAppAvatarUrl(app); +} + +function isInContainer(app: IWidget, widgetApi: WidgetApi, roomId: string): boolean { + return widgetApi.isAppInContainer(app, "center", roomId) || widgetApi.isAppInContainer(app, "top", roomId); +} + +type WidgetToggleProps = { + app: IWidget; + roomId: string; + widgetApi: WidgetApi; + i18nApi: I18nApi; +}; + +export function WidgetToggle({ app, roomId, widgetApi, i18nApi }: WidgetToggleProps): JSX.Element { + const appAvatarUrl = avatarUrl(app, widgetApi); + const appNameOrType = app.name ?? app.type; + const inContainer = isInContainer(app, widgetApi, roomId); + + const label = i18nApi.translate(inContainer ? "Hide %(name)s" : "Show %(name)s", { name: appNameOrType }); + + const onClick = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + if (inContainer) { + widgetApi.moveAppToContainer(app, "right", roomId); + } else { + widgetApi.moveAppToContainer(app, "top", roomId); + } + }, + [app, inContainer, roomId, widgetApi], + ); + + return ( + + + {appAvatarUrl ? ( + {appNameOrType} + ) : ( + + + + )} + + + ); +} diff --git a/modules/widget-toggles/element-web/tsconfig.json b/modules/widget-toggles/element-web/tsconfig.json new file mode 100644 index 0000000..fbda638 --- /dev/null +++ b/modules/widget-toggles/element-web/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "lib", + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/modules/widget-toggles/element-web/vite.config.ts b/modules/widget-toggles/element-web/vite.config.ts new file mode 100644 index 0000000..b65e3ec --- /dev/null +++ b/modules/widget-toggles/element-web/vite.config.ts @@ -0,0 +1,51 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { nodePolyfills } from "vite-plugin-node-polyfills"; +import externalGlobals from "rollup-plugin-external-globals"; +import svgr from "vite-plugin-svgr"; +import { importCSSSheet } from "@arcmantle/vite-plugin-import-css-sheet"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, "src/index.tsx"), + name: "element-web-module-widget-toggles", + fileName: "index", + formats: ["es"], + }, + outDir: "lib", + target: "esnext", + sourcemap: true, + rollupOptions: { + external: ["react"], + }, + }, + plugins: [ + importCSSSheet(), + react(), + svgr(), + nodePolyfills({ + include: ["events"], + }), + externalGlobals({ + // Reuse React from the host app + react: "window.React", + }), + ], + define: { + // Use production mode for the build as it is tested against production builds of Element Web, + // this is required for React JSX versions to be compatible. + process: { env: { NODE_ENV: "production" } }, + }, +}); diff --git a/yarn.lock b/yarn.lock index b923cd5..06e5e1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2189,6 +2189,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== +"@types/events@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.3.tgz#a8ef894305af28d1fc6d2dfdfc98e899591ea529" + integrity sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g== + "@types/json5@^0.0.29": version "0.0.29" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" @@ -4232,7 +4237,7 @@ eventemitter3@^5.0.1: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== -events@^3.0.0, events@^3.3.0: +events@^3.0.0, events@^3.2.0, events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== @@ -5583,6 +5588,14 @@ matrix-web-i18n@^3.3.0: minimist "^1.2.8" walk "^2.3.15" +matrix-widget-api@^1.17.0: + version "1.17.0" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz#2336de2186fe70d8bd741c1603c162f60b2099c2" + integrity sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ== + dependencies: + "@types/events" "^3.0.0" + events "^3.2.0" + md5.js@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" @@ -7180,7 +7193,7 @@ strip-json-comments@^3.1.1, strip-json-comments@~3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -styled-components@^6.1.18: +styled-components@^6.1.18, styled-components@^6.3.11: version "6.3.11" resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-6.3.11.tgz#6babdb151a3419b0a79a368353d1dfbe8c5dddbc" integrity sha512-opzgceGlQ5rdZdGwf9ddLW7EM2F4L7tgsgLn6fFzQ2JgE5EVQ4HZwNkcgB1p8WfOBx1GEZP3fa66ajJmtXhSrA== @@ -7921,7 +7934,7 @@ zod@^3.22.4: resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.12.tgz#64f1ea53d00eab91853195653b5af9eee68970f0" integrity sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ== -zod@^4.0.0, zod@^4.1.11: +zod@^4.0.0, zod@^4.1.11, zod@^4.3.6: version "4.3.6" resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a" integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== From 5700772011c6a470d27bfe44fcc61df284e1d6a1 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 10 Mar 2026 14:14:44 +0000 Subject: [PATCH 02/15] Update api name --- modules/widget-toggles/element-web/src/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/widget-toggles/element-web/src/index.tsx b/modules/widget-toggles/element-web/src/index.tsx index d9a8d99..9527917 100644 --- a/modules/widget-toggles/element-web/src/index.tsx +++ b/modules/widget-toggles/element-web/src/index.tsx @@ -26,7 +26,7 @@ class WidgetToggleModule implements Module { throw new Error(`Errors in module configuration for widget toggles module`); } - this.api.extras.setRoomHeaderButtonCallback((roomId: string) => { + this.api.extras.addRoomHeaderButtonCallback((roomId: string) => { const widgets = this.api.widget.getWidgetsInRoom(roomId); const toggleElements: JSX.Element[] = []; From 2ac7e0640c198782d9c8b5868b1eeb1c0c452cc3 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 13 Mar 2026 17:32:06 +0000 Subject: [PATCH 03/15] Add playwright test for widget toggles module --- .../element-web/e2e/fixture/widget.html | 5 + ...idget-toggle-button-default-icon-linux.png | Bin 0 -> 316 bytes .../element-web/e2e/widget-toggles.spec.ts | 170 ++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 modules/widget-toggles/element-web/e2e/fixture/widget.html create mode 100644 modules/widget-toggles/element-web/e2e/snapshots/widget-toggles.spec.ts/widget-toggle-button-default-icon-linux.png create mode 100644 modules/widget-toggles/element-web/e2e/widget-toggles.spec.ts diff --git a/modules/widget-toggles/element-web/e2e/fixture/widget.html b/modules/widget-toggles/element-web/e2e/fixture/widget.html new file mode 100644 index 0000000..c2c28e4 --- /dev/null +++ b/modules/widget-toggles/element-web/e2e/fixture/widget.html @@ -0,0 +1,5 @@ + + +

This is the content of the widget

+ + diff --git a/modules/widget-toggles/element-web/e2e/snapshots/widget-toggles.spec.ts/widget-toggle-button-default-icon-linux.png b/modules/widget-toggles/element-web/e2e/snapshots/widget-toggles.spec.ts/widget-toggle-button-default-icon-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..a171d0adc8a83fb9044826c752b1388649dd8e3e GIT binary patch literal 316 zcmV-C0mJ@@P)I(iwuFGgMGf-J@l+%4My(OhpCN8G@igq}HZd8rTN|GRzEvFxVcSp~EMvi6b<3YF8Y>=7F{^WX($U&Ad>UYQt zmdfqe9?oO|2u3ie3|Rnzk;;hnxV!#e6OXIKyjm&4;r0H^oF(_cr2hjKkcad;;z(7J zDG0vQffaub8=35v1(VH8rj8(k#ouGtYa4ovbl~oSNo9~}kWSr3aCh0al_cA9&`~U2 ziCL5mui~&M54?)QqNKbMvq; +}>({ + navigationJsonResolver: async ({}, use) => { + await use(Promise.withResolvers()); + }, +}); + +const TEST_WIDGET_NAME = "Name of the test widget"; + +async function makeRoomWithWidgetAndGoTo( + homeserver: StartedHomeserverContainer, + user: Credentials, + page: Page, + avatarUrl?: string, +): Promise { + const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>( + "POST", + "/v3/createRoom", + user.accessToken, + { + name: "Come on in we've got widgets", + }, + ); + await homeserver.csApi.request<{ + event_id: string; + }>("PUT", `/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`, user.accessToken, { + id: "1", + creatorUserId: user.userId, + type: "m.custom", + name: TEST_WIDGET_NAME, + url: `http://localhost:8080/widget.html`, + avatar_url: avatarUrl, + }); + + await page.goto(`/#/room/${roomId}`); + + return roomId; +} + +async function moveWidgetToContainer( + homeserver: StartedHomeserverContainer, + user: Credentials, + roomId: string, + container: Container, +): Promise { + await homeserver.csApi.request( + "PUT", + `/v3/user/${encodeURIComponent(user.userId)}/rooms/${encodeURIComponent(roomId)}/account_data/im.vector.web.settings`, + user.accessToken, + { + "Widgets.layout": { + widgets: { + "1": { container }, + }, + }, + }, + ); +} + +test.describe("widget-toggles", () => { + test.use({ + displayName: "Timmy", + page: async ({ context, page, moduleDir }, use) => { + await context.route("http://localhost:8080/widget.html*", async (route) => { + await route.fulfill({ path: `${moduleDir}/e2e/fixture/widget.html`, contentType: "text/html" }); + }); + + await context.route("http://localhost:8080/wigeon.png", async (route) => { + await route.fulfill({ path: `${moduleDir}/e2e/fixture/wigeon.png`, contentType: "image/png" }); + }); + + await page.goto("/"); + await use(page); + }, + }); + + test("should error if config is missing", async ({ page }) => { + await expect(page.getByText("Your Element is misconfigured")).toBeVisible(); + await expect(page.getByText("Errors in module configuration")).toBeVisible(); + // We don't take a screenshot as we don't want to assert Element's styling, only our own + }); + + test.describe("with correct config", () => { + test.use({ + config: { + "io.element.element-web-modules.widget-toggles": { + types: ["m.custom"], + }, + }, + }); + + test( + "should render 'show' button for widget not in top", + { tag: ["@screenshot"] }, + async ({ homeserver, page, user }) => { + await makeRoomWithWidgetAndGoTo(homeserver, user, page); + + await expect(page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME })).toBeVisible(); + }, + ); + + test( + "should render 'hide' button for widget in top", + { tag: ["@screenshot"] }, + async ({ homeserver, page, user }) => { + const roomId = await makeRoomWithWidgetAndGoTo(homeserver, user, page); + + await moveWidgetToContainer(homeserver, user, roomId, "top"); + + await expect(page.getByRole("button", { name: "Hide " + TEST_WIDGET_NAME })).toBeVisible(); + }, + ); + + test("should move widget to top when 'show' button is clicked", async ({ homeserver, page, user }) => { + await makeRoomWithWidgetAndGoTo(homeserver, user, page); + + await page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME }).click(); + await expect(page.getByRole("button", { name: "Hide " + TEST_WIDGET_NAME })).toBeVisible(); + + await expect(page.locator('iframe[title="Name of the test widget"]')).toBeVisible(); + }); + + test("should move widget to left when 'hide' button is clicked", async ({ homeserver, page, user }) => { + const roomId = await makeRoomWithWidgetAndGoTo(homeserver, user, page); + await moveWidgetToContainer(homeserver, user, roomId, "top"); + + await expect(page.locator('iframe[title="Name of the test widget"]')).toBeVisible(); + + await page.getByRole("button", { name: "Hide " + TEST_WIDGET_NAME }).click(); + + await expect(page.locator('iframe[title="Name of the test widget"]')).not.toBeVisible(); + }); + + test("uses widget icon for button image if present", async ({ homeserver, page, user }) => { + await makeRoomWithWidgetAndGoTo(homeserver, user, page, "mxc://fakehomeserver/fake_content_id"); + + await expect( + page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME }).getByRole("img"), + ).toHaveAttribute("src", /\/_matrix\/media\/v3\/download\/fakehomeserver\/fake_content_id$/); + }); + + test( + "uses built in icon for widgets with no avatar", + { tag: ["@screenshot"] }, + async ({ homeserver, page, user }) => { + await makeRoomWithWidgetAndGoTo(homeserver, user, page); + + await expect(page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME })).toMatchScreenshot( + "widget-toggle-button-default-icon.png", + ); + }, + ); + }); +}); From 9af99fa0b3cfbcbd1d4b334c91452cdd042affb8 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 13 Mar 2026 18:29:39 +0000 Subject: [PATCH 04/15] Add test for config.ts --- .../widget-toggles/element-web/package.json | 5 ++- .../element-web/tests/config.test.ts | 43 +++++++++++++++++++ .../element-web/vitest.config.ts | 26 +++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 modules/widget-toggles/element-web/tests/config.test.ts create mode 100644 modules/widget-toggles/element-web/vitest.config.ts diff --git a/modules/widget-toggles/element-web/package.json b/modules/widget-toggles/element-web/package.json index 2a9c15e..faef7f8 100644 --- a/modules/widget-toggles/element-web/package.json +++ b/modules/widget-toggles/element-web/package.json @@ -9,7 +9,7 @@ "prepare": "vite build", "lint:types": "tsc --noEmit", "lint:codestyle": "echo 'handled by lint:eslint'", - "test": "echo no tests yet" + "test": "vitest run --coverage" }, "devDependencies": { "@arcmantle/vite-plugin-import-css-sheet": "^1.0.12", @@ -22,7 +22,8 @@ "typescript": "^5.7.3", "vite": "^7.1.11", "vite-plugin-node-polyfills": "^0.25.0", - "vite-plugin-svgr": "^4.3.0" + "vite-plugin-svgr": "^4.3.0", + "vitest": "^4.0.0" }, "dependencies": { "@vector-im/compound-design-tokens": "^6.0.0", diff --git a/modules/widget-toggles/element-web/tests/config.test.ts b/modules/widget-toggles/element-web/tests/config.test.ts new file mode 100644 index 0000000..7863ffa --- /dev/null +++ b/modules/widget-toggles/element-web/tests/config.test.ts @@ -0,0 +1,43 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { describe, expect, test } from "vitest"; + +import { WidgetTogglesConfig } from "../src/config"; + +describe("WidgetTogglesConfig", () => { + test("parses a valid config with an array of widget types", () => { + const result = WidgetTogglesConfig.safeParse({ types: ["m.video", "m.audio"] }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.types).toEqual(["m.video", "m.audio"]); + } + }); + + test("parses a valid config with an empty types array", () => { + const result = WidgetTogglesConfig.safeParse({ types: [] }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.types).toEqual([]); + } + }); + + test("rejects a config missing the types field", () => { + const result = WidgetTogglesConfig.safeParse({}); + expect(result.success).toBe(false); + }); + + test("rejects a config where types is not an array", () => { + const result = WidgetTogglesConfig.safeParse({ types: "m.video" }); + expect(result.success).toBe(false); + }); + + test("rejects a config where types contains non-string values", () => { + const result = WidgetTogglesConfig.safeParse({ types: [1, 2, 3] }); + expect(result.success).toBe(false); + }); +}); diff --git a/modules/widget-toggles/element-web/vitest.config.ts b/modules/widget-toggles/element-web/vitest.config.ts new file mode 100644 index 0000000..783608f --- /dev/null +++ b/modules/widget-toggles/element-web/vitest.config.ts @@ -0,0 +1,26 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { defineConfig } from "vitest/config"; +import { env } from "node:process"; + +const isGHA = env["GITHUB_ACTIONS"] !== undefined; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + exclude: ["./e2e/**/*", "./node_modules/**/*"], + reporters: isGHA + ? ["default", ["vitest-sonar-reporter", { outputFile: "coverage/sonar-report.xml" }]] + : ["default"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + reporter: [["lcov", { projectRoot: "../../../" }], "text"], + }, + }, +}); From 9689ceb0e333387088f6530f5601f920e0494769 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 13 Mar 2026 18:32:42 +0000 Subject: [PATCH 05/15] Port knip change from https://github.com/element-hq/element-modules/pull/199 --- knip.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/knip.ts b/knip.ts index a2d4f42..f29cd5d 100644 --- a/knip.ts +++ b/knip.ts @@ -7,6 +7,9 @@ Please see LICENSE files in the repository root for full details. import { KnipConfig } from "knip"; +// Specify this as knip loads config files which may conditionally add reporters, e.g. `vitest-sonar-reporter` +process.env.GITHUB_ACTIONS = "1"; + export default { ignoreDependencies: [ // Needed for lint:workflows From 1f0309a680872c40faf0b2d13be896f29fd9aac1 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 13 Mar 2026 18:36:55 +0000 Subject: [PATCH 06/15] Add deps --- modules/widget-toggles/element-web/package.json | 4 +++- yarn.lock | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/modules/widget-toggles/element-web/package.json b/modules/widget-toggles/element-web/package.json index faef7f8..3ae3089 100644 --- a/modules/widget-toggles/element-web/package.json +++ b/modules/widget-toggles/element-web/package.json @@ -17,13 +17,15 @@ "@types/node": "^22.10.7", "@types/react": "^19", "@vitejs/plugin-react": "^5.0.0", + "@vitest/coverage-v8": "^4.0.0", "react": "^19", "rollup-plugin-external-globals": "^0.13.0", "typescript": "^5.7.3", "vite": "^7.1.11", "vite-plugin-node-polyfills": "^0.25.0", "vite-plugin-svgr": "^4.3.0", - "vitest": "^4.0.0" + "vitest": "^4.0.0", + "vitest-sonar-reporter": "^2.0.0" }, "dependencies": { "@vector-im/compound-design-tokens": "^6.0.0", diff --git a/yarn.lock b/yarn.lock index 06e5e1f..f7b55ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7673,6 +7673,11 @@ vite@^7.1.11: optionalDependencies: fsevents "~2.3.3" +vitest-sonar-reporter@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/vitest-sonar-reporter/-/vitest-sonar-reporter-2.0.4.tgz#e81ae089364bea257f4da89b38599ae52535e41f" + integrity sha512-6mKFLXYzaHsuR+qnmuXXVhcjhostuicZ9iL3I325uf6sUKSZ2ZOpDWUBcgQwEmGs2xE05SPF6F72eoHmfOWX7A== + vitest-sonar-reporter@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/vitest-sonar-reporter/-/vitest-sonar-reporter-3.0.0.tgz#3bb34a9a46390dce83a50de16135fc325c73359d" From 88a2af789b3d809c6b8f6326820efba2493c050b Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 13 Mar 2026 18:43:31 +0000 Subject: [PATCH 07/15] Port sonar ignores from https://github.com/element-hq/element-modules/pull/199 --- sonar-project.properties | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sonar-project.properties b/sonar-project.properties index 4fc4c58..cd5d433 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -11,8 +11,10 @@ sonar.test.inclusions=\ **/*.spec.* sonar.exclusions=**/*.test.*,**/*.api.md,**/e2e/fixture/** sonar.coverage.exclusions=\ + knip.ts,\ playwright.config.ts,\ **/vite.config.ts,\ + **/vitest.config.ts,\ **/*playwright*/**/*,\ modules/banner/element-web/**/*,\ modules/restricted-guests/element-web/**/* From 8245712fcf6251996272b1a1b92c454c01eaae73 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 16 Mar 2026 17:58:23 +0000 Subject: [PATCH 08/15] Pin react deps to exact version in package.json Yarn had somehow confused itself and got into a state where it refused to install react 19.2.4, so much so that even renovate thought it had upgraded the package (https://github.com/element-hq/element-modules/pull/208) when it hadn't. Until we switch to pnom and can use catalog:, pin them to exact version which seems to make yarn play ball again. --- modules/banner/element-web/package.json | 2 +- modules/opendesk/element-web/package.json | 4 ++-- modules/restricted-guests/element-web/package.json | 2 +- yarn.lock | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/modules/banner/element-web/package.json b/modules/banner/element-web/package.json index c78181b..df45f81 100644 --- a/modules/banner/element-web/package.json +++ b/modules/banner/element-web/package.json @@ -17,7 +17,7 @@ "@types/node": "^22.10.7", "@types/react": "^19", "@vitejs/plugin-react": "^5.0.0", - "react": "^19", + "react": "19.2.4", "rollup-plugin-external-globals": "^0.13.0", "typescript": "^5.7.3", "vite": "^7.1.11", diff --git a/modules/opendesk/element-web/package.json b/modules/opendesk/element-web/package.json index 653df09..c0fa58b 100644 --- a/modules/opendesk/element-web/package.json +++ b/modules/opendesk/element-web/package.json @@ -22,8 +22,8 @@ "@types/node": "^22.10.7", "@types/react": "^19", "@vitejs/plugin-react": "^5.0.0", - "react": "^19", - "react-dom": "^19", + "react": "19.2.4", + "react-dom": "19.2.4", "rollup-plugin-external-globals": "^0.13.0", "typescript": "^5.7.3", "vite": "^7.1.11", diff --git a/modules/restricted-guests/element-web/package.json b/modules/restricted-guests/element-web/package.json index 8587318..dfe5c0f 100644 --- a/modules/restricted-guests/element-web/package.json +++ b/modules/restricted-guests/element-web/package.json @@ -17,7 +17,7 @@ "@types/node": "^22.10.7", "@types/react": "^19", "@vitejs/plugin-react": "^5.0.0", - "react": "^19", + "react": "19.2.4", "rollup-plugin-external-globals": "^0.13.0", "typescript": "^5.7.3", "vite": "^7.1.11", diff --git a/yarn.lock b/yarn.lock index ebdc3b8..99fd3d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6380,7 +6380,7 @@ react-clientside-effect@^1.2.6: dependencies: "@babel/runtime" "^7.12.13" -react-dom@^19: +react-dom@19.2.4: version "19.2.4" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.4.tgz#6fac6bd96f7db477d966c7ec17c1a2b1ad8e6591" integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ== @@ -6436,10 +6436,10 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: get-nonce "^1.0.0" tslib "^2.0.0" -react@^19: - version "19.2.0" - resolved "https://registry.yarnpkg.com/react/-/react-19.2.0.tgz#d33dd1721698f4376ae57a54098cb47fc75d93a5" - integrity sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ== +react@19.2.4: + version "19.2.4" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a" + integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== read-pkg-up@^7.0.1: version "7.0.1" From 0f79c1eb1ddff1fbd379249e2306850859fed95a Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 16 Mar 2026 18:51:40 +0000 Subject: [PATCH 09/15] Add unit test for the toggle component Using playwright-provided chromium via vitest + testing library --- .../widget-toggles/element-web/package.json | 4 + .../element-web/tests/setupTests.ts | 13 ++ .../element-web/tests/toggle.test.tsx | 111 ++++++++++++ .../element-web/tests/tsconfig.json | 7 + .../element-web/vitest.config.ts | 10 +- yarn.lock | 158 ++++++++++++++++-- 6 files changed, 291 insertions(+), 12 deletions(-) create mode 100644 modules/widget-toggles/element-web/tests/setupTests.ts create mode 100644 modules/widget-toggles/element-web/tests/toggle.test.tsx create mode 100644 modules/widget-toggles/element-web/tests/tsconfig.json diff --git a/modules/widget-toggles/element-web/package.json b/modules/widget-toggles/element-web/package.json index 3ae3089..b4f1a3e 100644 --- a/modules/widget-toggles/element-web/package.json +++ b/modules/widget-toggles/element-web/package.json @@ -14,9 +14,13 @@ "devDependencies": { "@arcmantle/vite-plugin-import-css-sheet": "^1.0.12", "@element-hq/element-web-module-api": "^1.0.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.10.7", "@types/react": "^19", "@vitejs/plugin-react": "^5.0.0", + "@vitest/browser-playwright": "4.0.18", "@vitest/coverage-v8": "^4.0.0", "react": "^19", "rollup-plugin-external-globals": "^0.13.0", diff --git a/modules/widget-toggles/element-web/tests/setupTests.ts b/modules/widget-toggles/element-web/tests/setupTests.ts new file mode 100644 index 0000000..d7601a1 --- /dev/null +++ b/modules/widget-toggles/element-web/tests/setupTests.ts @@ -0,0 +1,13 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; + +afterEach(() => { + cleanup(); +}); diff --git a/modules/widget-toggles/element-web/tests/toggle.test.tsx b/modules/widget-toggles/element-web/tests/toggle.test.tsx new file mode 100644 index 0000000..3f115b9 --- /dev/null +++ b/modules/widget-toggles/element-web/tests/toggle.test.tsx @@ -0,0 +1,111 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { type WidgetApi, type I18nApi } from "@element-hq/element-web-module-api"; +import { type IWidget } from "matrix-widget-api"; +import { type PropsWithChildren } from "react"; +import { TooltipProvider } from "@vector-im/compound-web"; +import userEvent from "@testing-library/user-event"; + +import { WidgetToggle } from "../src/toggle"; + +const roomId = "!room:example.com"; + +const mockWidget = (overrides: Partial = {}): IWidget => ({ + id: "widget-1", + creatorUserId: "@user:example.com", + type: "m.custom", + name: "My Widget", + url: "https://example.com", + ...overrides, +}); + +const mockWidgetApi = (overrides: Partial = {}): WidgetApi => + ({ + getAppAvatarUrl: vi.fn().mockReturnValue(null), + isAppInContainer: vi.fn().mockReturnValue(false), + moveAppToContainer: vi.fn(), + ...overrides, + }) as unknown as WidgetApi; + +const mockI18nApi = (): I18nApi => + ({ + translate: vi.fn().mockImplementation((key: string, vars?: Record) => { + let result = key; + if (vars) { + for (const [k, v] of Object.entries(vars)) { + result = result.replace(`%(${k})s`, v); + } + } + return result; + }), + }) as unknown as I18nApi; + +const wrapper = ({ children }: PropsWithChildren): React.JSX.Element => {children}; + +describe("WidgetToggle", () => { + let widgetApi: WidgetApi; + let i18nApi: I18nApi; + let app: IWidget; + + beforeEach(() => { + widgetApi = mockWidgetApi(); + i18nApi = mockI18nApi(); + app = mockWidget(); + }); + + test("displays avatar image when widget has an avatar URL", () => { + (widgetApi.getAppAvatarUrl as ReturnType).mockReturnValue("https://example.com/avatar.png"); + render(, { wrapper }); + const img = screen.getByRole("img", { name: app.name }); + expect(img).toBeDefined(); + expect(img.getAttribute("src")).toBe("https://example.com/avatar.png"); + }); + + test("renders the Jitsi avatar for Jitsi widgets", () => { + app = mockWidget({ type: "m.jitsi", name: "Jitsi" }); + render(, { wrapper }); + const img = screen.getByRole("img", { name: "Jitsi" }); + expect(img.getAttribute("src")).toMatch(/^data:image\/svg\+xml;base64,/); + }); + + test("shows 'Show' label when widget is not in container", () => { + (widgetApi.isAppInContainer as ReturnType).mockReturnValue(false); + render(, { wrapper }); + const button = screen.getByRole("button", { name: "Show My Widget" }); + expect(button).toBeDefined(); + }); + + test("shows 'Hide' label when widget is in container", () => { + (widgetApi.isAppInContainer as ReturnType).mockReturnValue(true); + render(, { wrapper }); + const button = screen.getByRole("button", { name: "Hide My Widget" }); + expect(button).toBeDefined(); + }); + + test("calls moveAppToContainer with 'top' when widget is not in container and button is clicked", async () => { + const user = userEvent.setup(); + + (widgetApi.isAppInContainer as ReturnType).mockReturnValue(false); + render(, { wrapper }); + const button = screen.getByRole("button", { name: "Show My Widget" }); + await user.click(button); + expect(widgetApi.moveAppToContainer).toHaveBeenCalledWith(app, "top", roomId); + }); + + test("calls moveAppToContainer with 'right' when widget is in container and button is clicked", async () => { + const user = userEvent.setup(); + + (widgetApi.isAppInContainer as ReturnType).mockReturnValue(true); + render(, { wrapper }); + const button = screen.getByRole("button", { name: "Hide My Widget" }); + await user.click(button); + expect(widgetApi.moveAppToContainer).toHaveBeenCalledWith(app, "right", roomId); + }); +}); diff --git a/modules/widget-toggles/element-web/tests/tsconfig.json b/modules/widget-toggles/element-web/tests/tsconfig.json new file mode 100644 index 0000000..f5eb2ca --- /dev/null +++ b/modules/widget-toggles/element-web/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx" + }, + "include": ["."] +} diff --git a/modules/widget-toggles/element-web/vitest.config.ts b/modules/widget-toggles/element-web/vitest.config.ts index 783608f..4a5b5a3 100644 --- a/modules/widget-toggles/element-web/vitest.config.ts +++ b/modules/widget-toggles/element-web/vitest.config.ts @@ -7,12 +7,13 @@ Please see LICENSE files in the repository root for full details. import { defineConfig } from "vitest/config"; import { env } from "node:process"; +import { playwright } from "@vitest/browser-playwright"; const isGHA = env["GITHUB_ACTIONS"] !== undefined; export default defineConfig({ test: { - include: ["tests/**/*.test.ts"], + include: ["tests/**/*.test.{ts,tsx}"], exclude: ["./e2e/**/*", "./node_modules/**/*"], reporters: isGHA ? ["default", ["vitest-sonar-reporter", { outputFile: "coverage/sonar-report.xml" }]] @@ -22,5 +23,12 @@ export default defineConfig({ include: ["src/**/*.ts"], reporter: [["lcov", { projectRoot: "../../../" }], "text"], }, + browser: { + enabled: true, + headless: true, + provider: playwright({}), + instances: [{ browser: "chromium" }], + }, + setupFiles: ["tests/setupTests.ts"], }, }); diff --git a/yarn.lock b/yarn.lock index 2c7bfdc..5f85cff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -45,6 +45,15 @@ js-tokens "^4.0.0" picocolors "^1.0.0" +"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" + integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/code-frame@^7.26.2", "@babel/code-frame@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" @@ -54,15 +63,6 @@ js-tokens "^4.0.0" picocolors "^1.1.1" -"@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": - version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" - integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== - dependencies: - "@babel/helper-validator-identifier" "^7.28.5" - js-tokens "^4.0.0" - picocolors "^1.1.1" - "@babel/compat-data@^7.27.2": version "7.27.2" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.2.tgz#4183f9e642fd84e74e3eea7ffa93a412e3b102c9" @@ -438,6 +438,11 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.12.5": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.6.tgz#d267a43cb1836dc4d182cce93ae75ba954ef6d2b" + integrity sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA== + "@babel/template@^7.26.9", "@babel/template@^7.27.1", "@babel/template@^7.27.2": version "7.27.2" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" @@ -1304,6 +1309,11 @@ dependencies: playwright "1.58.2" +"@polka/url@^1.0.0-next.24": + version "1.0.0-next.29" + resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1" + integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww== + "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" @@ -2105,6 +2115,32 @@ dependencies: testcontainers "^11.12.0" +"@testing-library/dom@^10.4.1": + version "10.4.1" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.1.tgz#d444f8a889e9a46e9a3b4f3b88e0fcb3efb6cf95" + integrity sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "5.3.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + picocolors "1.1.1" + pretty-format "^27.0.2" + +"@testing-library/react@^16.3.2": + version "16.3.2" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.3.2.tgz#672883b7acb8e775fc0492d9e9d25e06e89786d0" + integrity sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g== + dependencies: + "@babel/runtime" "^7.12.5" + +"@testing-library/user-event@^14.6.1": + version "14.6.1" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" + integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== + "@tybys/wasm-util@^0.10.1": version "0.10.1" resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" @@ -2117,6 +2153,11 @@ resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9" integrity sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA== +"@types/aria-query@^5.0.1": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" + integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== + "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -2426,6 +2467,29 @@ "@types/babel__core" "^7.20.5" react-refresh "^0.18.0" +"@vitest/browser-playwright@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/browser-playwright/-/browser-playwright-4.0.18.tgz#1a844a44cf2f1e2321ca70e405063104350e5472" + integrity sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g== + dependencies: + "@vitest/browser" "4.0.18" + "@vitest/mocker" "4.0.18" + tinyrainbow "^3.0.3" + +"@vitest/browser@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/browser/-/browser-4.0.18.tgz#9d826cc21f09c27f8fe758715a92a6a878236a02" + integrity sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng== + dependencies: + "@vitest/mocker" "4.0.18" + "@vitest/utils" "4.0.18" + magic-string "^0.30.21" + pixelmatch "7.1.0" + pngjs "^7.0.0" + sirv "^3.0.2" + tinyrainbow "^3.0.3" + ws "^8.18.3" + "@vitest/coverage-v8@^4.0.0": version "4.0.18" resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz#b9c4db7479acd51d5f0ced91b2853c29c3d0cda7" @@ -2660,6 +2724,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + ansi-styles@^6.1.0: version "6.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" @@ -2715,6 +2784,13 @@ aria-hidden@^1.2.4: dependencies: tslib "^2.0.0" +aria-query@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" + integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== + dependencies: + dequal "^2.0.3" + aria-query@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" @@ -3582,6 +3658,11 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== +dequal@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + des.js@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.1.0.tgz#1d37f5766f3bbff4ee9638e871a8768c173b81da" @@ -3658,6 +3739,11 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-accessibility-api@^0.5.9: + version "0.5.16" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" + integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== + domain-browser@4.22.0: version "4.22.0" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-4.22.0.tgz#6ddd34220ec281f9a65d3386d267ddd35c491f9f" @@ -5533,6 +5619,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + magic-string@^0.30.10, magic-string@^0.30.17, magic-string@^0.30.3: version "0.30.17" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453" @@ -5735,6 +5826,11 @@ motion-utils@^12.29.2: resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.29.2.tgz#8fdd28babe042c2456b078ab33b32daa3bf5938b" integrity sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A== +mrmime@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc" + integrity sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ== + ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" @@ -6141,7 +6237,7 @@ pbkdf2@^3.1.2: sha.js "^2.4.11" to-buffer "^1.2.0" -picocolors@^1.0.0, picocolors@^1.1.1: +picocolors@1.1.1, picocolors@^1.0.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -6156,6 +6252,13 @@ picomatch@^4.0.1, picomatch@^4.0.2, picomatch@^4.0.3: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== +pixelmatch@7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-7.1.0.tgz#9d59bddc8c779340e791106c0f245ac33ae4d113" + integrity sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng== + dependencies: + pngjs "^7.0.0" + pkg-dir@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-5.0.0.tgz#a02d6aebe6ba133a928f74aec20bafdfe6b8e760" @@ -6200,6 +6303,11 @@ pluralize@^8.0.0: resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== +pngjs@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-7.0.0.tgz#a8b7446020ebbc6ac739db6c5415a65d17090e26" + integrity sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow== + possible-typed-array-names@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae" @@ -6247,6 +6355,15 @@ prettier@^3.4.2: resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== +pretty-format@^27.0.2: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -6404,6 +6521,11 @@ react-is@^16.13.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + react-refresh@^0.18.0: version "0.18.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.18.0.tgz#2dce97f4fe932a4d8142fa1630e475c1729c8062" @@ -6436,7 +6558,7 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: get-nonce "^1.0.0" tslib "^2.0.0" -react@19.2.4: +react@19.2.4, react@^19: version "19.2.4" resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a" integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== @@ -6874,6 +6996,15 @@ signal-exit@^4.0.1, signal-exit@^4.1.0: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== +sirv@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/sirv/-/sirv-3.0.2.tgz#f775fccf10e22a40832684848d636346f41cd970" + integrity sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g== + dependencies: + "@polka/url" "^1.0.0-next.24" + mrmime "^2.0.0" + totalist "^3.0.0" + slash@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" @@ -7387,6 +7518,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +totalist@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8" + integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ== + ts-api-utils@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.4.0.tgz#2690579f96d2790253bdcf1ca35d569ad78f9ad8" From 0d3721b21f38cae3734d6fa240bf387c59999c8e Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 16 Mar 2026 19:41:10 +0000 Subject: [PATCH 10/15] Download playwright binaries --- .github/workflows/tests.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f78dc01..df71b8e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,6 +28,18 @@ jobs: - name: Install Deps run: "yarn install --frozen-lockfile" + - name: Get installed Playwright version + working-directory: packages/shared-components + id: playwright + run: echo "version=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[].devDependencies["@playwright/test"].version')" >> $GITHUB_OUTPUT + + - name: Cache playwright binaries + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}-onlyshell + - name: Run tests run: yarn test From de6ab5c048ddfd15859eaaf39be3ce0786e0dbdc Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 16 Mar 2026 20:08:49 +0000 Subject: [PATCH 11/15] Fix playright version step --- .github/workflows/tests.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index df71b8e..9652569 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,9 +29,8 @@ jobs: run: "yarn install --frozen-lockfile" - name: Get installed Playwright version - working-directory: packages/shared-components id: playwright - run: echo "version=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[].devDependencies["@playwright/test"].version')" >> $GITHUB_OUTPUT + run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT - name: Cache playwright binaries uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 From 569d6ded5f9632515a0aed6b5a0fe028ee65efe3 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 16 Mar 2026 20:15:40 +0000 Subject: [PATCH 12/15] also install the binaries --- .github/workflows/tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9652569..d57760e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,6 +39,10 @@ jobs: path: ~/.cache/ms-playwright key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}-onlyshell + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: "yarn playwright install --with-deps --only-shell" + - name: Run tests run: yarn test From 25081b205c15363820b6a1e32c47b7b5b463ae6e Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 16 Mar 2026 20:23:04 +0000 Subject: [PATCH 13/15] exclude setupTests from coverage --- sonar-project.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/sonar-project.properties b/sonar-project.properties index cd5d433..f0cd15c 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -16,6 +16,7 @@ sonar.coverage.exclusions=\ **/vite.config.ts,\ **/vitest.config.ts,\ **/*playwright*/**/*,\ + **/setupTests.ts \ modules/banner/element-web/**/*,\ modules/restricted-guests/element-web/**/* sonar.typescript.tsconfigPath=./tsconfig.json From 7de5f07ec3b0fc09a0a0cf4b65b005cd9cb6b340 Mon Sep 17 00:00:00 2001 From: David Baker Date: Mon, 16 Mar 2026 20:57:18 +0000 Subject: [PATCH 14/15] of course there's a comma --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index f0cd15c..3c35871 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -16,7 +16,7 @@ sonar.coverage.exclusions=\ **/vite.config.ts,\ **/vitest.config.ts,\ **/*playwright*/**/*,\ - **/setupTests.ts \ + **/setupTests.ts, \ modules/banner/element-web/**/*,\ modules/restricted-guests/element-web/**/* sonar.typescript.tsconfigPath=./tsconfig.json From 6d927beec2478a965abcda83eae0aff4a763b5c7 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 17 Mar 2026 10:46:28 +0000 Subject: [PATCH 15/15] Also a test for the module entrypoint --- .../element-web/tests/index.test.ts | 175 ++++++++++++++++++ .../widget-toggles/element-web/tests/mocks.ts | 45 +++++ .../element-web/tests/toggle.test.tsx | 33 +--- 3 files changed, 222 insertions(+), 31 deletions(-) create mode 100644 modules/widget-toggles/element-web/tests/index.test.ts create mode 100644 modules/widget-toggles/element-web/tests/mocks.ts diff --git a/modules/widget-toggles/element-web/tests/index.test.ts b/modules/widget-toggles/element-web/tests/index.test.ts new file mode 100644 index 0000000..d1258e5 --- /dev/null +++ b/modules/widget-toggles/element-web/tests/index.test.ts @@ -0,0 +1,175 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { type Api } from "@element-hq/element-web-module-api"; +import { type IWidget } from "matrix-widget-api"; + +import WidgetToggleModule from "../src/index"; +import { CONFIG_KEY, WidgetTogglesConfig } from "../src/config"; +import { mockWidget, mockWidgetApi } from "./mocks"; + +const makeApi = (widgets: IWidget[] = []): Api => { + const addRoomHeaderButtonCallback = vi.fn(); + return { + config: { + get: vi.fn().mockReturnValue({ types: ["m.custom"] }), + }, + extras: { + addRoomHeaderButtonCallback, + }, + widget: mockWidgetApi({ + getWidgetsInRoom: vi.fn().mockReturnValue(widgets), + }), + i18n: { + translate: vi.fn().mockImplementation((key: string) => key), + }, + } as unknown as Api; +}; + +vi.mock("../src/config", async () => { + return { + CONFIG_KEY: "fake_config_key", + WidgetTogglesConfig: { + parse: vi.fn(), + }, + }; +}); + +describe("WidgetToggleModule", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("load", () => { + test("reads config using CONFIG_KEY", async () => { + const api = makeApi(); + + const module = new WidgetToggleModule(api); + await module.load(); + + expect(api.config.get).toHaveBeenCalledWith(CONFIG_KEY); + }); + + test("parses config with WidgetTogglesConfig.parse", async () => { + const api = makeApi(); + const rawConfig = { types: ["m.custom"] }; + (api.config.get as ReturnType).mockReturnValue(rawConfig); + + const module = new WidgetToggleModule(api); + await module.load(); + + expect(WidgetTogglesConfig.parse).toHaveBeenCalledWith(rawConfig); + }); + + test("registers a room header button callback", async () => { + const api = makeApi(); + (WidgetTogglesConfig.parse as ReturnType).mockReturnValue({ types: ["m.custom"] }); + + const module = new WidgetToggleModule(api); + await module.load(); + + expect(api.extras.addRoomHeaderButtonCallback).toHaveBeenCalledOnce(); + }); + + test("throws error when config parsing fails", async () => { + const api = makeApi(); + (WidgetTogglesConfig.parse as ReturnType).mockImplementation(() => { + throw new Error("Invalid config"); + }); + + const module = new WidgetToggleModule(api); + await expect(module.load()).rejects.toThrow("Errors in module configuration for widget toggles module"); + }); + }); + + describe("room header button callback", () => { + const roomId = "!room:example.com"; + + const getCallback = async (api: Api): Promise<(roomId: string) => React.JSX.Element | undefined> => { + (WidgetTogglesConfig.parse as ReturnType).mockReturnValue({ types: ["m.custom"] }); + const module = new WidgetToggleModule(api); + await module.load(); + return (api.extras.addRoomHeaderButtonCallback as ReturnType).mock.calls[0][0]; + }; + + test("returns undefined when there are no widgets in the room", async () => { + const api = makeApi([]); + const callback = await getCallback(api); + + const result = callback(roomId); + expect(result).toBeUndefined(); + }); + + test("returns undefined when no widgets match the configured types", async () => { + const api = makeApi([mockWidget({ type: "m.other" })]); + const callback = await getCallback(api); + + const result = callback(roomId); + expect(result).toBeUndefined(); + }); + + test("renders WidgetToggle for each matching widget", async () => { + const api = makeApi([ + mockWidget({ id: "w1", type: "m.custom", name: "Widget One" }), + mockWidget({ id: "w2", type: "m.custom", name: "Widget Two" }), + ]); + (api.i18n.translate as ReturnType).mockImplementation( + (key: string, vars?: Record) => { + let result = key; + if (vars) { + for (const [k, v] of Object.entries(vars)) { + result = result.replace(`%(${k})s`, v); + } + } + return result; + }, + ); + const callback = await getCallback(api); + + const result = callback(roomId); + expect(result).toBeDefined(); + render(result!); + + expect(screen.getAllByRole("button").length).toBe(2); + }); + + test("does not render WidgetToggle for non-matching widget types", async () => { + const api = makeApi([ + mockWidget({ id: "w1", type: "m.custom", name: "Widget One" }), + mockWidget({ id: "w2", type: "m.other", name: "Widget Other" }), + ]); + (api.i18n.translate as ReturnType).mockImplementation( + (key: string, vars?: Record) => { + let result = key; + if (vars) { + for (const [k, v] of Object.entries(vars)) { + result = result.replace(`%(${k})s`, v); + } + } + return result; + }, + ); + const callback = await getCallback(api); + + const result = callback(roomId); + expect(result).toBeDefined(); + render(result!); + + expect(screen.getAllByRole("button").length).toBe(1); + }); + + test("calls getWidgetsInRoom with correct roomId", async () => { + const api = makeApi([]); + const callback = await getCallback(api); + + callback(roomId); + expect(api.widget.getWidgetsInRoom).toHaveBeenCalledWith(roomId); + }); + }); +}); diff --git a/modules/widget-toggles/element-web/tests/mocks.ts b/modules/widget-toggles/element-web/tests/mocks.ts new file mode 100644 index 0000000..e37cd67 --- /dev/null +++ b/modules/widget-toggles/element-web/tests/mocks.ts @@ -0,0 +1,45 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type I18nApi, type WidgetApi } from "@element-hq/element-web-module-api"; +import { type IWidget } from "matrix-widget-api"; +import { vi } from "vitest"; + +export function mockWidget(overrides: Partial = {}): IWidget { + return { + id: "widget-1", + creatorUserId: "@user:example.com", + type: "m.custom", + name: "My Widget", + url: "https://example.com", + ...overrides, + }; +} + +export function mockWidgetApi(overrides: Partial = {}): WidgetApi { + return { + getWidgetsInRoom: vi.fn().mockReturnValue([]), + getAppAvatarUrl: vi.fn().mockReturnValue(null), + isAppInContainer: vi.fn().mockReturnValue(false), + moveAppToContainer: vi.fn(), + ...overrides, + } as unknown as WidgetApi; +} + +export function mockI18nApi(): I18nApi { + return { + translate: vi.fn().mockImplementation((key: string, vars?: Record) => { + let result = key; + if (vars) { + for (const [k, v] of Object.entries(vars)) { + result = result.replace(`%(${k})s`, v); + } + } + return result; + }), + } as unknown as I18nApi; +} diff --git a/modules/widget-toggles/element-web/tests/toggle.test.tsx b/modules/widget-toggles/element-web/tests/toggle.test.tsx index 3f115b9..0a0e404 100644 --- a/modules/widget-toggles/element-web/tests/toggle.test.tsx +++ b/modules/widget-toggles/element-web/tests/toggle.test.tsx @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test, type vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { type WidgetApi, type I18nApi } from "@element-hq/element-web-module-api"; import { type IWidget } from "matrix-widget-api"; @@ -14,39 +14,10 @@ import { TooltipProvider } from "@vector-im/compound-web"; import userEvent from "@testing-library/user-event"; import { WidgetToggle } from "../src/toggle"; +import { mockI18nApi, mockWidget, mockWidgetApi } from "./mocks"; const roomId = "!room:example.com"; -const mockWidget = (overrides: Partial = {}): IWidget => ({ - id: "widget-1", - creatorUserId: "@user:example.com", - type: "m.custom", - name: "My Widget", - url: "https://example.com", - ...overrides, -}); - -const mockWidgetApi = (overrides: Partial = {}): WidgetApi => - ({ - getAppAvatarUrl: vi.fn().mockReturnValue(null), - isAppInContainer: vi.fn().mockReturnValue(false), - moveAppToContainer: vi.fn(), - ...overrides, - }) as unknown as WidgetApi; - -const mockI18nApi = (): I18nApi => - ({ - translate: vi.fn().mockImplementation((key: string, vars?: Record) => { - let result = key; - if (vars) { - for (const [k, v] of Object.entries(vars)) { - result = result.replace(`%(${k})s`, v); - } - } - return result; - }), - }) as unknown as I18nApi; - const wrapper = ({ children }: PropsWithChildren): React.JSX.Element => {children}; describe("WidgetToggle", () => {