First slice of the Plan 8 deferred doc-update checklist: - CLAUDE.md Key Conventions: factory-function use cases/controllers, entities/models/<x>.ts paths, .toDynamicValue DI bindings, direct injection in tests - docs/architecture/overview.md data-flow box updated to factory style (controller resolved via container.get<IXController>; use case factory takes deps as args) - docs/decisions/adr-012-lazar-conformance.md created — records the conformance decision and four intentional divergences - docs/superpowers/plans/2026-05-05-plan-7-tdd-foundation.md and the matching spec annotated with a "pre-Plan-8 layout" note pointing at the refactor log Remaining Plan 8 doc-update items (root AGENTS.md, per-feature AGENTS.md, adding-a-feature.md, tdd-workflow.md, testing-strategy.md, vertical-feature-spec.md §6/§10, core-testing AGENTS.md) intentionally paused — Plan 9 (input/output unification) will change overlapping content, so resuming after Plan 9 lands avoids double-churn. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2290 lines
72 KiB
Markdown
2290 lines
72 KiB
Markdown
# Plan 7 — TDD Foundation Implementation Plan
|
|
|
|
> **Note (2026-05-05, post-Plan-8):** All file paths in this plan reference the **pre-Plan-8 layout** (`mock-articles.repository.ts`, `entities/article.ts`, multi-method `articles.controller.ts`, etc.). The Lazar conformance refactor (Plan 8) renamed/reshaped these files. See `docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md` for the full mapping; ADR-012 documents the conformance decision.
|
|
|
|
> **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.
|