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:
+11
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+874
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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}"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user