Files
agentic-dev/docs/superpowers/plans/2026-05-05-plan-7-tdd-foundation.md
Danijel Martinek bffc6a96b3 docs(plan-8): partial Lazar doc-update pass (CLAUDE.md, overview.md, ADR-012, Plan 7 annotations)
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>
2026-05-06 09:17:35 +02:00

72 KiB

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

mkdir -p packages/core-testing/src/{factory,contract,setup,payload,react}
  • Step 2: Write package.json

packages/core-testing/package.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:

{
  "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:

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:

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:

{
  "$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:

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
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:

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:

export { defineFactory, type Factory, type FactoryContext } from "./define-factory.js";
  • Step 10: Run test to verify GREEN
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:

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
cd packages/core-testing && pnpm test

Expected: FAIL — defineContractSuite not exported.

  • Step 13: Implement defineContractSuite

packages/core-testing/src/contract/define-contract-suite.ts:

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:

export { defineContractSuite, type ContractContext, type ContractSuite } from "./define-contract-suite.js";
  • Step 14: Run test to verify GREEN
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:

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:

// 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:

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:

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:

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:

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:

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:

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:

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:

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:

"@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:

# @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
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:

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
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:

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:

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
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:

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:

"./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:

// 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
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:

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
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
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:

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
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:

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:

export { articleFactory } from "./article.factory.js";
  • Step 4: Run to verify GREEN
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:

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:

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:

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:

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:

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:

// 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
pnpm test

Expected: all tests pass; factories add ~10 new tests.

  • Step 8: Commit
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:

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:

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
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:

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
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
pnpm test

Expected: existing 96 + ~30 new contract assertions pass.

  • Step 8: Commit
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:

{
  "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:

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:

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
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:

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:

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
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
cd packages/core-ui && pnpm test

Expected: all new tests pass.

  • Step 8: Commit
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:

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:

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
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:

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):

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:

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
pnpm test

Expected: existing tests + 6+ new core-* tests pass.

  • Step 8: Commit
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:

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:

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
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:

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
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

pnpm test

Expected: all tests pass.

  • Step 9: Commit
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:

{
  "@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:

{
  "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:

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:

{
  "scripts": {
    "test:stories": "turbo run test:stories"
  }
}
  • Step 4: Add task to root turbo.json

Edit root turbo.json:

{
  "tasks": {
    "test:stories": {
      "dependsOn": ["build-storybook"],
      "cache": false
    },
    "build-storybook": {
      "outputs": ["storybook-static/**"]
    }
  }
}
  • Step 5: Verify the storybook test runner works locally
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
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 namingdescribe(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:

### 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:

> **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:

## 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:

# 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:

| `@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:

## 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

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
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

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%:

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
pnpm test -- --coverage

Expected: all thresholds met.

  • Step 4: Commit
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:

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)

  • 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)
  • No placeholders — every step has concrete code or commands
  • Type consistency — defineFactory, defineContractSuite, renderWithProviders, mockPayloadModule named identically across all tasks
  • 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.