Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions app/api/chat/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { UIMessage } from "ai"
import { POST } from "./route"

vi.mock("@/lib/constants", () => ({
CHATBOT_ENABLED: false,
CHATBOT_MODEL: "claude-haiku-4-5",
DEBUG: false,
}))

vi.mock("@/lib/retrieval", () => ({
retrieveContext: vi.fn().mockResolvedValue("mocked context"),
}))

function makeRequest(body: unknown): Request {
return new Request("http://localhost/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
}

function makeMessages(text = "hello"): UIMessage[] {
return [{ id: "1", role: "user", parts: [{ type: "text", text }] }]
}

describe("POST /api/chat", () => {
it("returns 400 when messages is an empty array", async () => {
const res = await POST(makeRequest({ messages: [] }))
expect(res.status).toBe(400)
expect(await res.text()).toBe("messages must be a non-empty array")
})

it("returns 400 when body has no messages field", async () => {
const res = await POST(makeRequest({}))
expect(res.status).toBe(400)
expect(await res.text()).toBe("messages must be a non-empty array")
})

it("returns the offline message when CHATBOT_ENABLED is false", async () => {
const res = await POST(makeRequest({ messages: makeMessages() }))
expect(res.status).toBe(200)
const text = await res.text()
expect(text).toContain("The Oracle is currently offline.")
})
})
6 changes: 5 additions & 1 deletion app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ export const POST = async (req: Request) => {
const stream = createUIMessageStream({
execute: ({ writer }) => {
writer.write({ type: "text-start", id })
writer.write({ type: "text-delta", id, delta: "The Oracle is currently offline." })
writer.write({
type: "text-delta",
id,
delta: "The Oracle is currently offline.",
})
writer.write({ type: "text-end", id })
},
})
Expand Down
69 changes: 69 additions & 0 deletions components/ChatInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ChatInput } from "./ChatInput"

describe("ChatInput", () => {
it("renders a textarea and submit button", () => {
render(<ChatInput onSubmit={vi.fn()} />)
expect(screen.getByRole("textbox")).toBeInTheDocument()
expect(screen.getByRole("button")).toBeInTheDocument()
})

it("calls onSubmit with trimmed value on button click", async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<ChatInput onSubmit={onSubmit} />)
await user.type(screen.getByRole("textbox"), " hello ")
await user.click(screen.getByRole("button"))
expect(onSubmit).toHaveBeenCalledWith("hello")
})

it("calls onSubmit on Enter key", async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<ChatInput onSubmit={onSubmit} />)
await user.type(screen.getByRole("textbox"), "spell{Enter}")
expect(onSubmit).toHaveBeenCalledWith("spell")
})

it("does not call onSubmit on Shift+Enter", async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<ChatInput onSubmit={onSubmit} />)
await user.type(screen.getByRole("textbox"), "spell")
await user.keyboard("{Shift>}{Enter}{/Shift}")
expect(onSubmit).not.toHaveBeenCalled()
})

it("clears the textarea after submit", async () => {
const user = userEvent.setup()
render(<ChatInput onSubmit={vi.fn()} />)
const textarea = screen.getByRole("textbox")
await user.type(textarea, "hello")
await user.click(screen.getByRole("button"))
expect(textarea).toHaveValue("")
})

it("does not call onSubmit when textarea is empty", async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<ChatInput onSubmit={onSubmit} />)
await user.click(screen.getByRole("button"))
expect(onSubmit).not.toHaveBeenCalled()
})

it("button is disabled when textarea is empty", () => {
render(<ChatInput onSubmit={vi.fn()} />)
expect(screen.getByRole("button")).toBeDisabled()
})

it("textarea and button are disabled when isDisabled is true", async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<ChatInput onSubmit={onSubmit} isDisabled />)
expect(screen.getByRole("textbox")).toBeDisabled()
expect(screen.getByRole("button")).toBeDisabled()
await user.type(screen.getByRole("textbox"), "test{Enter}")
expect(onSubmit).not.toHaveBeenCalled()
})
})
29 changes: 29 additions & 0 deletions components/Message.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { render, screen } from "@testing-library/react"
import { Message } from "./Message"

