test: Vitest + RTL component tests with mocked Wails bindings

- vitest + jsdom + @testing-library/react setup
- mocked wailsjs/go/main/MatrixService + runtime
- tests for: LoginScreen, App routing, HomeScreen auto-relogin,
  RoomList, Composer, EventBubble
- pnpm test runs the suite

Frontend coverage for matrix_client_pc (flow 0010 PC client).
This commit is contained in:
2026-05-25 12:20:32 +02:00
parent 36a485ea26
commit 1a2e9b2cc0
12 changed files with 1362 additions and 2 deletions
+11 -2
View File
@@ -6,7 +6,10 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui"
},
"dependencies": {
"@mantine/core": "^7.13.0",
@@ -17,10 +20,16 @@
"react-dom": "^18.3.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/ui": "^2.1.9",
"jsdom": "^29.1.1",
"typescript": "^5.6.2",
"vite": "^5.4.8"
"vite": "^5.4.8",
"vitest": "^2.1.9"
}
}
+874
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { matrixServiceMock, runtimeMock, resetWailsMocks } from "../test/mocks/wails";
import { renderWithMantine } from "../test/utils";
vi.mock("../../wailsjs/go/main/MatrixService", () => matrixServiceMock);
vi.mock("../../wailsjs/runtime/runtime", () => runtimeMock);
import App from "../App";
describe("App routing", () => {
beforeEach(() => resetWailsMocks());
it("renders LoginScreen when there is no last user id", async () => {
matrixServiceMock.GetLastUserID.mockResolvedValueOnce("");
renderWithMantine(<App />);
expect(
await screen.findByRole("button", { name: /sign in with matrix/i }),
).toBeInTheDocument();
});
it("renders LoginScreen when session is invalid (has_token false)", async () => {
matrixServiceMock.GetLastUserID.mockResolvedValueOnce("@alice:server");
matrixServiceMock.GetSession.mockResolvedValueOnce({
user_id: "@alice:server",
device_id: "D",
homeserver_url: "https://h",
has_token: false,
} as any);
renderWithMantine(<App />);
expect(
await screen.findByRole("button", { name: /sign in with matrix/i }),
).toBeInTheDocument();
});
it("renders HomeScreen when there is a valid session", async () => {
matrixServiceMock.GetLastUserID.mockResolvedValueOnce("@alice:server");
matrixServiceMock.GetSession.mockResolvedValueOnce({
user_id: "@alice:server",
device_id: "D",
homeserver_url: "https://h",
has_token: true,
} as any);
// Keep Start pending so HomeScreen mounts but doesn't trigger errors.
matrixServiceMock.Start.mockImplementation(() => new Promise(() => {}));
renderWithMantine(<App />);
await waitFor(() => {
expect(screen.getByText(/matrix_client_pc/i)).toBeInTheDocument();
// Header is HomeScreen-specific.
expect(screen.getByRole("button", { name: /logout/i })).toBeInTheDocument();
});
});
});
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { waitFor } from "@testing-library/react";
import { Notifications } from "@mantine/notifications";
import { matrixServiceMock, runtimeMock, resetWailsMocks } from "../test/mocks/wails";
import { renderWithMantine } from "../test/utils";
vi.mock("../../wailsjs/go/main/MatrixService", () => matrixServiceMock);
vi.mock("../../wailsjs/runtime/runtime", () => runtimeMock);
import HomeScreen from "../HomeScreen";
describe("HomeScreen auto-relogin on stale token", () => {
beforeEach(() => resetWailsMocks());
async function expectAutoLogout(errorMessage: string) {
const onLogout = vi.fn();
matrixServiceMock.Start.mockRejectedValueOnce(new Error(errorMessage));
renderWithMantine(
<>
<Notifications />
<HomeScreen userID="@alice:server" onLogout={onLogout} />
</>,
);
await waitFor(() => {
expect(matrixServiceMock.Logout).toHaveBeenCalledWith("@alice:server");
expect(onLogout).toHaveBeenCalledTimes(1);
});
}
it("logs out when Start rejects with 'token rejected'", async () => {
await expectAutoLogout("token rejected by homeserver");
});
it("logs out when Start rejects with 'M_UNKNOWN_TOKEN'", async () => {
await expectAutoLogout("server returned M_UNKNOWN_TOKEN");
});
it("does NOT log out for unrelated Start failures", async () => {
const onLogout = vi.fn();
matrixServiceMock.Start.mockRejectedValueOnce(new Error("network timeout"));
renderWithMantine(
<>
<Notifications />
<HomeScreen userID="@alice:server" onLogout={onLogout} />
</>,
);
// Give effect a tick to run.
await new Promise((r) => setTimeout(r, 30));
expect(onLogout).not.toHaveBeenCalled();
expect(matrixServiceMock.Logout).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { matrixServiceMock, resetWailsMocks } from "../test/mocks/wails";
import { renderWithMantine } from "../test/utils";
vi.mock("../../wailsjs/go/main/MatrixService", () => matrixServiceMock);
import LoginScreen from "../LoginScreen";
describe("LoginScreen", () => {
beforeEach(() => resetWailsMocks());
it("renders the 'Sign in with Matrix' button", () => {
renderWithMantine(<LoginScreen onLogin={() => {}} />);
expect(
screen.getByRole("button", { name: /sign in with matrix/i }),
).toBeInTheDocument();
});
it("calls Login() and forwards the user id to onLogin on click", async () => {
const user = userEvent.setup();
const onLogin = vi.fn();
matrixServiceMock.Login.mockResolvedValueOnce("@alice:server");
renderWithMantine(<LoginScreen onLogin={onLogin} />);
await user.click(screen.getByRole("button", { name: /sign in with matrix/i }));
await waitFor(() => {
expect(matrixServiceMock.Login).toHaveBeenCalledTimes(1);
expect(onLogin).toHaveBeenCalledWith("@alice:server");
});
});
it("shows a red Alert when Login() rejects", async () => {
const user = userEvent.setup();
matrixServiceMock.Login.mockRejectedValueOnce(new Error("MAS unreachable"));
renderWithMantine(<LoginScreen onLogin={() => {}} />);
await user.click(screen.getByRole("button", { name: /sign in with matrix/i }));
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(/MAS unreachable/i);
});
});
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithMantine } from "../../test/utils";
import Composer from "../../components/Composer";
function makeProps(over?: Partial<React.ComponentProps<typeof Composer>>) {
return {
onSendText: vi.fn(async () => {}),
onSendMarkdown: vi.fn(async () => {}),
...over,
};
}
describe("Composer", () => {
it("sends on plain Enter with the typed body", async () => {
const user = userEvent.setup();
const props = makeProps();
renderWithMantine(<Composer {...props} />);
const textarea = screen.getByPlaceholderText(/enter to send/i);
await user.type(textarea, "hello world{Enter}");
expect(props.onSendText).toHaveBeenCalledTimes(1);
expect(props.onSendText).toHaveBeenCalledWith("hello world");
expect(props.onSendMarkdown).not.toHaveBeenCalled();
});
it("Shift+Enter inserts a newline and does NOT send", async () => {
const user = userEvent.setup();
const props = makeProps();
renderWithMantine(<Composer {...props} />);
const textarea = screen.getByPlaceholderText(/enter to send/i) as HTMLTextAreaElement;
await user.type(textarea, "line1{Shift>}{Enter}{/Shift}line2");
expect(props.onSendText).not.toHaveBeenCalled();
expect(textarea.value).toBe("line1\nline2");
});
it("clicking the Send action icon sends the body", async () => {
const user = userEvent.setup();
const props = makeProps();
const { container } = renderWithMantine(<Composer {...props} />);
const textarea = screen.getByPlaceholderText(/enter to send/i);
await user.type(textarea, "via button");
// Send is the last ActionIcon (button) in the Composer row.
const buttons = container.querySelectorAll("button");
const sendBtn = buttons[buttons.length - 1];
await user.click(sendBtn);
expect(props.onSendText).toHaveBeenCalledWith("via button");
});
it("empty body does not invoke onSendText", async () => {
const user = userEvent.setup();
const props = makeProps();
renderWithMantine(<Composer {...props} />);
const textarea = screen.getByPlaceholderText(/enter to send/i);
// press Enter on empty textarea
textarea.focus();
await user.keyboard("{Enter}");
await user.type(textarea, " {Enter}");
expect(props.onSendText).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithMantine } from "../../test/utils";
import EventBubble from "../../components/EventBubble";
import type { MatrixEvent } from "../../types";
function event(over: Partial<MatrixEvent> = {}): MatrixEvent {
return {
event_id: "$evt-1",
room_id: "!room1:server",
sender: "@bob:server",
type: "m.room.message",
ts: Date.now(),
body: "hello",
encrypted_raw: false,
...over,
} as MatrixEvent;
}
describe("EventBubble", () => {
it("renders the body of a plain m.room.message", () => {
renderWithMantine(<EventBubble event={event({ body: "hello there" })} isSelf={false} />);
expect(screen.getByText("hello there")).toBeInTheDocument();
});
it("renders the encrypted placeholder when encrypted_raw is true", () => {
renderWithMantine(
<EventBubble
event={event({
type: "m.room.encrypted",
encrypted_raw: true,
body: "",
})}
isSelf={false}
/>,
);
expect(screen.getByText(/encrypted message/i)).toBeInTheDocument();
});
it("shows the sender short form", () => {
renderWithMantine(
<EventBubble
event={event({ sender: "@alice:matrix.server", body: "yo" })}
isSelf={false}
/>,
);
expect(screen.getByText("@alice")).toBeInTheDocument();
});
});
@@ -0,0 +1,76 @@
import { describe, it, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithMantine } from "../../test/utils";
import RoomList from "../../components/RoomList";
import type { RoomSummary } from "../../types";
function room(over: Partial<RoomSummary> = {}): RoomSummary {
return {
room_id: "!room1:server",
name: "Public Room",
canonical_alias: undefined,
avatar_mxc: undefined,
topic: undefined,
is_direct: false,
is_space: false,
is_encrypted: false,
member_count: 3,
last_event_ts: Date.now() - 5000,
unread_count: 0,
tags: [],
...over,
} as RoomSummary;
}
describe("RoomList", () => {
it("renders each room with its name", () => {
const rooms: RoomSummary[] = [
room({ room_id: "!a:s", name: "Alpha" }),
room({ room_id: "!b:s", name: "Beta" }),
room({ room_id: "!c:s", name: "Gamma" }),
];
renderWithMantine(
<RoomList rooms={rooms} activeRoomID={null} onSelect={() => {}} />,
);
expect(screen.getByText("Alpha")).toBeInTheDocument();
expect(screen.getByText("Beta")).toBeInTheDocument();
expect(screen.getByText("Gamma")).toBeInTheDocument();
});
it("invokes onSelect with the room id when a room is clicked", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const rooms = [room({ room_id: "!a:s", name: "Alpha" })];
renderWithMantine(
<RoomList rooms={rooms} activeRoomID={null} onSelect={onSelect} />,
);
await user.click(screen.getByText("Alpha"));
expect(onSelect).toHaveBeenCalledWith("!a:s");
});
it("shows the lock icon for encrypted rooms", () => {
const rooms = [
room({ room_id: "!e:s", name: "Encrypted Room", is_encrypted: true }),
];
const { container } = renderWithMantine(
<RoomList rooms={rooms} activeRoomID={null} onSelect={() => {}} />,
);
// tabler IconLock renders an <svg class="...tabler-icon-lock...">.
const lock = container.querySelector(".tabler-icon-lock");
expect(lock).not.toBeNull();
});
it("shows empty placeholder when no rooms", () => {
renderWithMantine(
<RoomList rooms={[]} activeRoomID={null} onSelect={() => {}} />,
);
expect(screen.getByText(/no rooms yet/i)).toBeInTheDocument();
});
});
+58
View File
@@ -0,0 +1,58 @@
// Shared Wails bindings mock. Tests import { matrixServiceMock, runtimeMock }
// and call `vi.mock(...)` at module level pointing at this implementation, then
// override individual fns per test with .mockResolvedValueOnce / .mockRejectedValueOnce.
import { vi } from "vitest";
export const matrixServiceMock = {
Login: vi.fn(async () => "@alice:matrix-af2f3d.organic-machine.com"),
Logout: vi.fn(async (_uid: string) => undefined),
GetSession: vi.fn(async (uid: string) => ({
user_id: uid,
device_id: "DEVICE123",
homeserver_url: "https://matrix-af2f3d.organic-machine.com",
has_token: true,
expires_at: undefined,
})),
GetLastUserID: vi.fn(async () => ""),
Start: vi.fn(async (_uid: string) => undefined),
Stop: vi.fn(async () => undefined),
ListRooms: vi.fn(async () => []),
LoadTimeline: vi.fn(async (_roomID: string, _limit: number) => []),
SendText: vi.fn(async (_roomID: string, _body: string) => "$evt-1"),
SendMarkdown: vi.fn(async (_roomID: string, _md: string) => "$evt-1"),
GetDiagnostics: vi.fn(async () => ({
started: true,
client_ready: true,
crypto_initialized: true,
sync_active: true,
user_id: "@alice:matrix-af2f3d.organic-machine.com",
homeserver_url: "https://matrix-af2f3d.organic-machine.com",
rooms_count: 0,
encrypted_rooms: 0,
dms_count: 0,
last_error: "",
})),
GetLogTail: vi.fn(async (_n: number) => [] as string[]),
GetLogPath: vi.fn(async () => "/tmp/matrix_client_pc.log"),
SetContext: vi.fn(async () => undefined),
};
export const runtimeMock = {
EventsOn: vi.fn((_event: string, _cb: (...args: any[]) => void) => {
// Return an unsubscribe function (Wails contract).
return () => {};
}),
EventsEmit: vi.fn((..._args: any[]) => {}),
EventsOff: vi.fn((..._args: any[]) => {}),
EventsOnce: vi.fn((..._args: any[]) => () => {}),
EventsOnMultiple: vi.fn((..._args: any[]) => () => {}),
};
export function resetWailsMocks() {
for (const fn of Object.values(matrixServiceMock)) {
(fn as any).mockClear();
}
for (const fn of Object.values(runtimeMock)) {
(fn as any).mockClear();
}
}
+39
View File
@@ -0,0 +1,39 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// Mantine queries matchMedia (used by useMediaQuery, color scheme).
if (typeof window !== "undefined" && !window.matchMedia) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
// ResizeObserver is referenced by Mantine ScrollArea / popovers under jsdom.
if (typeof window !== "undefined" && !(window as any).ResizeObserver) {
(window as any).ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
}
// scrollTo is not implemented in jsdom; Mantine ScrollArea calls it.
if (typeof window !== "undefined" && !window.HTMLElement.prototype.scrollTo) {
// @ts-ignore
window.HTMLElement.prototype.scrollTo = () => {};
}
afterEach(() => {
cleanup();
});
+12
View File
@@ -0,0 +1,12 @@
import { ReactElement } from "react";
import { render, RenderOptions } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
export function renderWithMantine(ui: ReactElement, options?: RenderOptions) {
return render(ui, {
wrapper: ({ children }) => (
<MantineProvider defaultColorScheme="dark">{children}</MantineProvider>
),
...options,
});
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
css: false,
include: ["src/__tests__/**/*.test.{ts,tsx}"],
},
});