Consolidate vitest CI & coverage (#33808)
* Consolidate modules vitest coverage * Use vite-common as base for modules vitest config * Make knip happier * Fix coverage paths * Place modules unit tests alongside src * Switch to defineProject for better type safety * Consolidate vitest CI & coverage Kills off vite-common * Update comment * Update lockfile * Fix shared-components vitest config * Soften eslint config for tests in modules * Run eslint on modules/playwright dir too * Make tsc happy
This commit is contained in:
@@ -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 "./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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
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, type RenderResult, 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 "./index";
|
||||
import { CONFIG_KEY, WidgetTogglesConfig } from "./config";
|
||||
import { mockWidget, mockWidgetApi } from "./tests/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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue({ types: ["m.custom"] });
|
||||
const module = new WidgetToggleModule(api);
|
||||
await module.load();
|
||||
return (api.extras.addRoomHeaderButtonCallback as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
};
|
||||
|
||||
const mockAndRender = async (api: Api): Promise<RenderResult> => {
|
||||
(api.i18n.translate as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(key: string, vars?: Record<string, string>) => {
|
||||
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();
|
||||
return render(result!);
|
||||
};
|
||||
|
||||
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" }),
|
||||
]);
|
||||
await mockAndRender(api);
|
||||
|
||||
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" }),
|
||||
]);
|
||||
await mockAndRender(api);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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> = {}): 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<string, string>) => {
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
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, 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";
|
||||
import { type PropsWithChildren } from "react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { WidgetToggle } from "./toggle";
|
||||
import { mockI18nApi, mockWidget, mockWidgetApi } from "./tests/mocks";
|
||||
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren): React.JSX.Element => <TooltipProvider>{children}</TooltipProvider>;
|
||||
|
||||
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<typeof vi.fn>).mockReturnValue("https://example.com/avatar.png");
|
||||
render(<WidgetToggle app={app} roomId={roomId} widgetApi={widgetApi} i18nApi={i18nApi} />, { 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(<WidgetToggle app={app} roomId={roomId} widgetApi={widgetApi} i18nApi={i18nApi} />, { 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<typeof vi.fn>).mockReturnValue(false);
|
||||
render(<WidgetToggle app={app} roomId={roomId} widgetApi={widgetApi} i18nApi={i18nApi} />, { 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<typeof vi.fn>).mockReturnValue(true);
|
||||
render(<WidgetToggle app={app} roomId={roomId} widgetApi={widgetApi} i18nApi={i18nApi} />, { 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<typeof vi.fn>).mockReturnValue(false);
|
||||
render(<WidgetToggle app={app} roomId={roomId} widgetApi={widgetApi} i18nApi={i18nApi} />, { 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<typeof vi.fn>).mockReturnValue(true);
|
||||
render(<WidgetToggle app={app} roomId={roomId} widgetApi={widgetApi} i18nApi={i18nApi} />, { wrapper });
|
||||
const button = screen.getByRole("button", { name: "Hide My Widget" });
|
||||
await user.click(button);
|
||||
expect(widgetApi.moveAppToContainer).toHaveBeenCalledWith(app, "right", roomId);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user