Skip to content

Commit 627158c

Browse files
authored
Merge pull request #11 from riccjohn/feature/react-testing-library
Add React Testing Library UI component tests
2 parents 8d8dbe3 + 547bf1a commit 627158c

11 files changed

Lines changed: 880 additions & 3 deletions

app/api/chat/route.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { UIMessage } from "ai"
2+
import { POST } from "./route"
3+
4+
vi.mock("@/lib/constants", () => ({
5+
CHATBOT_ENABLED: false,
6+
CHATBOT_MODEL: "claude-haiku-4-5",
7+
DEBUG: false,
8+
}))
9+
10+
vi.mock("@/lib/retrieval", () => ({
11+
retrieveContext: vi.fn().mockResolvedValue("mocked context"),
12+
}))
13+
14+
function makeRequest(body: unknown): Request {
15+
return new Request("http://localhost/api/chat", {
16+
method: "POST",
17+
headers: { "Content-Type": "application/json" },
18+
body: JSON.stringify(body),
19+
})
20+
}
21+
22+
function makeMessages(text = "hello"): UIMessage[] {
23+
return [{ id: "1", role: "user", parts: [{ type: "text", text }] }]
24+
}
25+
26+
describe("POST /api/chat", () => {
27+
it("returns 400 when messages is an empty array", async () => {
28+
const res = await POST(makeRequest({ messages: [] }))
29+
expect(res.status).toBe(400)
30+
expect(await res.text()).toBe("messages must be a non-empty array")
31+
})
32+
33+
it("returns 400 when body has no messages field", async () => {
34+
const res = await POST(makeRequest({}))
35+
expect(res.status).toBe(400)
36+
expect(await res.text()).toBe("messages must be a non-empty array")
37+
})
38+
39+
it("returns the offline message when CHATBOT_ENABLED is false", async () => {
40+
const res = await POST(makeRequest({ messages: makeMessages() }))
41+
expect(res.status).toBe(200)
42+
const text = await res.text()
43+
expect(text).toContain("The Oracle is currently offline.")
44+
})
45+
})

