diff --git a/modules/banner/e2e/banner.spec.ts b/modules/banner/e2e/banner.spec.ts index 7a5f2a1185..3892865aa1 100644 --- a/modules/banner/e2e/banner.spec.ts +++ b/modules/banner/e2e/banner.spec.ts @@ -35,12 +35,28 @@ test.describe("Banner", () => { }, }); - 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(); + test("should not error if config is missing", async ({ page }) => { + await expect(page.getByText("Your Element is misconfigured")).not.toBeVisible(); + await expect(page.getByText("Errors in module configuration")).not.toBeVisible(); // We don't take a screenshot as we don't want to assert Element's styling, only our own }); + test.describe("invalid config", () => { + test.use({ + config: { + "io.element.element-web-modules.banner": { + type: "invalid", + } as any, + }, + }); + + test("should render error", 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 + }); + }); + const configs: input[] = [ { logo_url: "http://localhost:8080/logo.svg", diff --git a/modules/banner/src/config.ts b/modules/banner/src/config.ts index 678ed0360a..82f5489b6b 100644 --- a/modules/banner/src/config.ts +++ b/modules/banner/src/config.ts @@ -115,6 +115,6 @@ export const CONFIG_KEY = "io.element.element-web-modules.banner"; declare module "@element-hq/element-web-module-api" { export interface Config { - [CONFIG_KEY]: input; + [CONFIG_KEY]?: input; } } diff --git a/modules/banner/src/index.test.ts b/modules/banner/src/index.test.ts new file mode 100644 index 0000000000..41756a6fe6 --- /dev/null +++ b/modules/banner/src/index.test.ts @@ -0,0 +1,35 @@ +/* +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, it, vi } from "vitest"; +import { type Api } from "@element-hq/element-web-module-api"; + +import BannerModule from "./index"; + +const makeApi = (config: unknown): Api => { + return { + config: { + get: vi.fn().mockReturnValue(config), + }, + i18n: { + register: vi.fn(), + }, + } as unknown as Api; +}; + +describe("BannerModule", () => { + describe("load", () => { + it("should do nothing if no config present", async () => { + const api = makeApi(null); + + const module = new BannerModule(api); + await module.load(); + + expect(api.i18n.register).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/modules/banner/src/index.tsx b/modules/banner/src/index.tsx index 66859118a1..ab46be4fc8 100644 --- a/modules/banner/src/index.tsx +++ b/modules/banner/src/index.tsx @@ -23,13 +23,19 @@ class BannerModule implements Module { public constructor(private api: Api) {} public async load(): Promise { + const rawConfig = this.api.config.get(CONFIG_KEY); + if (!rawConfig) { + console.debug(`No configuration found for module "${ModuleName}", skipping initialization.`); + return; + } + document.adoptedStyleSheets.push(compound); document.adoptedStyleSheets.push(style); this.api.i18n.register(Translations); try { - this.config = ModuleConfig.parse(this.api.config.get(CONFIG_KEY)); + this.config = ModuleConfig.parse(rawConfig); } catch (e) { console.error("Failed to init module", e); throw new Error(`Errors in module configuration for "${ModuleName}"`); diff --git a/modules/banner/vitest.config.ts b/modules/banner/vitest.config.ts new file mode 100644 index 0000000000..3ef4e022b4 --- /dev/null +++ b/modules/banner/vitest.config.ts @@ -0,0 +1,14 @@ +/* +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 { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + exclude: ["./e2e/**/*", "./node_modules/**/*"], + }, +}); diff --git a/modules/restricted-guests/e2e/restricted-guests.spec.ts b/modules/restricted-guests/e2e/restricted-guests.spec.ts index a3717e3ac1..da5838520a 100644 --- a/modules/restricted-guests/e2e/restricted-guests.spec.ts +++ b/modules/restricted-guests/e2e/restricted-guests.spec.ts @@ -236,13 +236,29 @@ for (const auth of ["mas", "legacy"] as const) { }); test.describe(`Restricted guests auth=${auth} guests=${guestsEnabled}`, () => { - test("should error if config is missing", async ({ page }) => { + test("should not error if config is missing", async ({ page }) => { await page.goto("/"); - await expect(page.getByText("Your Element is misconfigured")).toBeVisible(); - await expect(page.getByText("Errors in module configuration")).toBeVisible(); + await expect(page.getByText("Your Element is misconfigured")).not.toBeVisible(); + await expect(page.getByText("Errors in module configuration")).not.toBeVisible(); }); - test.describe("with config", () => { + test.describe("with invalid config", () => { + test.use({ + config: { + "io.element.element-web-modules.restricted-guests": { + hs_url: "foo", + }, + }, + }); + + test("should render error", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Your Element is misconfigured")).toBeVisible(); + await expect(page.getByText("Errors in module configuration")).toBeVisible(); + }); + }); + + test.describe("with valid config", () => { test.beforeEach(async ({ config, guestHomeserver, page, testRoomId }) => { config["io.element.element-web-modules.restricted-guests"] = { guest_user_homeserver_url: guestHomeserver.baseUrl, diff --git a/modules/restricted-guests/src/config.ts b/modules/restricted-guests/src/config.ts index 766a6571bb..c490ba1744 100644 --- a/modules/restricted-guests/src/config.ts +++ b/modules/restricted-guests/src/config.ts @@ -39,7 +39,7 @@ export const CONFIG_KEY = "io.element.element-web-modules.restricted-guests"; declare module "@element-hq/element-web-module-api" { export interface Config { - [CONFIG_KEY]: input; + [CONFIG_KEY]?: input; sso_redirect_options?: { immediate?: boolean; // incompatible option }; diff --git a/modules/restricted-guests/src/index.test.ts b/modules/restricted-guests/src/index.test.ts new file mode 100644 index 0000000000..7bb1593587 --- /dev/null +++ b/modules/restricted-guests/src/index.test.ts @@ -0,0 +1,35 @@ +/* +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, it, vi } from "vitest"; +import { type Api } from "@element-hq/element-web-module-api"; + +import RestrictedGuestsModule from "./index"; + +const makeApi = (config: unknown): Api => { + return { + config: { + get: vi.fn().mockReturnValue(config), + }, + i18n: { + register: vi.fn(), + }, + } as unknown as Api; +}; + +describe("RestrictedGuestsModule", () => { + describe("load", () => { + it("should do nothing if no config present", async () => { + const api = makeApi(null); + + const module = new RestrictedGuestsModule(api); + await module.load(); + + expect(api.i18n.register).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/modules/restricted-guests/src/index.tsx b/modules/restricted-guests/src/index.tsx index d11e4c0691..e165a2d448 100644 --- a/modules/restricted-guests/src/index.tsx +++ b/modules/restricted-guests/src/index.tsx @@ -31,12 +31,18 @@ class RestrictedGuestsModule implements Module { public constructor(private api: Api) {} public async load(): Promise { + const rawConfig = this.api.config.get(CONFIG_KEY); + if (!rawConfig) { + console.debug(`No configuration found for module "${ModuleName}", skipping initialization.`); + return; + } + document.adoptedStyleSheets.push(compound); this.api.i18n.register(Translations); try { - this.config = ModuleConfig.parse(this.api.config.get(CONFIG_KEY)); + this.config = ModuleConfig.parse(rawConfig); } catch (e) { console.error("Failed to init module", e); throw new Error(`Errors in module configuration for "${ModuleName}"`); diff --git a/modules/restricted-guests/vitest.config.ts b/modules/restricted-guests/vitest.config.ts new file mode 100644 index 0000000000..3ef4e022b4 --- /dev/null +++ b/modules/restricted-guests/vitest.config.ts @@ -0,0 +1,14 @@ +/* +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 { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + exclude: ["./e2e/**/*", "./node_modules/**/*"], + }, +}); diff --git a/modules/widget-lifecycle/src/WidgetLifecycleModule.test.ts b/modules/widget-lifecycle/src/WidgetLifecycleModule.test.ts index b8fcb1ba00..7963102984 100644 --- a/modules/widget-lifecycle/src/WidgetLifecycleModule.test.ts +++ b/modules/widget-lifecycle/src/WidgetLifecycleModule.test.ts @@ -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 { describe, expect, it } from "vitest"; +import { vi, describe, expect, it } from "vitest"; import WidgetLifecycleModule, { type WidgetLifecycleApiAdapter } from "./WidgetLifecycleModule"; import type { @@ -24,15 +24,15 @@ const createApi = (config: unknown = {}) => { } = {}; const widgetLifecycle: WidgetLifecycleApiAdapter = { - registerPreloadApprover: (approver) => { + registerPreloadApprover: vi.fn((approver) => { handlers.preload = approver; - }, - registerIdentityApprover: (approver) => { + }), + registerIdentityApprover: vi.fn((approver) => { handlers.identity = approver; - }, - registerCapabilitiesApprover: (approver) => { + }), + registerCapabilitiesApprover: vi.fn((approver) => { handlers.capabilities = approver; - }, + }), }; return { @@ -55,6 +55,17 @@ const widget: WidgetDescriptor = { }; describe("WidgetLifecycleModule", () => { + it("does nothing when no config is present", async () => { + const { api } = createApi(null); + + const module = new WidgetLifecycleModule(api); + await module.load(); + + expect(api.widgetLifecycle.registerPreloadApprover).not.toHaveBeenCalled(); + expect(api.widgetLifecycle.registerIdentityApprover).not.toHaveBeenCalled(); + expect(api.widgetLifecycle.registerCapabilitiesApprover).not.toHaveBeenCalled(); + }); + it("approves preload when configured", async () => { const { api, handlers } = createApi({ widget_permissions: { diff --git a/modules/widget-lifecycle/src/WidgetLifecycleModule.ts b/modules/widget-lifecycle/src/WidgetLifecycleModule.ts index 6ad15e08bd..eff66406c6 100644 --- a/modules/widget-lifecycle/src/WidgetLifecycleModule.ts +++ b/modules/widget-lifecycle/src/WidgetLifecycleModule.ts @@ -9,6 +9,7 @@ import type { Api, Module, WidgetDescriptor, WidgetLifecycleApi } from "@element import { CONFIG_KEY, parseWidgetLifecycleConfig, type WidgetLifecycleModuleConfig } from "./config"; import { constructWidgetPermissions } from "./utils/constructWidgetPermissions"; import { matchPattern } from "./utils/matchPattern"; +import { name as ModuleName } from "../package.json"; /** Subset of {@link WidgetLifecycleApi} used by the module for registration only. */ export type WidgetLifecycleApiAdapter = Pick< @@ -30,6 +31,12 @@ export default class WidgetLifecycleModule implements Module { public constructor(private api: ModuleApi) {} public async load(): Promise { + const rawConfig = this.api.config.get(CONFIG_KEY); + if (!rawConfig) { + console.debug(`No configuration found for module "${ModuleName}", skipping initialization.`); + return; + } + if (!this.api.widgetLifecycle) { throw new Error( "Widget lifecycle API is not available. Update Element Web to a build that provides widget lifecycle module support.", @@ -37,7 +44,7 @@ export default class WidgetLifecycleModule implements Module { } try { - this.config = parseWidgetLifecycleConfig(this.api.config.get(CONFIG_KEY)); + this.config = parseWidgetLifecycleConfig(rawConfig); } catch (error) { console.error("[WidgetLifecycle] Failed to init module", error); this.config = {}; diff --git a/modules/widget-lifecycle/src/config.ts b/modules/widget-lifecycle/src/config.ts index c2e5b9166c..290ae58911 100644 --- a/modules/widget-lifecycle/src/config.ts +++ b/modules/widget-lifecycle/src/config.ts @@ -34,7 +34,7 @@ export type WidgetLifecycleModuleConfig = Record; declare module "@element-hq/element-web-module-api" { export interface Config { - [CONFIG_KEY]: z.input; + [CONFIG_KEY]?: z.input; } } diff --git a/modules/widget-toggles/e2e/widget-toggles.spec.ts b/modules/widget-toggles/e2e/widget-toggles.spec.ts index d5b9245d7f..7aa94f1f03 100644 --- a/modules/widget-toggles/e2e/widget-toggles.spec.ts +++ b/modules/widget-toggles/e2e/widget-toggles.spec.ts @@ -90,12 +90,28 @@ test.describe("widget-toggles", () => { }, }); - 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(); + test("should not error if config is missing", async ({ page }) => { + await expect(page.getByText("Your Element is misconfigured")).not.toBeVisible(); + await expect(page.getByText("Errors in module configuration")).not.toBeVisible(); // We don't take a screenshot as we don't want to assert Element's styling, only our own }); + test.describe("with invalid config", () => { + test.use({ + config: { + "io.element.element-web-modules.widget-toggles": { + foo: "bar", + }, + }, + }); + + test("should render error", 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: { diff --git a/modules/widget-toggles/src/config.ts b/modules/widget-toggles/src/config.ts index 54e2d075a4..2c4bdb9d1a 100644 --- a/modules/widget-toggles/src/config.ts +++ b/modules/widget-toggles/src/config.ts @@ -22,6 +22,6 @@ 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; + [CONFIG_KEY]?: input; } } diff --git a/modules/widget-toggles/src/index.tsx b/modules/widget-toggles/src/index.tsx index ec6542106a..aadb0519f3 100644 --- a/modules/widget-toggles/src/index.tsx +++ b/modules/widget-toggles/src/index.tsx @@ -11,6 +11,7 @@ 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"; +import { name as ModuleName } from "../package.json"; class WidgetToggleModule implements Module { public static readonly moduleApiVersion = "^1.12.0"; @@ -19,8 +20,14 @@ class WidgetToggleModule implements Module { public constructor(private api: Api) {} public async load(): Promise { + const rawConfig = this.api.config.get(CONFIG_KEY); + if (!rawConfig) { + console.debug(`No configuration found for module "${ModuleName}", skipping initialization.`); + return; + } + try { - this.config = WidgetTogglesConfig.parse(this.api.config.get(CONFIG_KEY)); + this.config = WidgetTogglesConfig.parse(rawConfig); } catch (e) { console.error("Failed to init module", e); throw new Error(`Errors in module configuration for widget toggles module`);