Skip to content

Commit 9e29004

Browse files
committed
test: add unit tests for ListDatabases component and useRenderData hook, configure testing environment with happy-dom
1 parent 5ddd5de commit 9e29004

File tree

7 files changed

+806
-12
lines changed

7 files changed

+806
-12
lines changed

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,13 @@
9494
"@modelcontextprotocol/inspector": "^0.17.1",
9595
"@mongodb-js/oidc-mock-provider": "^0.12.0",
9696
"@redocly/cli": "^2.0.8",
97+
"@testing-library/jest-dom": "^6.9.1",
98+
"@testing-library/react": "^16.3.0",
9799
"@types/express": "^5.0.3",
98100
"@types/node": "^24.5.2",
99101
"@types/proper-lockfile": "^4.1.4",
100102
"@types/react": "^18.3.0",
101103
"@types/react-dom": "^19.2.3",
102-
"react": "^18.3.0",
103-
"react-dom": "^18.3.0",
104104
"@types/semver": "^7.7.0",
105105
"@types/yargs-parser": "^21.0.3",
106106
"@typescript-eslint/parser": "^8.44.0",
@@ -113,6 +113,7 @@
113113
"eslint-config-prettier": "^10.1.8",
114114
"eslint-plugin-prettier": "^5.5.4",
115115
"globals": "^16.3.0",
116+
"happy-dom": "^20.0.11",
116117
"husky": "^9.1.7",
117118
"knip": "^5.63.1",
118119
"mongodb": "^6.21.0",
@@ -121,6 +122,8 @@
121122
"openapi-typescript": "^7.9.1",
122123
"prettier": "^3.6.2",
123124
"proper-lockfile": "^4.1.2",
125+
"react": "^18.3.0",
126+
"react-dom": "^18.3.0",
124127
"semver": "^7.7.2",
125128
"simple-git": "^3.28.0",
126129
"testcontainers": "^11.7.1",

pnpm-lock.yaml