app/api/chat/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ export const POST = async (req: Request) => {
2323
const stream = createUIMessageStream({
2424
execute: ({ writer }) => {
2525
writer.write({ type: "text-start", id })
26-
writer.write({ type: "text-delta", id, delta: "The Oracle is currently offline." })
26+
writer.write({
27+
type: "text-delta",
28+
id,
29+
delta: "The Oracle is currently offline.",
30+
})
2731
writer.write({ type: "text-end", id })
2832
},
2933
})

components/ChatInput.test.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { render, screen } from "@testing-library/react"
2+
import userEvent from "@testing-library/user-event"
3+
import { ChatInput } from "./ChatInput"
4+
5+
describe("ChatInput", () => {
6+
it("renders a textarea and submit button", () => {
7+
render(<ChatInput onSubmit={vi.fn()} />)
8+
expect(screen.getByRole("textbox")).toBeInTheDocument()
9+
expect(screen.getByRole("button")).toBeInTheDocument()
10+
})
11+
12+
it("calls onSubmit with trimmed value on button click", async () => {
13+
const user = userEvent.setup()
14+
const onSubmit = vi.fn()
15+
render(<ChatInput onSubmit={onSubmit} />)
16+
await user.type(screen.getByRole("textbox"), " hello ")
17+
await user.click(screen.getByRole("button"))
18+
expect(onSubmit).toHaveBeenCalledWith("hello")
19+
})
20+
21+
it("calls onSubmit on Enter key", async () => {
22+
const user = userEvent.setup()
23+
const onSubmit = vi.fn()
24+
render(<ChatInput onSubmit={onSubmit} />)
25+
await user.type(screen.getByRole("textbox"), "spell{Enter}")
26+
expect(onSubmit).toHaveBeenCalledWith("spell")
27+
})
28+
29+
it("does not call onSubmit on Shift+Enter", async () => {
30+
const user = userEvent.setup()
31+
const onSubmit = vi.fn()
32+
render(<ChatInput onSubmit={onSubmit} />)
33+
await user.type(screen.getByRole("textbox"), "spell")
34+
await user.keyboard("{Shift>}{Enter}{/Shift}")
35+
expect(onSubmit).not.toHaveBeenCalled()
36+
})
37+
38+
it("clears the textarea after submit", async () => {
39+
const user = userEvent.setup()
40+
render(<ChatInput onSubmit={vi.fn()} />)
41+
const textarea = screen.getByRole("textbox")
42+
await user.type(textarea, "hello")
43+
await user.click(screen.getByRole("button"))
44+
expect(textarea).toHaveValue("")
45+
})
46+
47+
it("does not call onSubmit when textarea is empty", async () => {
48+
const user = userEvent.setup()
49+
const onSubmit = vi.fn()
50+
render(<ChatInput onSubmit={onSubmit} />)
51+
await user.click(screen.getByRole("button"))
52+
expect(onSubmit).not.toHaveBeenCalled()
53+
})
54+
55+
it("button is disabled when textarea is empty", () => {
56+
render(<ChatInput onSubmit={vi.fn()} />)
57+
expect(screen.getByRole("button")).toBeDisabled()
58+
})
59+
60+
it("textarea and button are disabled when isDisabled is true", async () => {
61+
const user = userEvent.setup()
62+
const onSubmit = vi.fn()
63+
render(<ChatInput onSubmit={onSubmit} isDisabled />)
64+
expect(screen.getByRole("textbox")).toBeDisabled()
65+
expect(screen.getByRole("button")).toBeDisabled()
66+
await user.type(screen.getByRole("textbox"), "test{Enter}")
67+
expect(onSubmit).not.toHaveBeenCalled()
68+
})
69+
})

components/Message.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { render, screen } from "@testing-library/react"
2+
import { Message } from "./Message"
3+
4+
describe("Message", () => {
5+
it("shows '> You' label for user role", () => {
6+
render(<Message role="user" content="Hello" />)
7+
expect(screen.getByText("> You")).toBeInTheDocument()
8+
})
9+
10+
it("shows '🧙 Oracle' label for assistant role", () => {
11+
render(<Message role="assistant" content="Hello" />)
12+
expect(screen.getByText("🧙 Oracle")).toBeInTheDocument()
13+
})
14+
15+
it("renders content", () => {
16+
render(<Message role="user" content="What is AC?" />)
17+
expect(screen.getByText("What is AC?")).toBeInTheDocument()
18+
})
19+
20+
it("shows streaming cursor when isStreaming is true", () => {
21+
render(<Message role="assistant" content="Thinking" isStreaming />)
22+
expect(screen.getByText("█")).toBeInTheDocument()
23+
})
24+
25+
it("hides streaming cursor by default", () => {
26+
render(<Message role="assistant" content="Done" />)
27+
expect(screen.queryByText("█")).not.toBeInTheDocument()
28+
})
29+
})

components/MessageList.test.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { render, screen } from "@testing-library/react"
2+
import type { UIMessage } from "ai"
3+
import { MessageList } from "./MessageList"
4+
5+
window.HTMLElement.prototype.scrollIntoView = vi.fn()
6+
7+
function makeMessage(
8+
role: UIMessage["role"],
9+
text: string,
10+
id = crypto.randomUUID()
11+
): UIMessage {
12+
return { id, role, parts: [{ type: "text", text }] }
13+
}
14+
15+
describe("MessageList", () => {
16+
it("shows placeholder when there are no messages", () => {
17+
render(<MessageList messages={[]} status="ready" />)
18+
expect(screen.getByText(/oracle awaits/i)).toBeInTheDocument()
19+
})
20+
21+
it("renders all messages", () => {
22+
const messages = [
23+
makeMessage("user", "What is THAC0?"),
24+
makeMessage("assistant", "THAC0 stands for..."),
25+
]
26+
render(<MessageList messages={messages} status="ready" />)
27+
expect(screen.getByText("What is THAC0?")).toBeInTheDocument()
28+
expect(screen.getByText("THAC0 stands for...")).toBeInTheDocument()
29+
})
30+
31+
it("shows 'Consulting the grimoire' when status is submitted and last message is from user", () => {
32+
const messages = [makeMessage("user", "Hello")]
33+
render(<MessageList messages={messages} status="submitted" />)
34+
expect(screen.getByText(/consulting the grimoire/i)).toBeInTheDocument()
35+
})
36+
37+
it("does not show loading indicator when status is ready", () => {
38+
const messages = [makeMessage("user", "Hello")]
39+
render(<MessageList messages={messages} status="ready" />)
40+
expect(
41+
screen.queryByText(/consulting the grimoire/i)
42+
).not.toBeInTheDocument()
43+
})
44+
45+
it("marks the last assistant message as streaming during streaming status", () => {
46+
const messages = [
47+
makeMessage("user", "Hello", "1"),
48+
makeMessage("assistant", "Hi there", "2"),
49+
]
50+
render(<MessageList messages={messages} status="streaming" />)
51+
expect(screen.getByText("█")).toBeInTheDocument()
52+
})
53+
})

components/ThemeToggle.test.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { cleanup, render, screen, waitFor } from "@testing-library/react"
2+
import userEvent from "@testing-library/user-event"
3+
import { ThemeToggle } from "./ThemeToggle"
4+
5+
describe("ThemeToggle", () => {
6+
afterEach(() => {
7+
cleanup() // unmount before touching the DOM to avoid MutationObserver act() warnings
8+
document.documentElement.classList.remove("dark")
9+
localStorage.clear()
10+
})
11+
12+
it("shows 'Dark' button when dark mode is off", () => {
13+
render(<ThemeToggle />)
14+
expect(
15+
screen.getByRole("button", { name: /toggle theme/i })
16+
).toHaveTextContent("◑ Dark")
17+
})
18+
19+
it("shows 'Light' button when dark mode is on", () => {
20+
document.documentElement.classList.add("dark")
21+
render(<ThemeToggle />)
22+
expect(
23+
screen.getByRole("button", { name: /toggle theme/i })
24+
).toHaveTextContent("◑ Light")
25+
})
26+
27+
it("enables dark mode and saves to localStorage on click", async () => {
28+
const user = userEvent.setup()
29+
render(<ThemeToggle />)
30+
await user.click(screen.getByRole("button", { name: /toggle theme/i }))
31+
expect(document.documentElement.classList.contains("dark")).toBe(true)
32+
expect(localStorage.getItem("theme")).toBe("dark")
33+
})
34+
35+
it("disables dark mode on second click", async () => {
36+
const user = userEvent.setup()
37+
document.documentElement.classList.add("dark")
38+
render(<ThemeToggle />)
39+
await user.click(screen.getByRole("button", { name: /toggle theme/i }))
40+
await waitFor(() =>
41+
expect(document.documentElement.classList.contains("dark")).toBe(false)
42+
)
43+
expect(localStorage.getItem("theme")).toBe("light")
44+
})
45+
})

lefthook.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pre-commit:
2+
commands:
3+
check:
4+
glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc,md}"
5+
run: npx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
6+
stage_fixed: true

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,17 @@
3030
"devDependencies": {
3131
"@biomejs/biome": "^2.4.4",
3232
"@tailwindcss/postcss": "^4.2.1",
33+
"@testing-library/jest-dom": "^6.9.1",
34+
"@testing-library/react": "^16.3.2",
35+
"@testing-library/user-event": "^14.6.1",
3336
"@types/cli-progress": "^3.11.6",
3437
"@types/node": "^20.19.35",
3538
"@types/react": "^19.2.14",
3639
"@types/react-dom": "^19.2.3",
3740
"eslint": "^9.39.3",
3841
"eslint-config-next": "16.1.6",
42+
"jsdom": "^28.1.0",
43+
"lefthook": "^2.1.2",
3944
"tailwindcss": "^4.2.1",
4045
"tsx": "^4.21.0",
4146
"typescript": "^5.9.3",

0 commit comments

Comments
 (0)