Files
agentic-dev/docs/superpowers/plans/2026-05-05-plan-7-tdd-foundation.md
Danijel Martinek 1ab3a3274f docs(plan): Plan 7 — TDD foundation (12 tasks, TDD-driven, subagent-ready)
Twelve self-contained tasks closing all 10 gaps from the spec:
1. Scaffold @repo/core-testing
2. Vitest base configs (jsdom + node) in core-typescript
3. Factories per feature
4. Contract suites per repository interface
5. core-ui jsdom + RTL tests
6. core-api / core-cms / core-trpc composition tests
7. App unit tests (web-next, web-tanstack, cms)
8. Storybook test-runner integration
9. tdd-workflow.md + adding-a-feature.md restructure
10. ADR-011 + per-feature AGENTS.md
11. CI workflow
12. Per-directory coverage thresholds

Each task TDD'd, single-commit-scoped, ready for subagent dispatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:53:13 +02:00

2288 lines
72 KiB
Markdown

# Plan 7 — TDD Foundation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make full TDD frictionless in this monorepo by closing the ten gaps catalogued in `docs/superpowers/specs/2026-05-05-tdd-foundation-design.md`.
**Architecture:** Add a new `@repo/core-testing` package providing factories, contract suites, RTL helpers, and Payload mocks. Layer Vitest safety defaults (jsdom + node bases with coverage thresholds) into `core-typescript`. Add tests to every package and app, rewrite the docs to enforce TDD order, and add a CI workflow that runs typecheck + lint + boundaries + test + build + e2e + storybook on every PR.
**Tech Stack:** Vitest 3, @testing-library/react, @testing-library/user-event, jsdom, @storybook/test-runner, GitHub Actions, msw (or fetch stubs), tsx.
**Spec:** `docs/superpowers/specs/2026-05-05-tdd-foundation-design.md` — read this first if any task is unclear.
**Worktree:** Execute on branch `feature/tdd-foundation` in `.worktrees/tdd-foundation/`.
---
## Cross-cutting conventions (re-read at the start of every task)
- **TDD always:** write a failing test, run it to confirm RED, write minimal implementation, run to confirm GREEN, refactor, commit.
- **Source files use relative imports** (`../foo.js`); test files use `@/` alias.
- **Every new vitest config** must extend `nodeVitestConfig` or `jsdomVitestConfig` from `@repo/core-typescript` and declare the `@/` alias.
- **Commit per task** with a clear message. Never bundle two tasks into one commit.
- **After each task:** run `pnpm typecheck && pnpm lint && pnpm test` from repo root before declaring DONE.
- **If a step says "expected: PASS"** and the test fails, do NOT proceed. Diagnose and fix before moving on.
---
### Task 1: Scaffold `@repo/core-testing` package
**Files:**
- Create: `packages/core-testing/package.json`
- Create: `packages/core-testing/tsconfig.json`
- Create: `packages/core-testing/vitest.config.ts`
- Create: `packages/core-testing/eslint.config.js`
- Create: `packages/core-testing/turbo.json`
- Create: `packages/core-testing/AGENTS.md`
- Create: `packages/core-testing/src/index.ts`
- Create: `packages/core-testing/src/factory/define-factory.ts`
- Create: `packages/core-testing/src/factory/define-factory.test.ts`
- Create: `packages/core-testing/src/factory/index.ts`
- Create: `packages/core-testing/src/contract/define-contract-suite.ts`
- Create: `packages/core-testing/src/contract/define-contract-suite.test.ts`
- Create: `packages/core-testing/src/contract/index.ts`
- Create: `packages/core-testing/src/setup/jsdom.ts`
- Create: `packages/core-testing/src/setup/node.ts`
- Create: `packages/core-testing/src/payload/stub-config.ts`
- Create: `packages/core-testing/src/payload/mock-payload-module.ts`
- Create: `packages/core-testing/src/payload/index.ts`
- Create: `packages/core-testing/src/react/render-with-providers.tsx`
- Create: `packages/core-testing/src/react/render-with-providers.test.tsx`
- Create: `packages/core-testing/src/react/mock-trpc.ts`
- Create: `packages/core-testing/src/react/index.ts`
- Modify: `tsconfig.base.json` — add `@repo/core-testing/*` aliases
- [ ] **Step 1: Create package skeleton**
```bash
mkdir -p packages/core-testing/src/{factory,contract,setup,payload,react}
```
- [ ] **Step 2: Write package.json**
`packages/core-testing/package.json`:
```json
{
"name": "@repo/core-testing",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./factory": "./src/factory/index.ts",
"./contract": "./src/contract/index.ts",
"./react": "./src/react/index.ts",
"./payload": "./src/payload/index.ts",
"./setup/jsdom": "./src/setup/jsdom.ts",
"./setup/node": "./src/setup/node.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.0",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/tanstack-react-query": "^11.0.0",
"@tanstack/react-query": "^5.59.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"superjson": "^2.2.0",
"vitest": "^3.0.0"
},
"peerDependencies": {
"payload": "^3.0.0"
},
"peerDependenciesMeta": {
"payload": { "optional": true }
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"jsdom": "^25.0.0",
"typescript": "^5.8.0"
}
}
```
- [ ] **Step 3: Write tsconfig.json**
`packages/core-testing/tsconfig.json`:
```json
{
"extends": "@repo/core-typescript/react-library.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
```
- [ ] **Step 4: Write vitest.config.ts**
`packages/core-testing/vitest.config.ts`:
```typescript
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "jsdom",
include: ["src/**/*.test.{ts,tsx}"],
setupFiles: ["./src/setup/jsdom.ts"],
clearMocks: true,
restoreMocks: true,
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});
```
- [ ] **Step 5: Write eslint.config.js**
`packages/core-testing/eslint.config.js`:
```javascript
import { config as base } from "@repo/core-eslint/base";
export default [
...base,
{
rules: {
// test utilities are intended to be used in test contexts
"no-console": "off",
},
},
];
```
- [ ] **Step 6: Write turbo.json (tag: tooling)**
`packages/core-testing/turbo.json`:
```json
{
"$schema": "https://turborepo.dev/schema.json",
"extends": ["//"],
"tags": ["tooling"]
}
```
- [ ] **Step 7: Write failing test for `defineFactory`**
`packages/core-testing/src/factory/define-factory.test.ts`:
```typescript
import { describe, it, expect, beforeEach } from "vitest";
import { defineFactory } from "@/factory/define-factory";
interface User {
id: string;
name: string;
age: number;
createdAt: Date;
}
describe("defineFactory", () => {
const userFactory = defineFactory<User>(({ sequence }) => ({
id: `user-${sequence}`,
name: `User ${sequence}`,
age: 30,
createdAt: new Date("2026-01-01T00:00:00Z"),
}));
beforeEach(() => userFactory.reset());
it("builds a default object", () => {
const u = userFactory.build();
expect(u).toEqual({
id: "user-1",
name: "User 1",
age: 30,
createdAt: new Date("2026-01-01T00:00:00Z"),
});
});
it("increments sequence per build", () => {
const a = userFactory.build();
const b = userFactory.build();
expect(a.id).toBe("user-1");
expect(b.id).toBe("user-2");
});
it("applies overrides", () => {
const u = userFactory.build({ name: "Alice", age: 25 });
expect(u.name).toBe("Alice");
expect(u.age).toBe(25);
expect(u.id).toBe("user-1");
});
it("buildList builds N items with same overrides", () => {
const list = userFactory.buildList(3, { age: 40 });
expect(list).toHaveLength(3);
expect(list.map((u) => u.id)).toEqual(["user-1", "user-2", "user-3"]);
expect(list.every((u) => u.age === 40)).toBe(true);
});
it("reset() restarts the sequence", () => {
userFactory.build();
userFactory.build();
userFactory.reset();
expect(userFactory.build().id).toBe("user-1");
});
});
```
- [ ] **Step 8: Run test to verify RED**
```bash
pnpm install
cd packages/core-testing && pnpm test
```
Expected: FAIL — `defineFactory` not exported.
- [ ] **Step 9: Implement `defineFactory`**
`packages/core-testing/src/factory/define-factory.ts`:
```typescript
export interface FactoryContext {
sequence: number;
}
export interface Factory<T> {
build(overrides?: Partial<T>): T;
buildList(count: number, overrides?: Partial<T>): T[];
reset(): void;
}
export function defineFactory<T extends object>(
builder: (ctx: FactoryContext) => T,
): Factory<T> {
let sequence = 0;
return {
build(overrides) {
sequence += 1;
const base = builder({ sequence });
return { ...base, ...(overrides ?? {}) } as T;
},
buildList(count, overrides) {
return Array.from({ length: count }, () => this.build(overrides));
},
reset() {
sequence = 0;
},
};
}
```
`packages/core-testing/src/factory/index.ts`:
```typescript
export { defineFactory, type Factory, type FactoryContext } from "./define-factory.js";
```
- [ ] **Step 10: Run test to verify GREEN**
```bash
cd packages/core-testing && pnpm test
```
Expected: 5/5 PASS in `define-factory.test.ts`.
- [ ] **Step 11: Write failing test for `defineContractSuite`**
`packages/core-testing/src/contract/define-contract-suite.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import { defineContractSuite } from "@/contract/define-contract-suite";
interface Adder {
add(a: number, b: number): number;
}
const adderContract = defineContractSuite<Adder>("Adder", ({ buildSubject }) => {
it("adds two positive numbers", async () => {
const subject = await buildSubject();
expect(subject.add(2, 3)).toBe(5);
});
it("handles zero", async () => {
const subject = await buildSubject();
expect(subject.add(0, 0)).toBe(0);
});
});
class RealAdder implements Adder {
add(a: number, b: number) {
return a + b;
}
}
describe("RealAdder satisfies Adder contract", () => {
adderContract.run(() => new RealAdder());
});
```
- [ ] **Step 12: Run test to verify RED**
```bash
cd packages/core-testing && pnpm test
```
Expected: FAIL — `defineContractSuite` not exported.
- [ ] **Step 13: Implement `defineContractSuite`**
`packages/core-testing/src/contract/define-contract-suite.ts`:
```typescript
import { describe } from "vitest";
export interface ContractContext<T> {
buildSubject: () => Promise<T> | T;
}
export interface ContractSuite<T> {
run(buildSubject: () => Promise<T> | T): void;
}
export function defineContractSuite<T>(
name: string,
suite: (ctx: ContractContext<T>) => void,
): ContractSuite<T> {
return {
run(buildSubject) {
describe(`Contract: ${name}`, () => {
suite({ buildSubject });
});
},
};
}
```
`packages/core-testing/src/contract/index.ts`:
```typescript
export { defineContractSuite, type ContractContext, type ContractSuite } from "./define-contract-suite.js";
```
- [ ] **Step 14: Run test to verify GREEN**
```bash
cd packages/core-testing && pnpm test
```
Expected: 7/7 PASS (5 factory + 2 contract).
- [ ] **Step 15: Implement setup files**
`packages/core-testing/src/setup/jsdom.ts`:
```typescript
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});
```
`packages/core-testing/src/setup/node.ts`:
```typescript
// Reserved for future global node-env setup. Currently a no-op so that
// vitest configs may reference @repo/core-testing/setup/node uniformly.
export {};
```
- [ ] **Step 16: Implement payload stubs**
`packages/core-testing/src/payload/stub-config.ts`:
```typescript
import type { SanitizedConfig } from "payload";
// Minimal SanitizedConfig stub for tests that need to construct repos
// without actually loading the real Payload config. Repository tests
// that mock the `payload` module never read fields off this object.
export const stubPayloadConfig = {} as SanitizedConfig;
```
`packages/core-testing/src/payload/mock-payload-module.ts`:
```typescript
import { vi } from "vitest";
import type { Payload } from "payload";
// Helper to mock the `payload` package with a custom getPayload impl.
// Call inside a test file BEFORE importing the SUT.
export function mockPayloadModule(impl: Partial<Payload>): void {
vi.mock("payload", () => ({
getPayload: vi.fn().mockResolvedValue(impl),
}));
}
```
`packages/core-testing/src/payload/index.ts`:
```typescript
export { stubPayloadConfig } from "./stub-config.js";
export { mockPayloadModule } from "./mock-payload-module.js";
```
- [ ] **Step 17: Implement react helpers**
`packages/core-testing/src/react/mock-trpc.ts`:
```typescript
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import type { AnyTRPCRouter } from "@trpc/server";
// Returns a tRPC client whose fetch is a stub honouring the provided mocks.
// Mocks are keyed by procedure path ("blog.articleBySlug") returning the
// raw response body.
export function createMockTrpcClient<TRouter extends AnyTRPCRouter>(
mocks: Record<string, unknown> = {},
) {
const fetchStub: typeof fetch = async (input) => {
const url = typeof input === "string" ? input : (input as Request).url;
const path = new URL(url, "http://mock").pathname.replace(/^\/api\/trpc\//, "");
const result = mocks[path];
if (result === undefined) {
return new Response(JSON.stringify([{ error: { code: -32603, message: `No mock for ${path}` } }]), { status: 200 });
}
return new Response(JSON.stringify([{ result: { data: superjson.serialize(result) } }]), { status: 200 });
};
return createTRPCClient<TRouter>({
links: [
httpBatchLink({
url: "http://mock/api/trpc",
transformer: superjson,
fetch: fetchStub,
}),
],
});
}
```
`packages/core-testing/src/react/render-with-providers.tsx`:
```typescript
import type { PropsWithChildren, ReactElement } from "react";
import { render, type RenderResult } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export interface RenderOptions {
queryClient?: QueryClient;
}
export function renderWithProviders(
ui: ReactElement,
options: RenderOptions = {},
): RenderResult & { queryClient: QueryClient } {
const queryClient =
options.queryClient ??
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const Wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return { ...render(ui, { wrapper: Wrapper }), queryClient };
}
```
`packages/core-testing/src/react/render-with-providers.test.tsx`:
```typescript
import { describe, it, expect } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "@/react/render-with-providers";
describe("renderWithProviders", () => {
it("renders the child", () => {
renderWithProviders(<div data-testid="x">hi</div>);
expect(screen.getByTestId("x")).toBeInTheDocument();
});
it("returns the queryClient instance", () => {
const { queryClient } = renderWithProviders(<div />);
expect(queryClient).toBeDefined();
});
});
```
`packages/core-testing/src/react/index.ts`:
```typescript
export { renderWithProviders, type RenderOptions } from "./render-with-providers.js";
export { createMockTrpcClient } from "./mock-trpc.js";
```
- [ ] **Step 18: Write top-level barrel**
`packages/core-testing/src/index.ts`:
```typescript
export * from "./factory/index.js";
export * from "./contract/index.js";
```
- [ ] **Step 19: Add tsconfig path aliases**
Edit `tsconfig.base.json` and add to `compilerOptions.paths`:
```json
"@repo/core-testing": ["packages/core-testing/src/index.ts"],
"@repo/core-testing/factory": ["packages/core-testing/src/factory/index.ts"],
"@repo/core-testing/contract": ["packages/core-testing/src/contract/index.ts"],
"@repo/core-testing/react": ["packages/core-testing/src/react/index.ts"],
"@repo/core-testing/payload": ["packages/core-testing/src/payload/index.ts"],
"@repo/core-testing/setup/jsdom": ["packages/core-testing/src/setup/jsdom.ts"],
"@repo/core-testing/setup/node": ["packages/core-testing/src/setup/node.ts"]
```
- [ ] **Step 20: Write AGENTS.md**
`packages/core-testing/AGENTS.md`:
```markdown
# @repo/core-testing
Shared testing utilities. Tag: `tooling`. May be depended on by any package as a devDependency.
## Subpath exports
- `@repo/core-testing/factory``defineFactory<T>(builder)` for test data factories
- `@repo/core-testing/contract``defineContractSuite<T>(name, suite)` for cross-impl contract tests
- `@repo/core-testing/react``renderWithProviders`, `createMockTrpcClient`
- `@repo/core-testing/payload``stubPayloadConfig`, `mockPayloadModule`
- `@repo/core-testing/setup/jsdom` — vitest setupFile (jest-dom + cleanup)
- `@repo/core-testing/setup/node` — vitest setupFile (no-op placeholder)
## Adding a factory
```typescript
import { defineFactory } from "@repo/core-testing/factory";
export const articleFactory = defineFactory<Article>(({ sequence }) => ({
id: `article-${sequence}`,
title: `Article ${sequence}`,
// stable defaults — overrides drive variation
}));
```
## Adding a contract suite
See `docs/guides/tdd-workflow.md` §"Contract suite usage".
```
- [ ] **Step 21: Verify everything**
```bash
pnpm install
pnpm typecheck --filter @repo/core-testing
pnpm test --filter @repo/core-testing
pnpm lint --filter @repo/core-testing
pnpm turbo boundaries
```
Expected: all green; 7 tests pass.
- [ ] **Step 22: Commit**
```bash
git add packages/core-testing tsconfig.base.json
git commit -m "feat(core-testing): scaffold shared testing utilities package
Adds @repo/core-testing (tag: tooling) with:
- factory/defineFactory: monotonic-sequence object factories with overrides
- contract/defineContractSuite: shared test suites runnable against multiple impls
- react/renderWithProviders + createMockTrpcClient: RTL helpers
- payload/stubPayloadConfig + mockPayloadModule: Payload mocking helpers
- setup/{jsdom,node}: vitest setup files
Spec: docs/superpowers/specs/2026-05-05-tdd-foundation-design.md §5"
```
---
### Task 2: Vitest base configs (jsdom + node) in core-typescript
**Files:**
- Modify: `packages/core-typescript/vitest.base.ts` → split into node/jsdom
- Create: `packages/core-typescript/vitest.base.node.ts`
- Create: `packages/core-typescript/vitest.base.jsdom.ts`
- Modify: `packages/core-typescript/package.json` (exports)
- Modify: 5 feature `vitest.config.ts` to use new base + alias
- Modify: `packages/core-shared/vitest.config.ts`
- [ ] **Step 1: Write failing test for the new node base**
`packages/core-typescript/vitest.base.node.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import { nodeVitestConfig } from "./vitest.base.node";
describe("nodeVitestConfig", () => {
it("uses node environment", () => {
expect(nodeVitestConfig.test?.environment).toBe("node");
});
it("enables clearMocks, restoreMocks, mockReset", () => {
expect(nodeVitestConfig.test?.clearMocks).toBe(true);
expect(nodeVitestConfig.test?.restoreMocks).toBe(true);
expect(nodeVitestConfig.test?.mockReset).toBe(true);
});
it("declares coverage thresholds", () => {
expect(nodeVitestConfig.test?.coverage?.thresholds).toMatchObject({
statements: 80, branches: 75, functions: 80, lines: 80,
});
});
it("includes src and tests glob", () => {
expect(nodeVitestConfig.test?.include).toEqual(
expect.arrayContaining(["src/**/*.test.ts", "tests/**/*.test.ts"]),
);
});
it("excludes factories and contracts from coverage", () => {
expect(nodeVitestConfig.test?.coverage?.exclude).toEqual(
expect.arrayContaining(["src/__factories__/**", "src/__contracts__/**"]),
);
});
});
```
- [ ] **Step 2: Run to verify RED**
```bash
cd packages/core-typescript && pnpm test
```
Expected: FAIL — module does not exist.
- [ ] **Step 3: Implement `vitest.base.node.ts`**
`packages/core-typescript/vitest.base.node.ts`:
```typescript
import { defineConfig } from "vitest/config";
export const nodeVitestConfig = defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
setupFiles: ["@repo/core-testing/setup/node"],
clearMocks: true,
restoreMocks: true,
mockReset: true,
unstubGlobals: true,
sequence: { shuffle: true },
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
include: ["src/**"],
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/index.ts",
"src/__factories__/**",
"src/__contracts__/**",
],
thresholds: {
statements: 80, branches: 75, functions: 80, lines: 80,
},
},
},
});
```
- [ ] **Step 4: Write failing test for jsdom base**
`packages/core-typescript/vitest.base.jsdom.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import { jsdomVitestConfig } from "./vitest.base.jsdom";
describe("jsdomVitestConfig", () => {
it("uses jsdom environment", () => {
expect(jsdomVitestConfig.test?.environment).toBe("jsdom");
});
it("loads the jsdom setup file", () => {
expect(jsdomVitestConfig.test?.setupFiles).toEqual(
expect.arrayContaining(["@repo/core-testing/setup/jsdom"]),
);
});
it("includes tsx files", () => {
expect(jsdomVitestConfig.test?.include).toEqual(
expect.arrayContaining(["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"]),
);
});
it("inherits clearMocks from node base", () => {
expect(jsdomVitestConfig.test?.clearMocks).toBe(true);
});
});
```
- [ ] **Step 5: Run to verify RED**
```bash
cd packages/core-typescript && pnpm test
```
Expected: FAIL — module does not exist.
- [ ] **Step 6: Implement `vitest.base.jsdom.ts`**
`packages/core-typescript/vitest.base.jsdom.ts`:
```typescript
import { defineConfig, mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "./vitest.base.node.js";
export const jsdomVitestConfig = mergeConfig(
nodeVitestConfig,
defineConfig({
test: {
environment: "jsdom",
setupFiles: ["@repo/core-testing/setup/jsdom"],
include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"],
},
}),
);
```
- [ ] **Step 7: Update package.json exports**
Edit `packages/core-typescript/package.json` exports map to add:
```json
"./vitest.base.node": "./vitest.base.node.ts",
"./vitest.base.jsdom": "./vitest.base.jsdom.ts"
```
(Keep existing `./vitest.base` for backwards-compat — it should re-export `nodeVitestConfig` as `baseVitestConfig` from `vitest.base.ts`.)
- [ ] **Step 8: Update legacy `vitest.base.ts`**
`packages/core-typescript/vitest.base.ts`:
```typescript
// Backwards-compat re-export. New code should import from
// vitest.base.node or vitest.base.jsdom directly.
export { nodeVitestConfig as baseVitestConfig } from "./vitest.base.node.js";
```
- [ ] **Step 9: Add devDependency on @repo/core-testing for the type aliases used by setupFiles**
Edit `packages/core-typescript/package.json` devDependencies — add `"@repo/core-testing": "workspace:*"`.
- [ ] **Step 10: Run tests for core-typescript to verify GREEN**
```bash
pnpm install
cd packages/core-typescript && pnpm test
```
Expected: 9/9 PASS.
- [ ] **Step 11: Migrate feature vitest configs**
For each of `packages/{auth,blog,marketing-pages,navigation,core-shared}/vitest.config.ts`, replace contents with:
```typescript
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});
```
- [ ] **Step 12: Run all tests to confirm migration didn't break anything**
```bash
pnpm test
```
Expected: existing 96 tests still pass (some may now flag coverage threshold misses — note them, will fix in Task 12).
- [ ] **Step 13: Commit**
```bash
git add packages/core-typescript packages/auth/vitest.config.ts packages/blog/vitest.config.ts packages/marketing-pages/vitest.config.ts packages/navigation/vitest.config.ts packages/core-shared/vitest.config.ts
git commit -m "feat(core-typescript): split vitest base into node + jsdom flavors
Adds vitest.base.node and vitest.base.jsdom with safety defaults
(clearMocks, restoreMocks, mockReset, unstubGlobals, sequence.shuffle)
and coverage thresholds (80/75/80/80). Migrates all feature configs
to the new base. Existing baseVitestConfig kept as backwards-compat
re-export of nodeVitestConfig.
Spec: §6.2"
```
---
### Task 3: Add factories to all 5 features
**Files:**
- Create: `packages/auth/src/__factories__/user.factory.ts`
- Create: `packages/auth/src/__factories__/user.factory.test.ts`
- Create: `packages/auth/src/__factories__/index.ts`
- Create: `packages/blog/src/__factories__/article.factory.ts`
- Create: `packages/blog/src/__factories__/article.factory.test.ts`
- Create: `packages/blog/src/__factories__/index.ts`
- Create: `packages/marketing-pages/src/__factories__/page.factory.ts`
- Create: `packages/marketing-pages/src/__factories__/page.factory.test.ts`
- Create: `packages/marketing-pages/src/__factories__/site-settings.factory.ts`
- Create: `packages/marketing-pages/src/__factories__/index.ts`
- Create: `packages/navigation/src/__factories__/header.factory.ts`
- Create: `packages/navigation/src/__factories__/header.factory.test.ts`
- Create: `packages/navigation/src/__factories__/index.ts`
- Create: `packages/media/src/__factories__/media.factory.ts`
- Create: `packages/media/src/__factories__/media.factory.test.ts`
- Create: `packages/media/src/__factories__/index.ts`
- Modify: each feature's `package.json` — add `@repo/core-testing` devDependency
- Modify: existing tests in each feature to use factories where it cuts boilerplate (lightweight pass; do not rewrite working tests just for stylistic reasons)
- [ ] **Step 1: Write failing test for `articleFactory`**
`packages/blog/src/__factories__/article.factory.test.ts`:
```typescript
import { describe, it, expect, beforeEach } from "vitest";
import { articleFactory } from "@/__factories__/article.factory";
describe("articleFactory", () => {
beforeEach(() => articleFactory.reset());
it("returns an Article with stable defaults", () => {
const a = articleFactory.build();
expect(a.title).toBe("Article 1");
expect(a.slug).toBe("article-1");
expect(a.status).toBe("draft");
expect(a.createdAt).toEqual(new Date("2026-01-01T00:00:00Z"));
});
it("applies overrides", () => {
const a = articleFactory.build({ status: "published", title: "X" });
expect(a.status).toBe("published");
expect(a.title).toBe("X");
});
});
```
- [ ] **Step 2: Run to verify RED**
```bash
cd packages/blog && pnpm test
```
Expected: FAIL — `article.factory.ts` does not exist.
- [ ] **Step 3: Implement factory + add devDependency**
Edit `packages/blog/package.json` to add `"@repo/core-testing": "workspace:*"` under `devDependencies`. Run `pnpm install`.
`packages/blog/src/__factories__/article.factory.ts`:
```typescript
import { defineFactory } from "@repo/core-testing/factory";
import type { Article } from "../entities/article.js";
export const articleFactory = defineFactory<Article>(({ sequence }) => ({
id: `article-${sequence}`,
title: `Article ${sequence}`,
slug: `article-${sequence}`,
content: null,
status: "draft",
authorId: "user-1",
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
}));
```
`packages/blog/src/__factories__/index.ts`:
```typescript
export { articleFactory } from "./article.factory.js";
```
- [ ] **Step 4: Run to verify GREEN**
```bash
cd packages/blog && pnpm test
```
Expected: existing tests still pass + 2 new factory tests pass.
- [ ] **Step 5: Repeat steps 1-4 for each remaining factory**
For each, write a small `*.factory.test.ts` (2-3 tests), then implement:
`packages/auth/src/__factories__/user.factory.ts`:
```typescript
import { defineFactory } from "@repo/core-testing/factory";
import type { User } from "../entities/user.js";
export const userFactory = defineFactory<User>(({ sequence }) => ({
id: `user-${sequence}`,
email: `user${sequence}@example.com`,
role: "user",
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
}));
```
Adjust fields to match the actual `User` entity. Read `packages/auth/src/entities/user.ts` first.
`packages/marketing-pages/src/__factories__/page.factory.ts`:
```typescript
import { defineFactory } from "@repo/core-testing/factory";
import type { Page } from "../entities/page.js";
export const pageFactory = defineFactory<Page>(({ sequence }) => ({
id: `page-${sequence}`,
slug: `page-${sequence}`,
title: `Page ${sequence}`,
content: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
}));
```
Read `packages/marketing-pages/src/entities/page.ts` and adapt.
`packages/marketing-pages/src/__factories__/site-settings.factory.ts`:
```typescript
import { defineFactory } from "@repo/core-testing/factory";
import type { SiteSettings } from "../entities/site-settings.js";
export const siteSettingsFactory = defineFactory<SiteSettings>(({ sequence }) => ({
id: `settings-${sequence}`,
siteName: `Site ${sequence}`,
// adapt to actual entity
}));
```
Read `packages/marketing-pages/src/entities/site-settings.ts` and adapt.
`packages/navigation/src/__factories__/header.factory.ts`:
```typescript
import { defineFactory } from "@repo/core-testing/factory";
import type { Header } from "../entities/header.js";
export const headerFactory = defineFactory<Header>(({ sequence }) => ({
id: `header-${sequence}`,
navItems: [],
// adapt to actual entity
}));
```
Read `packages/navigation/src/entities/header.ts` and adapt.
`packages/media/src/__factories__/media.factory.ts`:
Media has no entity layer currently — create a minimal `Media` interface in this factory's file or skip if no consumer needs it. Read `packages/media/src` first; if there's no entity, write the factory to return a Payload Media doc shape:
```typescript
import { defineFactory } from "@repo/core-testing/factory";
export interface Media {
id: string;
alt: string;
url: string;
filename: string;
mimeType: string;
filesize: number;
}
export const mediaFactory = defineFactory<Media>(({ sequence }) => ({
id: `media-${sequence}`,
alt: `Media ${sequence}`,
url: `https://cdn.example.com/media-${sequence}.png`,
filename: `media-${sequence}.png`,
mimeType: "image/png",
filesize: 1024,
}));
```
- [ ] **Step 6: Refactor existing tests to consume factories where it removes boilerplate**
In each feature, find tests that inline raw fixture objects (like `packages/blog/src/application/use-cases/get-articles.use-case.test.ts:23-32`) and replace with `articleFactory.build({ overrides })`. Light pass — only edit tests where the change is mechanical and obviously correct.
Example refactor:
```typescript
// before:
await repo.createArticle({
id: "1", title: "A", slug: "a", content: null, status: "draft",
authorId: "u1", createdAt: now, updatedAt: now,
});
// after:
await repo.createArticle(articleFactory.build({ id: "1", title: "A", slug: "a" }));
```
- [ ] **Step 7: Run all tests**
```bash
pnpm test
```
Expected: all tests pass; factories add ~10 new tests.
- [ ] **Step 8: Commit**
```bash
git add packages/auth packages/blog packages/marketing-pages packages/navigation packages/media
git commit -m "feat(features): add test factories to all 5 features
Adds src/__factories__/<entity>.factory.ts to auth, blog, marketing-pages,
navigation, media. Each factory uses defineFactory from @repo/core-testing
with stable date defaults (2026-01-01) so snapshot diffs reflect SUT
behavior only. Refactors mechanical inline-fixture tests to use factories.
Spec: §5.1, §6.3"
```
---
### Task 4: Contract suites for repository interfaces
**Files:**
- Create: `packages/blog/src/__contracts__/articles-repository.contract.ts`
- Create: `packages/auth/src/__contracts__/users-repository.contract.ts`
- Create: `packages/marketing-pages/src/__contracts__/pages-repository.contract.ts`
- Create: `packages/marketing-pages/src/__contracts__/site-settings-repository.contract.ts`
- Create: `packages/navigation/src/__contracts__/header-repository.contract.ts`
- Modify: each `*.repository.test.ts` (mock + payload impls) to invoke `contract.run()`
- [ ] **Step 1: Write failing contract test for `IArticlesRepository`**
`packages/blog/src/__contracts__/articles-repository.contract.ts`:
```typescript
import { it, expect, beforeEach } from "vitest";
import { defineContractSuite } from "@repo/core-testing/contract";
import type { IArticlesRepository } from "../application/repositories/articles-repository.interface.js";
import { articleFactory } from "../__factories__/article.factory.js";
export const articlesRepositoryContract = defineContractSuite<IArticlesRepository>(
"IArticlesRepository",
({ buildSubject }) => {
let repo: IArticlesRepository;
beforeEach(async () => {
articleFactory.reset();
repo = await buildSubject();
});
it("createArticle persists then getArticleBySlug returns it", async () => {
const seed = articleFactory.build({ slug: "x" });
await repo.createArticle(seed);
const result = await repo.getArticleBySlug("x");
expect(result?.id).toBe(seed.id);
});
it("getArticleBySlug returns undefined for missing slug", async () => {
expect(await repo.getArticleBySlug("does-not-exist")).toBeUndefined();
});
it("listArticles returns all when no filter", async () => {
await repo.createArticle(articleFactory.build());
await repo.createArticle(articleFactory.build());
const list = await repo.listArticles();
expect(list).toHaveLength(2);
});
it("listArticles filters by status", async () => {
await repo.createArticle(articleFactory.build({ status: "draft" }));
await repo.createArticle(articleFactory.build({ status: "published" }));
const drafts = await repo.listArticles({ status: "draft" });
expect(drafts).toHaveLength(1);
expect(drafts[0]?.status).toBe("draft");
});
},
);
```
(If the actual `IArticlesRepository` interface differs, adjust assertions to match. Read the interface file first.)
- [ ] **Step 2: Wire contract into mock impl test**
Create or edit `packages/blog/src/infrastructure/repositories/mock-articles.repository.test.ts`:
```typescript
import { describe } from "vitest";
import { MockArticlesRepository } from "./mock-articles.repository";
import { articlesRepositoryContract } from "../../__contracts__/articles-repository.contract";
describe("MockArticlesRepository", () => {
articlesRepositoryContract.run(() => new MockArticlesRepository());
});
```
- [ ] **Step 3: Run to verify GREEN for mock impl**
```bash
cd packages/blog && pnpm test mock-articles.repository
```
Expected: 4 contract tests pass against MockArticlesRepository. If any fail, the mock has a bug — fix the mock to match the contract.
- [ ] **Step 4: Wire contract into payload impl test**
Edit `packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts` — wrap existing impl-specific tests in their own describe and add a contract block. Use `vi.mock('payload')` to back the contract's repo with an in-memory store:
```typescript
import { describe, vi, beforeEach } from "vitest";
import { PayloadArticlesRepository } from "./payload-articles.repository";
import { articlesRepositoryContract } from "../../__contracts__/articles-repository.contract";
import { stubPayloadConfig } from "@repo/core-testing/payload";
// Build an in-memory Payload-shaped store so the same contract suite runs.
function buildPayloadStub() {
const store = new Map<string, unknown>();
return {
create: vi.fn(async ({ data }: { data: { id: string } & Record<string, unknown> }) => {
store.set(data.id, data);
return data;
}),
find: vi.fn(async ({ where }: { where?: { slug?: { equals: string } } }) => {
const all = Array.from(store.values()) as Array<{ slug: string }>;
const docs = where?.slug ? all.filter((d) => d.slug === where.slug?.equals) : all;
return { docs };
}),
findByID: vi.fn(async ({ id }: { id: string }) => store.get(id)),
};
}
vi.mock("payload", () => ({
getPayload: vi.fn(),
}));
describe("PayloadArticlesRepository", () => {
describe("contract", () => {
articlesRepositoryContract.run(async () => {
const stub = buildPayloadStub();
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
return new PayloadArticlesRepository(stubPayloadConfig);
});
});
// ... keep existing impl-specific tests (Payload doc → domain mapping, etc.)
});
```
If the existing tests cover specific Payload-doc-to-domain mapping cases that the contract can't hit (e.g., the `author` field becoming `authorId`), keep those as separate `it` blocks alongside the contract.
- [ ] **Step 5: Run to verify GREEN for payload impl**
```bash
cd packages/blog && pnpm test payload-articles.repository
```
Expected: contract tests pass against PayloadArticlesRepository (with the in-memory stub).
- [ ] **Step 6: Repeat for the remaining 4 contracts**
For each of:
- `IUsersRepository` (auth) — mock only currently; contract still defined for future Payload impl
- `IPagesRepository` (marketing-pages) — mock + payload
- `ISiteSettingsRepository` (marketing-pages) — mock + payload
- `IHeaderRepository` (navigation) — mock + payload
Read the interface, draft the contract suite (4-8 cases per repo), wire into both impls (mock first, then payload via in-memory stub).
- [ ] **Step 7: Run all tests**
```bash
pnpm test
```
Expected: existing 96 + ~30 new contract assertions pass.
- [ ] **Step 8: Commit**
```bash
git add packages/{auth,blog,marketing-pages,navigation}/src/__contracts__ packages/{auth,blog,marketing-pages,navigation}/src/infrastructure/repositories/*.test.ts
git commit -m "feat(features): contract suites for all repository interfaces
Each repository interface now has a contract suite under
src/__contracts__/. Both Mock and Payload implementations run the
same suite, eliminating mock-vs-real drift. Payload impls back the
contract with an in-memory stub via vi.mock('payload') + a small
buildPayloadStub helper.
Spec: §5.2, §6.4"
```
---
### Task 5: Tests + jsdom config for `core-ui`
**Files:**
- Create: `packages/core-ui/vitest.config.ts`
- Modify: `packages/core-ui/package.json` — add devDeps + scripts
- Create: `packages/core-ui/src/atoms/button/button.test.tsx`
- Create: `packages/core-ui/src/atoms/input/input.test.tsx`
- Create: `packages/core-ui/src/atoms/label/label.test.tsx`
- Create at least one `.test.tsx` per discovered molecule and organism (read `packages/core-ui/src/{molecules,organisms,templates}/` first)
- [ ] **Step 1: Add devDependencies**
Edit `packages/core-ui/package.json`:
```json
{
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@storybook/react": "^8.6.0",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.0",
"@types/react": "^19.0.0",
"jsdom": "^25.0.0",
"vitest": "^3.0.0"
}
}
```
Run `pnpm install`.
- [ ] **Step 2: Create `vitest.config.ts`**
`packages/core-ui/vitest.config.ts`:
```typescript
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom";
export default mergeConfig(jsdomVitestConfig, {
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});
```
- [ ] **Step 3: Write failing test for Button**
`packages/core-ui/src/atoms/button/button.test.tsx`:
```typescript
import { describe, it, expect, vi } from "vitest";
import { renderWithProviders } from "@repo/core-testing/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "./button";
describe("Button", () => {
it("renders children inside a button", () => {
renderWithProviders(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it("calls onClick when activated", async () => {
const handleClick = vi.fn();
renderWithProviders(<Button onClick={handleClick}>Go</Button>);
await userEvent.click(screen.getByRole("button", { name: "Go" }));
expect(handleClick).toHaveBeenCalledOnce();
});
it("applies the variant class", () => {
renderWithProviders(<Button variant="destructive">X</Button>);
expect(screen.getByRole("button")).toHaveClass(/destructive/);
});
it("applies the size class", () => {
renderWithProviders(<Button size="lg">X</Button>);
expect(screen.getByRole("button")).toHaveClass(/h-11/);
});
it("disabled prop sets the attribute", () => {
renderWithProviders(<Button disabled>X</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
});
```
- [ ] **Step 4: Run to verify GREEN**
```bash
cd packages/core-ui && pnpm test
```
Expected: 5/5 PASS (Button already exists).
- [ ] **Step 5: Repeat for Input, Label**
Read each component file first, then write a small test suite that exercises rendering, props, and one user interaction.
`packages/core-ui/src/atoms/input/input.test.tsx`:
```typescript
import { describe, it, expect } from "vitest";
import { renderWithProviders } from "@repo/core-testing/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Input } from "./input";
describe("Input", () => {
it("renders an input element", () => {
renderWithProviders(<Input placeholder="email" />);
expect(screen.getByPlaceholderText("email")).toBeInTheDocument();
});
it("accepts user input", async () => {
renderWithProviders(<Input placeholder="email" />);
const input = screen.getByPlaceholderText("email");
await userEvent.type(input, "hi@example.com");
expect(input).toHaveValue("hi@example.com");
});
});
```
`packages/core-ui/src/atoms/label/label.test.tsx`:
```typescript
import { describe, it, expect } from "vitest";
import { renderWithProviders } from "@repo/core-testing/react";
import { screen } from "@testing-library/react";
import { Label } from "./label";
describe("Label", () => {
it("renders the text", () => {
renderWithProviders(<Label htmlFor="x">Email</Label>);
expect(screen.getByText("Email")).toBeInTheDocument();
});
});
```
- [ ] **Step 6: Discover and test molecules + organisms + templates**
```bash
ls packages/core-ui/src/molecules packages/core-ui/src/organisms packages/core-ui/src/templates
```
For each component discovered, create a corresponding `*.test.tsx` with at least:
1. A "renders" smoke test
2. One prop-driven assertion
3. One interaction test if the component handles user events
Keep tests minimal but real. The goal is establishing the pattern + at least one test per file, not exhaustive coverage in this task.
- [ ] **Step 7: Run all core-ui tests**
```bash
cd packages/core-ui && pnpm test
```
Expected: all new tests pass.
- [ ] **Step 8: Commit**
```bash
git add packages/core-ui
git commit -m "feat(core-ui): add jsdom Vitest config + RTL tests for components
Adopts jsdomVitestConfig from @repo/core-typescript. Adds
@testing-library/react, @testing-library/user-event, jsdom devDeps.
Writes smoke + interaction tests for every atom/molecule/organism/template
using renderWithProviders from @repo/core-testing/react.
Spec: §6.1, §6.5"
```
---
### Task 6: Tests + node config for `core-api`, `core-cms`, `core-trpc`
**Files:**
- Create: `packages/core-api/vitest.config.ts`
- Create: `packages/core-api/src/router.test.ts`
- Modify: `packages/core-api/package.json` — add test script + devDeps
- Create: `packages/core-cms/vitest.config.ts`
- Create: `packages/core-cms/src/payload.config.test.ts`
- Modify: `packages/core-cms/package.json` — add test script + devDeps
- Create: `packages/core-trpc/vitest.config.ts`
- Create: `packages/core-trpc/src/client.test.ts`
- Modify: `packages/core-trpc/package.json` — add test script + devDeps
- [ ] **Step 1: Add vitest devDep + script to core-api**
Edit `packages/core-api/package.json`:
- Scripts: add `"test": "vitest run --passWithNoTests"`
- DevDependencies: add `"@repo/core-testing": "workspace:*"`, `"vitest": "^3.0.0"`
Run `pnpm install`.
- [ ] **Step 2: Create vitest.config.ts**
`packages/core-api/vitest.config.ts`:
```typescript
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});
```
- [ ] **Step 3: Write failing composition test**
`packages/core-api/src/router.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import { appRouter } from "./root";
describe("appRouter composition", () => {
it("exposes auth, blog, marketingPages, navigation routers", () => {
const procedures = appRouter._def.procedures;
expect(Object.keys(procedures)).toEqual(
expect.arrayContaining(["auth", "blog", "marketingPages", "navigation"]),
);
});
it("blog router has expected procedures", () => {
const blog = (appRouter._def.procedures as Record<string, { _def: { procedures: Record<string, unknown> } }>).blog;
expect(blog._def.procedures).toHaveProperty("articleBySlug");
expect(blog._def.procedures).toHaveProperty("listArticles");
});
});
```
- [ ] **Step 4: Run to verify GREEN**
```bash
cd packages/core-api && pnpm test
```
Expected: 2/2 PASS. If procedure shape differs, adjust assertions to match reality (the goal is verifying composition, not enforcing a specific shape).
- [ ] **Step 5: Repeat for core-cms**
Edit `packages/core-cms/package.json` similarly.
`packages/core-cms/vitest.config.ts`: same pattern.
`packages/core-cms/src/payload.config.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import config from "./payload.config";
describe("payloadConfig composition", () => {
it("registers all feature collections", async () => {
const resolved = await config;
const slugs = resolved.collections?.map((c) => c.slug) ?? [];
expect(slugs).toEqual(expect.arrayContaining(["users", "articles", "pages", "media"]));
});
it("registers all feature globals", async () => {
const resolved = await config;
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
expect(slugs).toEqual(expect.arrayContaining(["site-settings", "header"]));
});
});
```
(`buildConfig` returns either a Promise or value; awaiting works for both.)
- [ ] **Step 6: Repeat for core-trpc**
Edit `packages/core-trpc/package.json` similarly.
`packages/core-trpc/vitest.config.ts`: same pattern, but jsdom (provider tests):
```typescript
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom";
export default mergeConfig(jsdomVitestConfig, {
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});
```
`packages/core-trpc/src/client.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import { useTRPC, TRPCProvider } from "./client";
describe("core-trpc client exports", () => {
it("exports useTRPC hook", () => {
expect(useTRPC).toBeTypeOf("function");
});
it("exports TRPCProvider component", () => {
expect(TRPCProvider).toBeTypeOf("function");
});
});
```
Add a more meaningful provider test if the providers/ folder has stable wiring to assert (e.g., assert `httpBatchLink` config includes `superjson`).
- [ ] **Step 7: Run all tests**
```bash
pnpm test
```
Expected: existing tests + 6+ new core-* tests pass.
- [ ] **Step 8: Commit**
```bash
git add packages/core-api packages/core-cms packages/core-trpc
git commit -m "feat(core-*): add Vitest configs + composition tests
core-api: appRouter exposes all 4 feature routers + blog procedure shape.
core-cms: payloadConfig registers all collections + globals.
core-trpc: client + provider exports verified.
Spec: §6.1, §6.6"
```
---
### Task 7: Unit tests for apps
**Files:**
- Create: `apps/web-next/vitest.config.ts`
- Create: `apps/web-next/src/server/bind-production.test.ts`
- Create: `apps/web-next/src/app/providers.test.tsx`
- Modify: `apps/web-next/package.json` — add test script + devDeps
- Create: `apps/web-tanstack/vitest.config.ts`
- Create: `apps/web-tanstack/src/<provider-or-bind>.test.tsx`
- Modify: `apps/web-tanstack/package.json` — add test script + devDeps
- Create: `apps/cms/vitest.config.ts`
- Create: `apps/cms/src/payload.config.test.ts` (or wherever the config is exported)
- Modify: `apps/cms/package.json` — add test script + devDeps
- [ ] **Step 1: Add devDeps and script to web-next**
Edit `apps/web-next/package.json`:
- Scripts: `"test": "vitest run --passWithNoTests"`
- DevDependencies: `"@repo/core-testing": "workspace:*"`, `"vitest": "^3.0.0"`, plus `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`, `jsdom` if not already present.
Run `pnpm install`.
- [ ] **Step 2: Create vitest.config.ts**
`apps/web-next/vitest.config.ts`:
```typescript
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom";
export default mergeConfig(jsdomVitestConfig, {
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});
```
- [ ] **Step 3: Write failing test for `bindAllProduction`**
`apps/web-next/src/server/bind-production.test.ts`:
```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) }));
vi.mock("@repo/blog/di/bind-production", () => ({ bindProductionBlog: vi.fn() }));
vi.mock("@repo/auth/di/bind-production", () => ({ bindProductionAuth: vi.fn() }));
vi.mock("@repo/marketing-pages/di/bind-production", () => ({ bindProductionMarketingPages: vi.fn() }));
vi.mock("@repo/navigation/di/bind-production", () => ({ bindProductionNavigation: vi.fn() }));
describe("bindAllProduction", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
it("binds all four feature production repos", async () => {
const { bindAllProduction } = await import("./bind-production");
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
const { bindProductionAuth } = await import("@repo/auth/di/bind-production");
const { bindProductionMarketingPages } = await import("@repo/marketing-pages/di/bind-production");
const { bindProductionNavigation } = await import("@repo/navigation/di/bind-production");
await bindAllProduction();
expect(bindProductionBlog).toHaveBeenCalledOnce();
expect(bindProductionAuth).toHaveBeenCalledOnce();
expect(bindProductionMarketingPages).toHaveBeenCalledOnce();
expect(bindProductionNavigation).toHaveBeenCalledOnce();
});
it("is idempotent — second call does not re-bind", async () => {
const { bindAllProduction } = await import("./bind-production");
const { bindProductionBlog } = await import("@repo/blog/di/bind-production");
await bindAllProduction();
await bindAllProduction();
expect(bindProductionBlog).toHaveBeenCalledOnce();
});
});
```
- [ ] **Step 4: Run to verify GREEN**
```bash
cd apps/web-next && pnpm test
```
Expected: 2/2 PASS.
- [ ] **Step 5: Write failing providers test**
Read `apps/web-next/src/app/providers.tsx` to understand its shape, then:
`apps/web-next/src/app/providers.test.tsx`:
```typescript
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { Providers } from "./providers";
describe("Providers", () => {
it("renders children", () => {
render(
<Providers>
<div data-testid="child">hi</div>
</Providers>,
);
expect(screen.getByTestId("child")).toBeInTheDocument();
});
});
```
If `Providers` requires server-side data (e.g., a tRPC initial state), pass it as a prop or mock the dependency.
- [ ] **Step 6: Run to verify GREEN**
```bash
cd apps/web-next && pnpm test
```
Expected: PASS.
- [ ] **Step 7: Repeat for web-tanstack and cms**
For `apps/web-tanstack`:
- vitest.config.ts (jsdom)
- One test asserting the providers wire `QueryClientProvider`
- One test asserting the equivalent of bindAllProduction (if applicable)
For `apps/cms`:
- vitest.config.ts (node)
- A `payload.config.test.ts` that asserts the exported config has expected collections/globals (similar to core-cms but with the app-level config)
- [ ] **Step 8: Run all tests**
```bash
pnpm test
```
Expected: all tests pass.
- [ ] **Step 9: Commit**
```bash
git add apps/web-next apps/web-tanstack apps/cms
git commit -m "feat(apps): add unit tests for providers + bind-production + cms config
web-next: bindAllProduction calls all 4 feature binders exactly once;
Providers renders children. web-tanstack: equivalent providers + bind tests.
cms: payload.config exports a SanitizedConfig with all expected collections.
Spec: §6.7, §9"
```
---
### Task 8: Storybook test-runner integration
**Files:**
- Modify: `apps/storybook/package.json` — add `@storybook/test-runner`, playwright, scripts
- Create: `apps/storybook/test-runner.config.ts`
- Create: `apps/storybook/.storybook/test-runner.test.ts` (smoke check the test-runner config loads)
- Modify: root `package.json` — add `test:stories` script
- Modify: root `turbo.json` — add `test-storybook` task
- [ ] **Step 1: Add devDeps**
Edit `apps/storybook/package.json` devDependencies:
```json
{
"@storybook/test-runner": "^0.21.0",
"playwright": "^1.50.0",
"concurrently": "^9.0.0",
"http-server": "^14.0.0",
"wait-on": "^8.0.0"
}
```
And scripts:
```json
{
"scripts": {
"build-storybook": "storybook build",
"test-storybook": "test-storybook --url http://localhost:6006",
"test:stories": "concurrently -k -s first -n 'SB,TEST' -c 'magenta,blue' 'pnpm exec http-server storybook-static --port 6006 --silent' 'pnpm exec wait-on tcp:6006 && pnpm test-storybook'"
}
}
```
Run `pnpm install`.
- [ ] **Step 2: Create test-runner.config.ts**
`apps/storybook/test-runner.config.ts`:
```typescript
import type { TestRunnerConfig } from "@storybook/test-runner";
const config: TestRunnerConfig = {
async preVisit(page) {
page.on("console", (msg) => {
if (msg.type() === "error") {
throw new Error(`Console error in story: ${msg.text()}`);
}
});
},
};
export default config;
```
- [ ] **Step 3: Add test script to root package.json**
Edit root `package.json` scripts:
```json
{
"scripts": {
"test:stories": "turbo run test:stories"
}
}
```
- [ ] **Step 4: Add task to root turbo.json**
Edit root `turbo.json`:
```json
{
"tasks": {
"test:stories": {
"dependsOn": ["build-storybook"],
"cache": false
},
"build-storybook": {
"outputs": ["storybook-static/**"]
}
}
}
```
- [ ] **Step 5: Verify the storybook test runner works locally**
```bash
pnpm install
pnpm exec playwright install --with-deps chromium
pnpm build-storybook --filter @repo/storybook
cd apps/storybook && pnpm test:stories
```
Expected: every story mounts without console errors. If any story fails, fix it (or document as a known issue with a TODO).
- [ ] **Step 6: Commit**
```bash
git add apps/storybook package.json turbo.json
git commit -m "feat(storybook): wire @storybook/test-runner for story smoke tests
Every story is now executed as a smoke test (mount + no console errors)
via @storybook/test-runner. New script: pnpm test:stories runs
build-storybook then test-storybook against the static build.
Spec: §6.8"
```
---
### Task 9: Documentation — `tdd-workflow.md` + restructure `adding-a-feature.md`
**Files:**
- Create: `docs/guides/tdd-workflow.md`
- Modify: `docs/guides/adding-a-feature.md` — interleave tests with implementation
- Modify: `docs/guides/testing-strategy.md` — cross-link to tdd-workflow
- Modify: `AGENTS.md` — link both guides
- Modify: `CLAUDE.md` — add TDD subsection under Quick Start
- [ ] **Step 1: Write `docs/guides/tdd-workflow.md`**
Sections required (no placeholders):
1. **Header + intent** (~50 words on why TDD here, not as dogma)
2. **The cycle** — Red, Green, Refactor with a fully worked blog example: write failing `getArticleBySlug` test → run → see fail → implement → run → see pass → refactor
3. **Test naming**`describe(SubjectUnderTest)` + `it("does X when Y")` with 3 examples
4. **AAA** — Arrange / Act / Assert with two examples (use case + component)
5. **When to mock — decision tree** as a DOT graph block:
```
Pure function? → no mock
Use case? → rebind repository at DI level
Repository (Payload)?→ vi.mock('payload') + stubPayloadConfig
Component with data? → renderWithProviders with mocks
Route handler? → mock at boundary only
```
6. **Test pyramid for this monorepo** — table with ratios (entities ≥ use-cases ≥ controllers ≥ feature integration ≥ component ≥ e2e)
7. **What NOT to test** — bullet list (getters/setters, framework code, third-party libs, types-only modules, generated code)
8. **Coverage targets** — 80/75/80/80 baseline, 100% in entities/use-cases/controllers, how to inspect locally
9. **Factory usage** — when to call `factory.build()` vs hand-crafted object; how to add a new factory
10. **Contract suite usage** — how to add a new repo impl: write impl → run contract → fix until green; example with code
11. **Running tests** — watch mode, focused tests (`it.only`), debugging failures, `--coverage`, `pnpm test:stories`, `pnpm test:e2e`
(Each section short and concrete; no "TODO" or "see elsewhere" without an actual link.)
- [ ] **Step 2: Restructure `adding-a-feature.md`**
Replace Part 2 ("Build the Layers") with an interleaved test-first sequence. Each layer becomes red→green:
```markdown
### Step 1: Write failing test for entity schema
Create `packages/<feature>/src/entities/<entity>.test.ts`:
[concrete failing test code]
Run: `pnpm test --filter @repo/<feature>`
Expected: FAIL — entity does not exist.
### Step 2: Implement entity to pass
[concrete entity code]
Run: `pnpm test --filter @repo/<feature>`
Expected: PASS.
### Step 3: Write factory in src/__factories__/<entity>.factory.ts
[concrete factory code]
### Step 4: Write failing test for use case
[concrete use case test using factory]
### Step 5: Implement use case to pass
[concrete use case code]
### Step 6: Write contract suite in src/__contracts__/<entity>-repository.contract.ts
[concrete contract code]
### Step 7: Implement Mock repo, run contract
[concrete mock code]
### Step 8: Implement Payload repo, run same contract
[concrete payload code with vi.mock setup]
### Step 9: Write failing controller test
[concrete controller test]
### Step 10: Implement controller to pass
[concrete controller code]
### Step 11: Write failing tRPC integration test
[concrete tests/<feature>.feature.test.ts code]
### Step 12: Wire router to pass
[concrete router code]
### Step 13: (UI optional) Write failing component test
### Step 14: Implement component to pass
### Step 15: Wire into core-api / core-cms, run typecheck + lint + boundaries
```
Add at the top:
```markdown
> **TDD Order Required:** You may not advance to the next layer until the
> current layer's tests are red, then green. See `docs/guides/tdd-workflow.md`.
```
- [ ] **Step 3: Cross-link**
Edit `docs/guides/testing-strategy.md` — add a sentence at the top: `For the *how* of TDD (red-green-refactor cycle, when to mock, what NOT to test), see ./tdd-workflow.md. This document covers test *placement* and infrastructure.`
Edit `AGENTS.md` — under "Specification & Guides", add bullet: `**TDD Workflow** — docs/guides/tdd-workflow.md — red-green-refactor cycle, mocking decision tree, coverage targets`.
Edit `CLAUDE.md` — under "Quick Start", add:
```markdown
## TDD
```bash
pnpm test --watch --filter @repo/<feature> # watch one feature
pnpm test -- --coverage # full run with coverage
pnpm test:stories # Storybook smoke tests
pnpm test:e2e # Playwright e2e
```
See `docs/guides/tdd-workflow.md` for the full cycle.
```
- [ ] **Step 4: Commit**
```bash
git add docs/guides/tdd-workflow.md docs/guides/adding-a-feature.md docs/guides/testing-strategy.md AGENTS.md CLAUDE.md
git commit -m "docs(guides): add TDD workflow + restructure adding-a-feature for TDD order
New: docs/guides/tdd-workflow.md — red-green-refactor cycle, AAA,
mocking decision tree, coverage targets, factory + contract usage.
Restructured: adding-a-feature.md interleaves tests with implementation;
TDD order is required, not optional. testing-strategy.md cross-links
the new guide. AGENTS.md and CLAUDE.md surface both.
Spec: §7"
```
---
### Task 10: ADR-011 + per-feature AGENTS.md updates
**Files:**
- Create: `docs/decisions/adr-011-tdd-foundation.md`
- Modify: `AGENTS.md` — add `@repo/core-testing` row to package map
- Modify: each feature `AGENTS.md` — add "Tests" section
- Modify: `packages/core-testing/AGENTS.md` (already created in Task 1; verify complete)
- [ ] **Step 1: Write ADR-011**
`docs/decisions/adr-011-tdd-foundation.md`:
```markdown
# ADR-011: TDD Foundation
**Status:** Accepted
**Date:** 2026-05-05
**Supersedes:** none
**Spec:** docs/superpowers/specs/2026-05-05-tdd-foundation-design.md
## Context
The vertical-feature monorepo refactor (ADRs 001-010) established
clean architecture with per-feature DI containers but did not enforce
TDD as the path of least resistance. Agentic workers were producing
code-first commits with tests added later, leading to test theatre and
mock/real drift.
## Decision
1. **New package `@repo/core-testing` (tag: tooling)** — shared test
utilities: defineFactory, defineContractSuite, renderWithProviders,
mock-payload helpers, jsdom setup file. Tagged `tooling` so any
package may depend on it as devDependency without boundary violation.
2. **Vitest base configs split into node + jsdom** with safety defaults
(clearMocks, restoreMocks, mockReset, unstubGlobals, sequence.shuffle)
and coverage thresholds (80/75/80/80 baseline; 100% in entities +
use-cases + controllers).
3. **Factories per feature** in `src/__factories__/` replace inline
fixtures. Stable date defaults (2026-01-01) so snapshot diffs reflect
SUT behavior only.
4. **Contract suites per repository interface** in `src/__contracts__/`
run against every implementation (Mock + Payload). Eliminates the
class of bug where the mock and the real impl drift apart.
5. **Tests in core-* packages and apps** — composition smoke tests
(appRouter, payloadConfig, bind-production, providers).
6. **Storybook test-runner** — every story executed as a smoke test.
7. **CI workflow** — typecheck + lint + boundaries + test + build +
e2e + storybook on every PR. Coverage uploaded as artifact.
8. **Two new docs** — tdd-workflow.md (process) + restructured
adding-a-feature.md (interleaves tests with impl).
## Alternatives considered
- **Fixture files instead of factories** — rejected. Fixtures rot with
schema changes and require manual updates per test.
- **One shared test file per impl** — rejected. Contract suites give
the same coverage in fewer LOC and prevent drift.
- **Real Postgres in tests via testcontainers** — rejected for unit
tests (slow, complex). Repository contract suites + vi.mock('payload')
give equivalent confidence in milliseconds.
- **Stryker mutation testing** — deferred. Coverage thresholds + contract
suites get us most of the way; mutation testing is incremental.
## Consequences
- New package to maintain (small, mostly stable surface).
- Coverage thresholds may fail builds initially; we add tests to cross
threshold as part of Plan 7.
- Sequence shuffle may surface latent flakes; we fix as found.
- Templates for new features now require writing tests first; this is
by design.
## Refines
- ADR-006 (boundary tags) — adds @repo/core-testing as a tooling package.
```
- [ ] **Step 2: Update root AGENTS.md package map**
In `AGENTS.md`, add row to the Package Map table:
```markdown
| `@repo/core-testing` | tooling | Shared test utilities (defineFactory, defineContractSuite, renderWithProviders, payload mocks) |
```
Update the "Five tags" section: `tooling (3 packages) — packages/core-eslint, core-typescript, core-testing`.
- [ ] **Step 3: Add Tests section to each feature AGENTS.md**
For each `packages/<feature>/AGENTS.md`, add a section near the bottom:
```markdown
## Tests
- **Factories:** `src/__factories__/<entity>.factory.ts` — use `factoryName.build({ overrides })` to construct test data with stable defaults.
- **Contract suite:** `src/__contracts__/<entity>-repository.contract.ts` — runs against every repository implementation (mock + payload).
- **Unit tests:** colocated as `*.test.ts` next to the source file.
- **Feature integration:** `tests/<feature>.feature.test.ts` — full slice through tRPC router → controller → use case → mock repo.
```bash
pnpm test --filter @repo/<feature> # all tests for this feature
pnpm test --filter @repo/<feature> -- --watch # watch mode
```
See `docs/guides/tdd-workflow.md` for the cycle.
```
- [ ] **Step 4: Verify core-testing AGENTS.md is complete**
The file was created in Task 1; ensure it documents factory + contract usage.
- [ ] **Step 5: Commit**
```bash
git add docs/decisions/adr-011-tdd-foundation.md AGENTS.md packages/*/AGENTS.md
git commit -m "docs(adr): ADR-011 TDD foundation; update AGENTS.md per-feature
Captures the decision to add @repo/core-testing, factories, contract
suites, vitest safety defaults, coverage thresholds, Storybook
test-runner, and CI as one cohesive TDD foundation. Per-feature
AGENTS.md gains a Tests section pointing to factories, contract suite,
and the canonical test commands.
Spec: §7.4, §7.5"
```
---
### Task 11: CI workflow
**Files:**
- Create: `.github/workflows/ci.yml`
- [ ] **Step 1: Create `.github/workflows/ci.yml`**
```yaml
name: CI
on:
push:
branches: [main]
pull_request:
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
CI: true
jobs:
validate:
name: typecheck + lint + boundaries + test + build
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: cms_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm turbo boundaries
- name: Test with coverage
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms_test
PAYLOAD_SECRET: test-secret-do-not-use-in-prod
run: pnpm test -- --coverage
- run: pnpm build
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: '**/coverage/lcov.info'
retention-days: 7
e2e:
name: Playwright e2e
needs: validate
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: cms_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- name: Run e2e
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms_test
PAYLOAD_SECRET: test-secret-do-not-use-in-prod
run: pnpm test:e2e
storybook:
name: Storybook smoke tests
needs: validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm build-storybook --filter @repo/storybook
- run: pnpm test:stories
```
- [ ] **Step 2: Commit**
```bash
mkdir -p .github/workflows
git add .github/workflows/ci.yml
git commit -m "ci: add GitHub Actions workflow
Runs typecheck + lint + boundaries + test (with coverage) + build
on every push to main and every PR. Postgres service for tests that
need DB. Playwright e2e and Storybook smoke tests gated on validate
job passing. Coverage uploaded as artifact (lcov format) for downstream
tools (Codecov, etc.) — wiring left to template users.
Spec: §6.11"
```
---
### Task 12: Tighten coverage thresholds where suites are mature
**Files:**
- Modify: `packages/{auth,blog,marketing-pages,navigation}/vitest.config.ts` — add per-directory thresholds
- [ ] **Step 1: Run coverage to see current state**
```bash
pnpm test -- --coverage
```
Expected: each feature reports coverage. Note any features below 80/75/80/80 baseline.
- [ ] **Step 2: Add per-directory thresholds for each feature**
For each feature where `entities/`, `use-cases/`, and `controllers/` are at 100%:
```typescript
import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
export default mergeConfig(nodeVitestConfig, {
test: {
coverage: {
thresholds: {
"src/entities/**": { statements: 100, branches: 100, functions: 100, lines: 100 },
"src/application/use-cases/**": { statements: 100, branches: 95, functions: 100, lines: 100 },
"src/interface-adapters/controllers/**": { statements: 100, branches: 95, functions: 100, lines: 100 },
statements: 80, branches: 75, functions: 80, lines: 80,
},
},
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});
```
If a directory is below threshold, either add tests to cross threshold or document why (e.g., generated types) and exclude.
- [ ] **Step 3: Run coverage again**
```bash
pnpm test -- --coverage
```
Expected: all thresholds met.
- [ ] **Step 4: Commit**
```bash
git add packages/*/vitest.config.ts
git commit -m "test: enforce per-directory coverage thresholds
entities + use-cases + controllers must hit 100% (95% branches).
Project-wide baseline remains 80/75/80/80. Tightening these directories
reflects the architectural intent: these are the pure-logic layers and
should be exhaustively tested.
Spec: §6.9"
```
---
## Final verification
After Task 12 commits:
```bash
pnpm install
pnpm typecheck
pnpm lint
pnpm turbo boundaries
pnpm test -- --coverage
pnpm build
pnpm exec playwright install --with-deps chromium
pnpm test:e2e
pnpm build-storybook --filter @repo/storybook
pnpm test:stories
```
Expected: all green.
## Self-review (run after writing the plan, before execution)
- [x] Spec coverage: all 10 gaps mapped to tasks (1→T1+T2+core-testing setup, 2→T1+T5, 3→T9, 4→T1+T3, 5→T1+T4, 6→T2, 7→T9, 8→T8, 9→T7, 10→T11)
- [x] No placeholders — every step has concrete code or commands
- [x] Type consistency — `defineFactory`, `defineContractSuite`, `renderWithProviders`, `mockPayloadModule` named identically across all tasks
- [x] Each task is independently committable
## Execution
Per `superpowers:subagent-driven-development`: dispatch one implementer subagent per task with the task text + scene-setting context, then spec compliance review, then code quality review, then proceed.