Lines changed: 474 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/setupReact.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import "@testing-library/jest-dom/vitest";
2+
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { describe, it, expect, vi, afterEach } from "vitest";
2+
import { render, screen, waitFor, act, cleanup, within } from "@testing-library/react";
3+
import { ListDatabases } from "../../../../../src/ui/components/ListDatabases/ListDatabases.js";
4+
5+
/**
6+
* Helper to simulate the parent window sending render data via postMessage
7+
*/
8+
function sendRenderData(data: unknown): void {
9+
window.dispatchEvent(
10+
new MessageEvent("message", {
11+
data: {
12+
type: "ui-lifecycle-iframe-render-data",
13+
payload: {
14+
renderData: data,
15+
},
16+
},
17+
})
18+
);
19+
}
20+
21+
describe("ListDatabases", () => {
22+
afterEach(() => {
23+
cleanup();
24+
});
25+
26+
it("should show loading state initially", () => {
27+
render(<ListDatabases />);
28+
29+
expect(screen.getByText("Loading...")).toBeInTheDocument();
30+
});
31+
32+
it("should render table with database data", async () => {
33+
render(<ListDatabases />);
34+
35+
act(() => {
36+
sendRenderData({
37+
databases: [
38+
{ name: "admin", size: 1024 },
39+
{ name: "local", size: 2048 },
40+
],
41+
totalCount: 2,
42+
});
43+
});
44+
45+
await waitFor(() => {
46+
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
47+
});
48+
49+
const table = screen.getByTestId("lg-table");
50+
expect(table).toBeInTheDocument();
51+
expect(within(table).getByText("admin")).toBeInTheDocument();
52+
expect(within(table).getByText("local")).toBeInTheDocument();
53+
expect(within(table).getByText("1 KB")).toBeInTheDocument();
54+
expect(within(table).getByText("2 KB")).toBeInTheDocument();
55+
});
56+
57+
it("should render empty table with no databases", async () => {
58+
render(<ListDatabases />);
59+
60+
act(() => {
61+
sendRenderData({
62+
databases: [],
63+
totalCount: 0,
64+
});
65+
});
66+
67+
await waitFor(() => {
68+
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
69+
});
70+
71+
const table = screen.getByTestId("lg-table");
72+
expect(table).toBeInTheDocument();
73+
expect(within(table).queryAllByTestId("lg-table-row")).toHaveLength(0);
74+
});
75+
76+
it("should format bytes correctly for various sizes", async () => {
77+
render(<ListDatabases />);
78+
79+
act(() => {
80+
sendRenderData({
81+
databases: [
82+
{ name: "tiny", size: 0 },
83+
{ name: "small", size: 512 },
84+
{ name: "medium", size: 1048576 }, // 1 MB
85+
{ name: "large", size: 1073741824 }, // 1 GB
86+
],
87+
totalCount: 4,
88+
});
89+
});
90+
91+
await waitFor(() => {
92+
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
93+
});
94+
95+
const table = screen.getByTestId("lg-table");
96+
expect(within(table).getByText("0 Bytes")).toBeInTheDocument();
97+
expect(within(table).getByText("512 Bytes")).toBeInTheDocument();
98+
expect(within(table).getByText("1 MB")).toBeInTheDocument();
99+
expect(within(table).getByText("1 GB")).toBeInTheDocument();
100+
});
101+
102+
it("should show error when data loading fails", async () => {
103+
render(<ListDatabases />);
104+
105+
act(() => {
106+
window.dispatchEvent(
107+
new MessageEvent("message", {
108+
data: {
109+
type: "ui-lifecycle-iframe-render-data",
110+
payload: "invalid-payload",
111+
},
112+
})
113+
);
114+
});
115+
116+
await waitFor(() => {
117+
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
118+
});
119+
120+
expect(screen.getByText(/Error:/)).toBeInTheDocument();
121+
});
122+
123+
it("should return null for invalid data structure", async () => {
124+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
125+
const { container } = render(<ListDatabases />);
126+
127+
act(() => {
128+
sendRenderData({
129+
// Missing required fields
130+
invalidField: "test",
131+
});
132+
});
133+
134+
await waitFor(() => {
135+
// Component should render null after validation fails
136+
expect(container.firstChild).toBeNull();
137+
});
138+
139+
consoleSpy.mockRestore();
140+
});
141+
});
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { renderHook, waitFor, act } from "@testing-library/react";
3+
import { useRenderData } from "../../../../src/ui/hooks/useRenderData.js";
4+
5+
describe("useRenderData", () => {
6+
let postMessageSpy: ReturnType<typeof vi.spyOn>;
7+
8+
beforeEach(() => {
9+
postMessageSpy = vi.spyOn(window.parent, "postMessage");
10+
});
11+
12+
afterEach(() => {
13+
postMessageSpy.mockRestore();
14+
});
15+
16+
it("should start in loading state", () => {
17+
const { result } = renderHook(() => useRenderData());
18+
expect(result.current.isLoading).toBe(true);
19+
expect(result.current.data).toBeNull();
20+
expect(result.current.error).toBeNull();
21+
});
22+
23+
it("should post ready message on mount", () => {
24+
renderHook(() => useRenderData());
25+
expect(postMessageSpy).toHaveBeenCalledWith({ type: "ui-lifecycle-iframe-ready" }, "*");
26+
});
27+
28+
it("should receive and set render data from postMessage", async () => {
29+
const { result } = renderHook(() => useRenderData<{ items: string[] }>());
30+
const testData = { items: ["a", "b", "c"] };
31+
32+
act(() => {
33+
window.dispatchEvent(
34+
new MessageEvent("message", {
35+
data: {
36+
type: "ui-lifecycle-iframe-render-data",
37+
payload: {
38+
renderData: testData,
39+
},
40+
},
41+
})
42+
);
43+
});
44+
45+
await waitFor(() => {
46+
expect(result.current.isLoading).toBe(false);
47+
});
48+
49+
expect(result.current.data).toEqual(testData);
50+
expect(result.current.error).toBeNull();
51+
});
52+
53+
it("should ignore messages with different type", async () => {
54+
const { result } = renderHook(() => useRenderData());
55+
56+
act(() => {
57+
window.dispatchEvent(
58+
new MessageEvent("message", {
59+
data: {
60+
type: "some-other-message",
61+
payload: { renderData: { test: true } },
62+
},
63+
})
64+
);
65+
});
66+
67+
// Should still be loading since we ignored the message
68+
expect(result.current.isLoading).toBe(true);
69+
expect(result.current.data).toBeNull();
70+
});
71+
72+
it("should set error for invalid payload structure", async () => {
73+
const { result } = renderHook(() => useRenderData());
74+
75+
act(() => {
76+
window.dispatchEvent(
77+
new MessageEvent("message", {
78+
data: {
79+
type: "ui-lifecycle-iframe-render-data",
80+
payload: "invalid-not-an-object",
81+
},
82+
})
83+
);
84+
});
85+
86+
await waitFor(() => {
87+
expect(result.current.isLoading).toBe(false);
88+
});
89+
90+
expect(result.current.error).toBe("Invalid payload structure received");
91+
expect(result.current.data).toBeNull();
92+
});
93+
94+
it("should set error when renderData is not an object", async () => {
95+
const { result } = renderHook(() => useRenderData());
96+
97+
act(() => {
98+
window.dispatchEvent(
99+
new MessageEvent("message", {
100+
data: {
101+
type: "ui-lifecycle-iframe-render-data",
102+
payload: {
103+
renderData: "string-not-object",
104+
},
105+
},
106+
})
107+
);
108+
});
109+
110+
await waitFor(() => {
111+
expect(result.current.isLoading).toBe(false);
112+
});
113+
114+
expect(result.current.error).toBe("Expected object but received string");
115+
expect(result.current.data).toBeNull();
116+
});
117+
118+
it("should handle null renderData without error", async () => {
119+
const { result } = renderHook(() => useRenderData());
120+
121+
act(() => {
122+
window.dispatchEvent(
123+
new MessageEvent("message", {
124+
data: {
125+
type: "ui-lifecycle-iframe-render-data",
126+
payload: {
127+
renderData: null,
128+
},
129+
},
130+
})
131+
);
132+
});
133+
134+
await waitFor(() => {
135+
expect(result.current.isLoading).toBe(false);
136+
});
137+
138+
// Null is intentionally allowed - not an error
139+
expect(result.current.error).toBeNull();
140+
expect(result.current.data).toBeNull();
141+
});
142+
143+
it("should handle undefined renderData without error", async () => {
144+
const { result } = renderHook(() => useRenderData());
145+
146+
act(() => {
147+
window.dispatchEvent(
148+
new MessageEvent("message", {
149+
data: {
150+
type: "ui-lifecycle-iframe-render-data",
151+
payload: {},
152+
},
153+
})
154+
);
155+
});
156+
157+
await waitFor(() => {
158+
expect(result.current.isLoading).toBe(false);
159+
});
160+
161+
expect(result.current.error).toBeNull();
162+
expect(result.current.data).toBeNull();
163+
});
164+
165+
it("should clean up message listener on unmount", () => {
166+
const removeEventListenerSpy = vi.spyOn(window, "removeEventListener");
167+
const { unmount } = renderHook(() => useRenderData());
168+
unmount();
169+
expect(removeEventListenerSpy).toHaveBeenCalledWith("message", expect.any(Function));
170+
removeEventListenerSpy.mockRestore();
171+
});
172+
});

tsconfig.test.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"extends": "./tsconfig.build.json",
33
"compilerOptions": {
44
"isolatedModules": true,
5-
"allowSyntheticDefaultImports": true
5+
"allowSyntheticDefaultImports": true,
6+
"types": ["node", "@testing-library/jest-dom"]
67
},
7-
"include": ["src/**/*.ts", "tests/**/*.ts"]
8+
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
89
}

vitest.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ export default defineConfig({
6969
hookTimeout: 7200000,
7070
},
7171
},
72+
{
73+
extends: true,
74+
test: {
75+
name: "ui-components",
76+
include: ["tests/unit/ui/**/*.test.tsx"],
77+
environment: "happy-dom",
78+
setupFiles: ["./tests/setup.ts", "./tests/setupReact.ts"],
79+
},
80+
},
7281
],
7382
},
7483
});

0 commit comments

Comments
 (0)