describe("Message", () => {
it("shows '> You' label for user role", () => {
render(<Message role="user" content="Hello" />)
expect(screen.getByText("> You")).toBeInTheDocument()
})

it("shows '🧙 Oracle' label for assistant role", () => {
render(<Message role="assistant" content="Hello" />)
expect(screen.getByText("🧙 Oracle")).toBeInTheDocument()
})

it("renders content", () => {
render(<Message role="user" content="What is AC?" />)
expect(screen.getByText("What is AC?")).toBeInTheDocument()
})

it("shows streaming cursor when isStreaming is true", () => {
render(<Message role="assistant" content="Thinking" isStreaming />)
expect(screen.getByText("█")).toBeInTheDocument()
})

it("hides streaming cursor by default", () => {
render(<Message role="assistant" content="Done" />)
expect(screen.queryByText("█")).not.toBeInTheDocument()
})
})
53 changes: 53 additions & 0 deletions components/MessageList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { render, screen } from "@testing-library/react"
import type { UIMessage } from "ai"
import { MessageList } from "./MessageList"

window.HTMLElement.prototype.scrollIntoView = vi.fn()

function makeMessage(
role: UIMessage["role"],
text: string,
id = crypto.randomUUID()
): UIMessage {
return { id, role, parts: [{ type: "text", text }] }
}

describe("MessageList", () => {
it("shows placeholder when there are no messages", () => {
render(<MessageList messages={[]} status="ready" />)
expect(screen.getByText(/oracle awaits/i)).toBeInTheDocument()
})

it("renders all messages", () => {
const messages = [
makeMessage("user", "What is THAC0?"),
makeMessage("assistant", "THAC0 stands for..."),
]
render(<MessageList messages={messages} status="ready" />)
expect(screen.getByText("What is THAC0?")).toBeInTheDocument()
expect(screen.getByText("THAC0 stands for...")).toBeInTheDocument()
})

it("shows 'Consulting the grimoire' when status is submitted and last message is from user", () => {
const messages = [makeMessage("user", "Hello")]
render(<MessageList messages={messages} status="submitted" />)
expect(screen.getByText(/consulting the grimoire/i)).toBeInTheDocument()
})

it("does not show loading indicator when status is ready", () => {
const messages = [makeMessage("user", "Hello")]
render(<MessageList messages={messages} status="ready" />)
expect(
screen.queryByText(/consulting the grimoire/i)
).not.toBeInTheDocument()
})

it("marks the last assistant message as streaming during streaming status", () => {
const messages = [
makeMessage("user", "Hello", "1"),
makeMessage("assistant", "Hi there", "2"),
]
render(<MessageList messages={messages} status="streaming" />)
expect(screen.getByText("█")).toBeInTheDocument()
})
})
45 changes: 45 additions & 0 deletions components/ThemeToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ThemeToggle } from "./ThemeToggle"

describe("ThemeToggle", () => {
afterEach(() => {
cleanup() // unmount before touching the DOM to avoid MutationObserver act() warnings
document.documentElement.classList.remove("dark")
localStorage.clear()
})

it("shows 'Dark' button when dark mode is off", () => {
render(<ThemeToggle />)
expect(
screen.getByRole("button", { name: /toggle theme/i })
).toHaveTextContent("◑ Dark")
})

it("shows 'Light' button when dark mode is on", () => {
document.documentElement.classList.add("dark")
render(<ThemeToggle />)
expect(
screen.getByRole("button", { name: /toggle theme/i })
).toHaveTextContent("◑ Light")
})

it("enables dark mode and saves to localStorage on click", async () => {
const user = userEvent.setup()
render(<ThemeToggle />)
await user.click(screen.getByRole("button", { name: /toggle theme/i }))
expect(document.documentElement.classList.contains("dark")).toBe(true)
expect(localStorage.getItem("theme")).toBe("dark")
})

it("disables dark mode on second click", async () => {
const user = userEvent.setup()
document.documentElement.classList.add("dark")
render(<ThemeToggle />)
await user.click(screen.getByRole("button", { name: /toggle theme/i }))
await waitFor(() =>
expect(document.documentElement.classList.contains("dark")).toBe(false)
)
expect(localStorage.getItem("theme")).toBe("light")
})
})
6 changes: 6 additions & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
pre-commit:
commands:
check:
glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc,md}"
run: npx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
stage_fixed: true
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,17 @@
"devDependencies": {
"@biomejs/biome": "^2.4.4",
"@tailwindcss/postcss": "^4.2.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/cli-progress": "^3.11.6",
"@types/node": "^20.19.35",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.3",
"eslint-config-next": "16.1.6",
"jsdom": "^28.1.0",
"lefthook": "^2.1.2",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
Expand Down
Loading