mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
test: component and hook unit tests for the web app
Ported from the amal66 fork (index: Open-Legal-Products/mike#205), adapted to this repo's frontend/ layout and current component behavior. Adds the jsdom + testing-library harness on top of the existing vitest setup: vitest.config.mts (jsdom environment, @/ alias mirroring tsconfig paths, dummy Supabase env for modules that build a client at import time) and vitest.setup.ts (jest-dom matchers). New devDependencies: jsdom, @testing-library/react, @testing-library/jest-dom, @testing-library/user-event, @vitejs/plugin-react. Ported suites (30 new tests): - FileTypeIcon.test.tsx (11) — fileTypeKind mapping + icon rendering - TRTable.test.tsx (1) — header/row render smoke test; the fork's ARIA role assertions (table/columnheader, "Tabular review" label) target fork-only markup — this repo's grid is div-based, so the test asserts rendered content instead - button.test.tsx (4), pill-button.test.tsx (8), cite-button.test.tsx (3) - useSmoothedReveal.test.ts (3) — passes against this repo's early-return snap behavior unchanged Dropped (subjects don't exist here): - HistoryDropdown.test.tsx — no tr-chat-panel/HistoryDropdown component - applyAssistantStreamEvent.test.ts — no such module - useProjectsQuery/useTabularReviewsQuery/useWorkflowsQuery tests — react-query is fork-only - lib/toast.test.ts — no frontend/src/lib/toast.ts - useAssistantChat.parsers.test.ts — the parser helpers exist inside useAssistantChat.ts but are not exported, and the fork's extracted module doesn't exist here Verified: frontend npm test 38/38 passing (30 new + 8 pre-existing cn() utils tests); npx tsc --noEmit clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4039b94980
commit
a29e67deac
10 changed files with 2374 additions and 3 deletions
1973
frontend/package-lock.json
generated
1973
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -62,15 +62,20 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"baseline-browser-mapping": "^2.9.11",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^16.2.6",
|
||||
"jsdom": "^27.0.1",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useSmoothedReveal } from "./useSmoothedReveal";
|
||||
|
||||
const FULL = "**Demo mode** — no AI provider key is configured.";
|
||||
|
||||
describe("useSmoothedReveal", () => {
|
||||
it("snaps to the full text when the stream ends mid-reveal", async () => {
|
||||
// Stream a long body in one chunk while active: the rAF pacer will only
|
||||
// have revealed a prefix by the time the stream ends.
|
||||
const { result, rerender } = renderHook(
|
||||
({ text, active }) => useSmoothedReveal(text, active),
|
||||
{ initialProps: { text: "", active: true } },
|
||||
);
|
||||
|
||||
rerender({ text: FULL, active: true });
|
||||
expect(result.current.length).toBeLessThan(FULL.length);
|
||||
|
||||
// Stream ends. The hook must snap to the full text — it drives the
|
||||
// rendered slice off `revealedInt`, so updating only the internal ref
|
||||
// would leave the reply frozen at a partial prefix (e.g. "**Demo mo").
|
||||
await act(async () => {
|
||||
rerender({ text: FULL, active: false });
|
||||
});
|
||||
|
||||
expect(result.current).toBe(FULL);
|
||||
});
|
||||
|
||||
it("returns the full text immediately for a replayed (non-streaming) message", () => {
|
||||
const { result } = renderHook(() => useSmoothedReveal(FULL, false));
|
||||
expect(result.current).toBe(FULL);
|
||||
});
|
||||
|
||||
it("never returns more than the text it was given", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ text, active }) => useSmoothedReveal(text, active),
|
||||
{ initialProps: { text: FULL, active: false } },
|
||||
);
|
||||
|
||||
// Text replaced by something shorter (edited / retried turn).
|
||||
await act(async () => {
|
||||
rerender({ text: "short", active: false });
|
||||
});
|
||||
|
||||
expect(result.current).toBe("short");
|
||||
});
|
||||
});
|
||||
92
frontend/src/app/components/shared/FileTypeIcon.test.tsx
Normal file
92
frontend/src/app/components/shared/FileTypeIcon.test.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FileTypeIcon, fileTypeKind } from "./FileTypeIcon";
|
||||
|
||||
describe("fileTypeKind", () => {
|
||||
it("maps bare file_type values to a kind", () => {
|
||||
expect(fileTypeKind("pdf")).toBe("pdf");
|
||||
expect(fileTypeKind("docx")).toBe("word");
|
||||
expect(fileTypeKind("doc")).toBe("word");
|
||||
expect(fileTypeKind("xlsx")).toBe("excel");
|
||||
expect(fileTypeKind("xlsm")).toBe("excel");
|
||||
expect(fileTypeKind("xls")).toBe("excel");
|
||||
expect(fileTypeKind("pptx")).toBe("ppt");
|
||||
expect(fileTypeKind("ppt")).toBe("ppt");
|
||||
});
|
||||
|
||||
it("maps filenames by their extension", () => {
|
||||
expect(fileTypeKind("report.pdf")).toBe("pdf");
|
||||
expect(fileTypeKind("Quarterly Deck.PPTX")).toBe("ppt");
|
||||
expect(fileTypeKind("model.final.xlsx")).toBe("excel");
|
||||
});
|
||||
|
||||
it("is case-insensitive and trims whitespace", () => {
|
||||
expect(fileTypeKind(" PDF ")).toBe("pdf");
|
||||
expect(fileTypeKind("DOCX")).toBe("word");
|
||||
});
|
||||
|
||||
it("falls back to other for unknown, empty, or nullish input", () => {
|
||||
expect(fileTypeKind("txt")).toBe("other");
|
||||
expect(fileTypeKind("")).toBe("other");
|
||||
expect(fileTypeKind(null)).toBe("other");
|
||||
expect(fileTypeKind(undefined)).toBe("other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTypeIcon", () => {
|
||||
const svgOf = (container: HTMLElement) => container.querySelector("svg");
|
||||
const imgOf = (container: HTMLElement) => container.querySelector("img");
|
||||
|
||||
it("renders the PDF icon image", () => {
|
||||
const { container } = render(<FileTypeIcon fileType="pdf" />);
|
||||
expect(imgOf(container)).toHaveAttribute(
|
||||
"src",
|
||||
expect.stringContaining("/icons/file-types/pdf.svg"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the Word icon image", () => {
|
||||
const { container } = render(<FileTypeIcon fileType="deck.docx" />);
|
||||
expect(imgOf(container)).toHaveAttribute(
|
||||
"src",
|
||||
expect.stringContaining("/icons/file-types/word.svg"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the Excel icon image", () => {
|
||||
const { container } = render(<FileTypeIcon fileType="xlsx" />);
|
||||
expect(imgOf(container)).toHaveAttribute(
|
||||
"src",
|
||||
expect.stringContaining("/icons/file-types/excel.svg"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a grey icon for unknown types", () => {
|
||||
const { container } = render(<FileTypeIcon fileType={null} />);
|
||||
expect(svgOf(container)).toHaveClass("text-gray-500");
|
||||
});
|
||||
|
||||
it("renders a muted grayscale image for a known kind", () => {
|
||||
const { container } = render(<FileTypeIcon fileType="pdf" muted />);
|
||||
const img = imgOf(container);
|
||||
expect(img).toHaveClass("grayscale");
|
||||
expect(img).toHaveClass("opacity-35");
|
||||
});
|
||||
|
||||
it("renders a muted grey placeholder for unknown types", () => {
|
||||
const { container } = render(<FileTypeIcon fileType={null} muted />);
|
||||
const svg = svgOf(container);
|
||||
expect(svg).toHaveClass("text-gray-300");
|
||||
});
|
||||
|
||||
it("always applies shrink-0 and merges a custom className", () => {
|
||||
const { container } = render(
|
||||
<FileTypeIcon fileType="pdf" className="h-6 w-6" />,
|
||||
);
|
||||
const img = imgOf(container);
|
||||
expect(img).toHaveClass("shrink-0");
|
||||
expect(img).toHaveClass("h-6");
|
||||
expect(img).toHaveClass("w-6");
|
||||
});
|
||||
});
|
||||
39
frontend/src/app/components/tabular/TRTable.test.tsx
Normal file
39
frontend/src/app/components/tabular/TRTable.test.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TRTable } from "./TRTable";
|
||||
import type { Document } from "../shared/types";
|
||||
|
||||
const doc = { id: "doc-1", filename: "report.pdf" } as Document;
|
||||
|
||||
function renderTable() {
|
||||
return render(
|
||||
<TRTable
|
||||
loading={false}
|
||||
columns={[]}
|
||||
documents={[doc]}
|
||||
cells={[]}
|
||||
savingColumn={false}
|
||||
savingColumnsConfig={false}
|
||||
selectedDocIds={[]}
|
||||
onSelectionChange={vi.fn()}
|
||||
onExpand={vi.fn()}
|
||||
onCitationClick={vi.fn()}
|
||||
onUpdateColumn={vi.fn()}
|
||||
onDeleteColumn={vi.fn()}
|
||||
onAddColumn={vi.fn()}
|
||||
onAddDocuments={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TRTable", () => {
|
||||
// The grid here is div-based (no table/columnheader/rowheader roles), so
|
||||
// this asserts on rendered content rather than ARIA table semantics.
|
||||
it("renders the Document header and a row for each document", () => {
|
||||
renderTable();
|
||||
expect(screen.getByText("Document")).toBeInTheDocument();
|
||||
expect(screen.getByText("report.pdf")).toBeInTheDocument();
|
||||
// One select-all checkbox in the header plus one per document row.
|
||||
expect(screen.getAllByRole("checkbox")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
44
frontend/src/app/components/ui/button.test.tsx
Normal file
44
frontend/src/app/components/ui/button.test.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Button } from "./button";
|
||||
|
||||
describe("Button", () => {
|
||||
it("renders its children", () => {
|
||||
render(<Button>Click me</Button>);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Click me" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires onClick when activated", async () => {
|
||||
const onClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<Button onClick={onClick}>Submit</Button>);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies the variant class for destructive buttons", () => {
|
||||
render(<Button variant="destructive">Delete</Button>);
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toHaveClass(
|
||||
"bg-destructive",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fire onClick while disabled", async () => {
|
||||
const onClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Button disabled onClick={onClick}>
|
||||
Disabled
|
||||
</Button>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Disabled" }));
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
40
frontend/src/app/components/ui/cite-button.test.tsx
Normal file
40
frontend/src/app/components/ui/cite-button.test.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { CiteButton } from "./cite-button";
|
||||
|
||||
describe("CiteButton", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders the default 'Cite' label", () => {
|
||||
render(<CiteButton quoteText="hello" citationText="Doe 2020" />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /cite/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the label when showText is false", () => {
|
||||
render(
|
||||
<CiteButton
|
||||
quoteText="hello"
|
||||
citationText="Doe 2020"
|
||||
showText={false}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Cite")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies the quote and citation, then shows 'Copied'", async () => {
|
||||
// userEvent.setup() installs a clipboard stub on navigator; spy on it.
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.spyOn(navigator.clipboard, "writeText");
|
||||
render(<CiteButton quoteText={`he said "hi"`} citationText="Doe 2020" />);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(`"he said 'hi'" Doe 2020`);
|
||||
expect(await screen.findByText("Copied")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
91
frontend/src/app/components/ui/pill-button.test.tsx
Normal file
91
frontend/src/app/components/ui/pill-button.test.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PillButton } from "./pill-button";
|
||||
|
||||
describe("PillButton", () => {
|
||||
it("renders its children as a button by default", () => {
|
||||
render(<PillButton tone="black">Save</PillButton>);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults to type=button", () => {
|
||||
render(<PillButton tone="black">Save</PillButton>);
|
||||
expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute(
|
||||
"type",
|
||||
"button",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the tone class", () => {
|
||||
render(<PillButton tone="danger">Delete</PillButton>);
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toHaveClass(
|
||||
"bg-red-600/90",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the normal size class when requested", () => {
|
||||
render(
|
||||
<PillButton tone="blue" size="normal">
|
||||
Continue
|
||||
</PillButton>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Continue" })).toHaveClass(
|
||||
"text-sm",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to the sm size class", () => {
|
||||
render(<PillButton tone="blue">Next</PillButton>);
|
||||
expect(screen.getByRole("button", { name: "Next" })).toHaveClass(
|
||||
"text-xs",
|
||||
);
|
||||
});
|
||||
|
||||
it("fires onClick when activated", async () => {
|
||||
const onClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<PillButton tone="white" onClick={onClick}>
|
||||
Click me
|
||||
</PillButton>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Click me" }));
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fire onClick while disabled", async () => {
|
||||
const onClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<PillButton tone="black" disabled onClick={onClick}>
|
||||
Disabled
|
||||
</PillButton>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Disabled" }));
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders as its child element via asChild", () => {
|
||||
render(
|
||||
<PillButton tone="blue" asChild>
|
||||
<a href="/docs">Docs</a>
|
||||
</PillButton>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "Docs" });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "/docs");
|
||||
// asChild drops the intrinsic button type onto the child.
|
||||
expect(link).not.toHaveAttribute("type");
|
||||
// Pill styling still lands on the rendered child.
|
||||
expect(link).toHaveClass("rounded-full");
|
||||
});
|
||||
});
|
||||
45
frontend/vitest.config.mts
Normal file
45
frontend/vitest.config.mts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { fileURLToPath } from "node:url";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const resolvePath = (relative: string) =>
|
||||
fileURLToPath(new URL(relative, import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
// Mirror the `@/*` path alias from tsconfig.json so unit tests resolve
|
||||
// the same module specifiers the app uses.
|
||||
alias: [
|
||||
{
|
||||
find: /^@\/(.*)$/,
|
||||
replacement: resolvePath("./src/$1"),
|
||||
},
|
||||
],
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
// app/lib/supabase.ts creates its client at module load, so any
|
||||
// component whose import graph reaches it needs these set. Dummy
|
||||
// values — unit tests never talk to Supabase.
|
||||
env: {
|
||||
NEXT_PUBLIC_SUPABASE_URL: "http://localhost:54321",
|
||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY: "test-anon-key",
|
||||
},
|
||||
// jsdom 27's CSS-color parser (@asamuzakjp/css-color) is CJS but
|
||||
// require()s the ESM-only @csstools/css-calc. That require() happens
|
||||
// in the worker process while the jsdom environment boots — before
|
||||
// Vite's transform pipeline is involved — so deps.inline can't fix it.
|
||||
// Instead, let Node itself handle require(esm): default on >=22.12,
|
||||
// and enabled by this (there harmless) flag on 22.0–22.11.
|
||||
execArgv: ["--experimental-require-module"],
|
||||
// Unit tests only. Keep any Playwright e2e specs (*.spec.ts) out.
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
exclude: ["node_modules/**", "e2e/**", "**/*.spec.ts"],
|
||||
// Generous timeouts to absorb cold-start jsdom + transform latency on CI.
|
||||
testTimeout: 20000,
|
||||
hookTimeout: 20000,
|
||||
},
|
||||
});
|
||||
1
frontend/vitest.setup.ts
Normal file
1
frontend/vitest.setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import "@testing-library/jest-dom/vitest";
|
||||
Loading…
Add table
Add a link
Reference in a new issue