diff --git a/packages/element-web-module-api/package.json b/packages/element-web-module-api/package.json index c3b8093a61..2c7f17038a 100644 --- a/packages/element-web-module-api/package.json +++ b/packages/element-web-module-api/package.json @@ -19,7 +19,8 @@ ], "scripts": { "prepare": "vite build && api-extractor run", - "lint:ts": "tsc --noEmit" + "lint:ts": "tsc --noEmit", + "test": "vitest" }, "devDependencies": { "@matrix-org/react-sdk-module-api": "^2.5.0", @@ -27,11 +28,14 @@ "@types/node": "^22.10.7", "@types/react": "^18", "@types/semver": "^7.5.8", + "@vitest/coverage-v8": "^3.0.4", "matrix-web-i18n": "^3.3.0", "semver": "^7.6.3", "typescript": "^5.7.3", "vite": "^6.0.11", - "vite-plugin-dts": "^4.5.0" + "vite-plugin-dts": "^4.5.0", + "vitest": "^3.0.4", + "vitest-sonar-reporter": "^2.0.0" }, "peerDependencies": { "@matrix-org/react-sdk-module-api": "*", diff --git a/packages/element-web-module-api/src/api.test.ts b/packages/element-web-module-api/src/api.test.ts new file mode 100644 index 0000000000..62dd3973aa --- /dev/null +++ b/packages/element-web-module-api/src/api.test.ts @@ -0,0 +1,23 @@ +/* +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 { expect, test } from "vitest"; + +import { Api } from "."; +import { isModule } from "./api.js"; + +const TestModule = { + default: class TestModule { + public static moduleApiVersion = "1.0.0"; + public constructor(private readonly api: Api) {} + public async load(): Promise {} + }, +}; + +test("isModule correctly identifies valid modules", () => { + expect(isModule(TestModule)).toBe(true); +}); diff --git a/packages/element-web-module-api/src/api.ts b/packages/element-web-module-api/src/api.ts index 1ab56aadd4..5534411375 100644 --- a/packages/element-web-module-api/src/api.ts +++ b/packages/element-web-module-api/src/api.ts @@ -40,13 +40,13 @@ export interface ModuleExport { } const moduleExportSignature: Record = { - default: "object", + default: "function", }; type Type = "function" | "string" | "number" | "boolean" | "object"; -function isInterface(obj: unknown, keys: Record): obj is T { - if (obj === null || typeof obj !== "object") return false; +function isInterface(obj: unknown, type: "object" | "function", keys: Record): obj is T { + if (obj === null || typeof obj !== type) return false; for (const key in keys) { if (typeof (obj as Record)[key] !== keys[key]) return false; } @@ -55,9 +55,9 @@ function isInterface(obj: unknown, keys: Record): obj is T { export function isModule(module: unknown): module is ModuleExport { return ( - isInterface(module, moduleExportSignature) && - isInterface(module.default, moduleFactorySignature) && - isInterface(module.default.prototype, moduleSignature) + isInterface(module, "object", moduleExportSignature) && + isInterface(module.default, "function", moduleFactorySignature) && + isInterface(module.default.prototype, "object", moduleSignature) ); } diff --git a/packages/element-web-module-api/src/loader.spec.ts b/packages/element-web-module-api/src/loader.spec.ts new file mode 100644 index 0000000000..853b6d3eb6 --- /dev/null +++ b/packages/element-web-module-api/src/loader.spec.ts @@ -0,0 +1,58 @@ +/* +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 { expect, test, describe, vi, beforeEach } from "vitest"; + +import { Api, ModuleIncompatibleError, ModuleLoader } from "."; + +describe("ModuleIncompatibleError", () => { + test("should extend Error", () => { + expect(new ModuleIncompatibleError("1.0.0")).toBeInstanceOf(Error); + }); +}); + +describe("ModuleLoader", () => { + const mockApi = {} as Api; + + beforeEach(() => { + vi.stubGlobal("__VERSION__", "1.0.1"); + }); + + test("should load a module", async () => { + const TestModule = { + default: class TestModule { + public static moduleApiVersion = "^1.0.0"; + public constructor(private readonly api: Api) {} + public async load(): Promise {} + }, + }; + + const spy = vi.spyOn(TestModule.default.prototype, "load"); + + const loader = new ModuleLoader(mockApi); + await loader.load(TestModule); + await loader.start(); + expect(spy).toHaveBeenCalledWith(); + }); + + test("should fail to load an incompatible module", async () => { + const TestModule = { + default: class TestModule { + public static moduleApiVersion = "^2"; + public constructor(private readonly api: Api) {} + public async load(): Promise {} + }, + }; + + const spy = vi.spyOn(TestModule.default.prototype, "load"); + + const loader = new ModuleLoader(mockApi); + await expect(loader.load(TestModule)).rejects.toThrowError(ModuleIncompatibleError); + await loader.start(); + expect(spy).not.toHaveBeenCalledWith(); + }); +}); diff --git a/packages/element-web-module-api/vite.config.ts b/packages/element-web-module-api/vite.config.ts index 0fee8e9834..6b0d47a8a2 100644 --- a/packages/element-web-module-api/vite.config.ts +++ b/packages/element-web-module-api/vite.config.ts @@ -27,4 +27,22 @@ export default defineConfig({ define: { __VERSION__: JSON.stringify(process.env.npm_package_version), }, + test: { + coverage: { + provider: "v8", + include: ["src/**/*"], + reporter: "lcov", + }, + reporters: [ + [ + "vitest-sonar-reporter", + { + outputFile: "coverage/sonar-report.xml", + onWritePath(path: string): string { + return `packages/element-web-module-api/${path}`; + }, + }, + ], + ], + }, });