Skip to content

Commit 75f74d6

Browse files
cliffhallclaude
andauthored
test(coverage): bootstrap vitest unit infra and add tests at 90%+ per file (#1245) (#1269)
* test(coverage): bootstrap vitest unit infra and add tests at 90%+ per file (#1245) Establishes the unit-test runtime AGENTS.md mandates: a vitest "unit" project alongside the existing storybook project, RTL + jest-dom + happy-dom, a Mantine-aware render helper, and a 90%-line per-file coverage gate enforced by CI. Adds 66 test files covering all 62 components under elements/groups/screens/views, plus core/mcp and clients/web/src/utils. Total: 577 tests, 98.7% line coverage globally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(coverage): address PR #1269 review feedback - AGENTS.md: correct coverage gate description (lines ≥ 90, statements ≥ 85, functions ≥ 80, branches ≥ 50) with rationale for the relaxed branch threshold - InspectorView.test.tsx: document what the Math.random() mock values (0.1 / 0.99) control vs. STUB_SUCCESS_RATE in InspectorView.tsx - vite.config.ts / tsconfig.test.json: drop unused `globals: true` and the `vitest/globals` type now that every test file imports describe/it/expect/vi explicitly; manual `afterEach(cleanup)` in setup.ts remains required because testing-library auto-cleanup relies on globalThis.afterEach Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 64a42e6 commit 75f74d6

79 files changed

Lines changed: 8117 additions & 30 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/main.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,6 @@ jobs:
3535
working-directory: ./clients/web
3636
run: npm run build
3737

38-
# - name: Run tests
39-
# run: npm run test
38+
- name: Run tests with coverage
39+
working-directory: ./clients/web
40+
run: npm run test:coverage

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ All work should be driven by items on the project board.
6464
- Ensure all code has corresponding tests
6565
- Ensure test coverage for each file is at least 90%
6666
- In unit tests that expect error output, suppress it from the console
67+
- Run unit tests with `npm run test` (or `npm run test:watch` during development) from `clients/web/`
68+
- Run `npm run test:coverage` to verify the per-file gate: lines ≥ 90, statements ≥ 85, functions ≥ 80, branches ≥ 50 (CI enforces this gate). Branches is intentionally relaxed because Mantine portal/media-query branches are not exercisable under happy-dom; new business-logic branches should still be covered.
69+
- Test files live alongside the source as `<Name>.test.tsx` (or `.test.ts` for non-React modules)
70+
- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme
6771

6872
### Responding to Code Reviews
6973
- When asked to respond to a code review of a PR,

clients/web/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ node_modules
1111
dist
1212
dist-ssr
1313
storybook-static
14+
coverage
1415
*.local
1516

1617
# Editor directories and files

clients/web/eslint.config.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
88
import tseslint from 'typescript-eslint'
99
import { defineConfig, globalIgnores } from 'eslint/config'
1010

11-
export default defineConfig([globalIgnores(['dist', 'storybook-static']), {
11+
export default defineConfig([globalIgnores(['dist', 'storybook-static', 'coverage']), {
1212
files: ['**/*.{ts,tsx}'],
1313
extends: [
1414
js.configs.recommended,
@@ -20,4 +20,11 @@ export default defineConfig([globalIgnores(['dist', 'storybook-static']), {
2020
ecmaVersion: 2020,
2121
globals: globals.browser,
2222
},
23+
}, {
24+
// Test setup files re-export utilities and mix components with helpers — the
25+
// react-refresh rule does not apply.
26+
files: ['src/test/**/*.{ts,tsx}', 'src/**/*.test.{ts,tsx}'],
27+
rules: {
28+
'react-refresh/only-export-components': 'off',
29+
},
2330
}, ...storybook.configs["flat/recommended"]])

clients/web/package-lock.json

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

clients/web/package.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@
99
"lint": "eslint .",
1010
"format": "prettier --write src",
1111
"format:check": "prettier --check src",
12-
"validate": "npm run format:check && npm run lint && npm run build",
12+
"validate": "npm run format:check && npm run lint && npm run build && npm run test:coverage",
1313
"preview": "vite preview",
1414
"storybook": "storybook dev -p 6006",
15-
"build-storybook": "storybook build"
15+
"build-storybook": "storybook build",
16+
"test": "vitest run --project=unit",
17+
"test:watch": "vitest --project=unit",
18+
"test:coverage": "vitest run --project=unit --coverage",
19+
"test:storybook": "vitest run --project=storybook"
1620
},
1721
"dependencies": {
1822
"@emotion/react": "^11.14.0",
@@ -35,12 +39,16 @@
3539
"@storybook/addon-onboarding": "^10.2.19",
3640
"@storybook/addon-vitest": "^10.2.19",
3741
"@storybook/react-vite": "^10.2.19",
42+
"@testing-library/jest-dom": "^6.9.1",
43+
"@testing-library/react": "^16.3.2",
44+
"@testing-library/user-event": "^14.6.1",
3845
"@types/node": "^24.12.0",
3946
"@types/react": "^19.2.14",
4047
"@types/react-dom": "^19.2.3",
4148
"@vitejs/plugin-react": "^6.0.0",
4249
"@vitest/browser-playwright": "^4.1.0",
4350
"@vitest/coverage-v8": "^4.1.0",
51+
"happy-dom": "^20.9.0",
4452
"eslint": "^9.39.4",
4553
"eslint-plugin-react-hooks": "^7.0.1",
4654
"eslint-plugin-react-refresh": "^0.5.2",
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, it, expect } from "vitest";
2+
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
3+
import { AnnotationBadge } from "./AnnotationBadge";
4+
5+
describe("AnnotationBadge", () => {
6+
it("renders audience labels", () => {
7+
renderWithMantine(<AnnotationBadge facet="audience" value={["user"]} />);
8+
expect(screen.getByText("audience: user")).toBeInTheDocument();
9+
});
10+
11+
it("joins multiple audience entries", () => {
12+
renderWithMantine(
13+
<AnnotationBadge facet="audience" value={["user", "assistant"]} />,
14+
);
15+
expect(screen.getByText("audience: user, assistant")).toBeInTheDocument();
16+
});
17+
18+
it.each([
19+
[0.9, "priority: high"],
20+
[0.5, "priority: medium"],
21+
[0.2, "priority: low"],
22+
])("maps priority %s to %s", (value, label) => {
23+
renderWithMantine(<AnnotationBadge facet="priority" value={value} />);
24+
expect(screen.getByText(label)).toBeInTheDocument();
25+
});
26+
27+
it.each([
28+
["readOnlyHint", "read-only"],
29+
["destructiveHint", "destructive"],
30+
["idempotentHint", "idempotent"],
31+
["openWorldHint", "open-world"],
32+
["longRunHint", "long-running"],
33+
] as const)("renders hint label for %s", (facet, label) => {
34+
renderWithMantine(<AnnotationBadge facet={facet} value={true} />);
35+
expect(screen.getByText(label)).toBeInTheDocument();
36+
});
37+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, it, expect } from "vitest";
2+
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
3+
import { CapabilityItem } from "./CapabilityItem";
4+
5+
describe("CapabilityItem", () => {
6+
it("renders the supported check mark", () => {
7+
renderWithMantine(<CapabilityItem capability="tools" supported />);
8+
expect(screen.getByText("✓")).toBeInTheDocument();
9+
expect(screen.getByText("Tools")).toBeInTheDocument();
10+
});
11+
12+
it("renders the unsupported cross mark", () => {
13+
renderWithMantine(
14+
<CapabilityItem capability="prompts" supported={false} />,
15+
);
16+
expect(screen.getByText("✗")).toBeInTheDocument();
17+
expect(screen.getByText("Prompts")).toBeInTheDocument();
18+
});
19+
20+
it("appends the count when provided", () => {
21+
renderWithMantine(
22+
<CapabilityItem capability="resources" supported count={5} />,
23+
);
24+
expect(screen.getByText("Resources (5)")).toBeInTheDocument();
25+
});
26+
27+
it("falls back to capability key for unknown labels", () => {
28+
renderWithMantine(
29+
<CapabilityItem capability={"custom" as never} supported />,
30+
);
31+
expect(screen.getByText("custom")).toBeInTheDocument();
32+
});
33+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
3+
import userEvent from "@testing-library/user-event";
4+
import { ConnectionToggle } from "./ConnectionToggle";
5+
6+
describe("ConnectionToggle", () => {
7+
it("is unchecked when disconnected", () => {
8+
renderWithMantine(
9+
<ConnectionToggle status="disconnected" onToggle={() => {}} />,
10+
);
11+
expect(screen.getByRole("switch")).not.toBeChecked();
12+
});
13+
14+
it("is checked when connected", () => {
15+
renderWithMantine(
16+
<ConnectionToggle status="connected" onToggle={() => {}} />,
17+
);
18+
expect(screen.getByRole("switch")).toBeChecked();
19+
});
20+
21+
it("is checked but disabled while connecting", () => {
22+
renderWithMantine(
23+
<ConnectionToggle status="connecting" onToggle={() => {}} />,
24+
);
25+
const sw = screen.getByRole("switch");
26+
expect(sw).toBeChecked();
27+
expect(sw).toBeDisabled();
28+
});
29+
30+
it("respects external disabled prop", () => {
31+
renderWithMantine(
32+
<ConnectionToggle status="disconnected" disabled onToggle={() => {}} />,
33+
);
34+
expect(screen.getByRole("switch")).toBeDisabled();
35+
});
36+
37+
it("invokes onToggle when clicked", async () => {
38+
const user = userEvent.setup();
39+
const onToggle = vi.fn();
40+
renderWithMantine(
41+
<ConnectionToggle status="disconnected" onToggle={onToggle} />,
42+
);
43+
await user.click(screen.getByRole("switch"));
44+
expect(onToggle).toHaveBeenCalledTimes(1);
45+
});
46+
});

0 commit comments

Comments
 (0)