# Pact Consumer CDC — Framework Setup ## Principle When scaffolding a Pact.js consumer contract testing framework, align every artifact — directory layout, vitest config, package.json scripts, shell scripts, CI workflow, and test files — with the canonical `@seontechnologies/pactjs-utils` conventions. Consistency across repositories eliminates onboarding friction and ensures CI pipelines are copy-paste portable. ## Rationale The TEA framework workflow generates scaffolding for consumer-driven contract (CDC) testing. Without opinionated, battle-tested conventions, each project invents its own structure — different script names, different env var patterns, different CI step ordering — making cross-repo maintenance expensive. This fragment codifies the production-proven patterns from the pactjs-utils reference implementation so that every new project starts correctly. ## Pattern Examples ### Example 1: Directory Structure & File Naming **Context**: Consumer contract test project layout using pactjs-utils conventions. **Implementation**: ``` tests/contract/ ├── consumer/ │ ├── get-filter-fields.pacttest.ts # Consumer test (one per endpoint group) │ ├── filter-transactions.pacttest.ts │ └── get-transaction-stats.pacttest.ts └── support/ ├── pact-config.ts # PactV4 factory (consumer/provider names, output dir) ├── provider-states.ts # Provider state factory functions └── consumer-helpers.ts # Local shim (until pactjs-utils is published) scripts/ ├── env-setup.sh # Shared env loader (sourced by all broker scripts) ├── publish-pact.sh # Publish pact files to broker ├── can-i-deploy.sh # Deployment safety check └── record-deployment.sh # Record deployment after merge .github/ ├── actions/ │ └── detect-breaking-change/ │ └── action.yml # PR checkbox-driven breaking change detection └── workflows/ └── contract-test-consumer.yml # Consumer CDC CI workflow ``` **Key Points**: - Consumer tests use `.pacttest.ts` extension (not `.pact.spec.ts` or `.contract.ts`) - Support files live in `tests/contract/support/`, not mixed with consumer tests - Shell scripts live in `scripts/` at project root, not nested inside test directories - CI workflow named `contract-test-consumer.yml` (not `pact-consumer.yml` or other variants) --- ### Example 2: Vitest Configuration for Pact **Context**: Minimal vitest config dedicated to contract tests — do NOT copy settings from the project's main `vitest.config.ts`. **Implementation**: ```typescript // vitest.config.pact.ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', include: ['tests/contract/**/*.pacttest.ts'], testTimeout: 30000, }, }); ``` **Key Points**: - Do NOT add `pool`, `poolOptions`, `setupFiles`, `coverage`, or other settings from the unit test config - Keep it minimal — Pact tests run in Node environment with extended timeout - 30 second timeout accommodates Pact mock server startup and interaction verification - Use a dedicated config file (`vitest.config.pact.ts`), not the main vitest config --- ### Example 3: Package.json Script Naming **Context**: Colon-separated naming matching pactjs-utils exactly. Scripts source `env-setup.sh` inline. **Implementation**: ```json { "scripts": { "test:pact:consumer": "vitest run --config vitest.config.pact.ts", "publish:pact": ". ./scripts/env-setup.sh && ./scripts/publish-pact.sh", "can:i:deploy:consumer": ". ./scripts/env-setup.sh && PACTICIPANT= ./scripts/can-i-deploy.sh", "record:consumer:deployment": ". ./scripts/env-setup.sh && PACTICIPANT= ./scripts/record-deployment.sh" } } ``` Replace `` with the consumer's pacticipant name (e.g., `my-frontend-app`). **Key Points**: - Use colon-separated naming: `test:pact:consumer`, NOT `test:contract` or `test:contract:consumer` - Broker scripts source `env-setup.sh` inline in package.json (`. ./scripts/env-setup.sh && ...`) - `PACTICIPANT` is set per-script invocation, not globally - Do NOT use `npx pact-broker` — use `pact-broker` directly (installed as a dependency) --- ### Example 4: Shell Scripts **Context**: Reusable bash scripts aligned with pactjs-utils conventions. #### `scripts/env-setup.sh` — Shared Environment Loader ```bash #!/bin/bash # -e: exit on error -u: error on undefined vars (catches typos/missing env vars in CI) set -eu if [ -f .env ]; then set -a source .env set +a fi export GITHUB_SHA="${GITHUB_SHA:-$(git rev-parse --short HEAD)}" export GITHUB_BRANCH="${GITHUB_BRANCH:-$(git rev-parse --abbrev-ref HEAD)}" ``` #### `scripts/publish-pact.sh` — Publish Pacts to Broker ```bash #!/bin/bash # Publish generated pact files to PactFlow/Pact Broker # # Requires: PACT_BROKER_BASE_URL, PACT_BROKER_TOKEN, GITHUB_SHA, GITHUB_BRANCH # -e: exit on error -u: error on undefined vars -o pipefail: fail if any pipe segment fails set -euo pipefail . ./scripts/env-setup.sh PACT_DIR="./pacts" pact-broker publish "$PACT_DIR" \ --consumer-app-version="$GITHUB_SHA" \ --branch="$GITHUB_BRANCH" \ --broker-base-url="$PACT_BROKER_BASE_URL" \ --broker-token="$PACT_BROKER_TOKEN" ``` #### `scripts/can-i-deploy.sh` — Deployment Safety Check ```bash #!/bin/bash # Check if a pacticipant version can be safely deployed # # Requires: PACTICIPANT (set by caller), PACT_BROKER_BASE_URL, PACT_BROKER_TOKEN, GITHUB_SHA # -e: exit on error -u: error on undefined vars -o pipefail: fail if any pipe segment fails set -euo pipefail . ./scripts/env-setup.sh PACTICIPANT="${PACTICIPANT:?PACTICIPANT env var is required}" ENVIRONMENT="${ENVIRONMENT:-dev}" pact-broker can-i-deploy \ --pacticipant "$PACTICIPANT" \ --version="$GITHUB_SHA" \ --to-environment "$ENVIRONMENT" \ --retry-while-unknown=10 \ --retry-interval=30 ``` #### `scripts/record-deployment.sh` — Record Deployment ```bash #!/bin/bash # Record a deployment to an environment in Pact Broker # Only records on main/master branch (skips feature branches) # # Requires: PACTICIPANT, PACT_BROKER_BASE_URL, PACT_BROKER_TOKEN, GITHUB_SHA, GITHUB_BRANCH # -e: exit on error -u: error on undefined vars -o pipefail: fail if any pipe segment fails set -euo pipefail . ./scripts/env-setup.sh PACTICIPANT="${PACTICIPANT:?PACTICIPANT env var is required}" if [ "$GITHUB_BRANCH" = "main" ] || [ "$GITHUB_BRANCH" = "master" ]; then pact-broker record-deployment \ --pacticipant "$PACTICIPANT" \ --version "$GITHUB_SHA" \ --environment "${npm_config_env:-dev}" else echo "Skipping record-deployment: not on main branch (current: $GITHUB_BRANCH)" fi ``` **Key Points**: - `env-setup.sh` uses `set -eu` (no pipefail — it only sources `.env`, no pipes); broker scripts use `set -euo pipefail` - Use `pact-broker` directly, NOT `npx pact-broker` - Use `PACTICIPANT` env var (required via `${PACTICIPANT:?...}`), not hardcoded service names - `can-i-deploy` includes `--retry-while-unknown=10 --retry-interval=30` (waits for provider verification) - `record-deployment` has branch guard (only records on main/master) - Do NOT invent custom env vars like `PACT_CONSUMER_VERSION` or `PACT_BREAKING_CHANGE` in scripts — those are handled by `env-setup.sh` and the CI detect-breaking-change action respectively --- ### Example 5: CI Workflow (`contract-test-consumer.yml`) **Context**: GitHub Actions workflow for consumer CDC, matching pactjs-utils structure exactly. **Implementation**: ```yaml name: Contract Test - Consumer on: pull_request: types: [opened, synchronize, reopened, edited] push: branches: [main] env: PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }} PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }} GITHUB_SHA: ${{ github.sha }} GITHUB_BRANCH: ${{ github.head_ref || github.ref_name }} concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: consumer-contract-test: if: github.actor != 'dependabot[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: 'npm' - name: Detect Pact breaking change uses: ./.github/actions/detect-breaking-change - name: Install dependencies run: npm ci # (1) Generate pact files - name: Run consumer contract tests run: npm run test:pact:consumer # (2) Publish pacts to broker - name: Publish pacts to PactFlow run: npm run publish:pact # After publish, PactFlow fires a webhook that triggers # the provider's contract-test-provider.yml workflow. # can-i-deploy retries while waiting for provider verification. # (4) Check deployment safety (main only — on PRs, local verification is the gate) - name: Can I deploy consumer? (main only) if: github.ref == 'refs/heads/main' && env.PACT_BREAKING_CHANGE != 'true' run: npm run can:i:deploy:consumer # (5) Record deployment (main only) - name: Record consumer deployment (main only) if: github.ref == 'refs/heads/main' run: npm run record:consumer:deployment --env=dev ``` **Key Points**: - **Workflow-level `env` block** for broker secrets and git vars — not per-step - **`detect-breaking-change` step** runs before install to set `PACT_BREAKING_CHANGE` env var - **Step numbering skips (3)** — step 3 is the webhook-triggered provider verification (happens externally) - **can-i-deploy condition**: `github.ref == 'refs/heads/main' && env.PACT_BREAKING_CHANGE != 'true'` - **Comment on (4)**: "on PRs, local verification is the gate" - **No upload-artifact step** — the broker is the source of truth for pact files - **`dependabot[bot]` skip** on the job (contract tests don't run for dependency updates) - **PR types include `edited`** — needed for breaking change checkbox detection in PR body - **`GITHUB_BRANCH`** uses `${{ github.head_ref || github.ref_name }}` — `head_ref` for PRs, `ref_name` for pushes --- ### Example 6: Detect Breaking Change Composite Action **Context**: GitHub composite action that reads a `[x] Pact breaking change` checkbox from the PR body. **Implementation**: Create `.github/actions/detect-breaking-change/action.yml`: ```yaml name: 'Detect Pact Breaking Change' description: 'Reads the PR template checkbox to determine if this change is a Pact breaking change. Sets PACT_BREAKING_CHANGE env var.' outputs: is_breaking_change: description: 'Whether the change is a breaking change (true/false)' value: ${{ steps.result.outputs.is_breaking_change }} runs: using: 'composite' steps: # PR event path: read checkbox directly from current PR body. - name: Set PACT_BREAKING_CHANGE from PR description (PR only) if: github.event_name == 'pull_request' uses: actions/github-script@v7 with: script: | const prBody = context.payload.pull_request.body || ''; const breakingChangePattern = /\[\s*[xX]\s*\]\s*Pact breaking change/i; const isBreakingChange = breakingChangePattern.test(prBody); core.exportVariable('PACT_BREAKING_CHANGE', isBreakingChange ? 'true' : 'false'); console.log(`PACT_BREAKING_CHANGE=${isBreakingChange ? 'true' : 'false'} (from PR description checkbox).`); # Push-to-main path: resolve the merged PR and read the same checkbox. - name: Set PACT_BREAKING_CHANGE from merged PR (push to main) if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/github-script@v7 with: script: | const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ owner: context.repo.owner, repo: context.repo.repo, commit_sha: context.sha, }); const merged = prs.find(pr => pr.merged_at); const mergedBody = merged?.body || ''; const breakingChangePattern = /\[\s*[xX]\s*\]\s*Pact breaking change/i; const isBreakingChange = breakingChangePattern.test(mergedBody); core.exportVariable('PACT_BREAKING_CHANGE', isBreakingChange ? 'true' : 'false'); console.log(`PACT_BREAKING_CHANGE=${isBreakingChange ? 'true' : 'false'} (from merged PR lookup).`); - name: Export result id: result shell: bash run: echo "is_breaking_change=${PACT_BREAKING_CHANGE:-false}" >> "$GITHUB_OUTPUT" ``` **Key Points**: - Two separate conditional steps (better CI log readability than single if/else) - PR path: reads checkbox directly from PR body - Push-to-main path: resolves merged PR via GitHub API, reads same checkbox - Exports `PACT_BREAKING_CHANGE` env var for downstream steps - `outputs.is_breaking_change` available for consuming workflows - Uses a case-insensitive checkbox regex (`/\[\s*[xX]\s*\]\s*Pact breaking change/i`) to detect checked states robustly --- ### Example 7: Consumer Test Using PactV4 Builder **Context**: Consumer pact test using PactV4 `addInteraction()` builder pattern. The test MUST call **real consumer code** (your actual API client/service functions) against the mock server — not raw `fetch()`. Using `fetch()` directly defeats the purpose of CDC testing because it doesn't verify your actual consumer code works with the contract. **Implementation**: The consumer code must expose a way to inject the base URL (e.g., `setApiUrl()`, constructor parameter, or environment variable). This is a prerequisite for contract testing. ```typescript // src/api/movie-client.ts — The REAL consumer code (already exists in your project) import axios from 'axios'; const axiosInstance = axios.create({ baseURL: process.env.API_URL || 'http://localhost:3001', }); // Expose a way to override the base URL for Pact testing export const setApiUrl = (url: string) => { axiosInstance.defaults.baseURL = url; }; export const getMovies = async () => { const res = await axiosInstance.get('/movies'); return res.data; }; export const getMovieById = async (id: number) => { const res = await axiosInstance.get(`/movies/${id}`); return res.data; }; ``` ```typescript // tests/contract/consumer/get-movies.pacttest.ts import { MatchersV3 } from '@pact-foundation/pact'; import type { V3MockServer } from '@pact-foundation/pact'; import { createProviderState, setJsonBody, setJsonContent } from '../support/consumer-helpers'; import { movieExists } from '../support/provider-states'; import { createPact } from '../support/pact-config'; // Import REAL consumer code — this is what we're actually testing import { getMovies, getMovieById, setApiUrl } from '../../../src/api/movie-client'; const { like, integer, string } = MatchersV3; const pact = createPact(); describe('Movies API Consumer Contract', () => { const movieWithId = { id: 1, name: 'The Matrix', year: 1999, rating: 8.7, director: 'Wachowskis' }; it('should get a movie by ID', async () => { const [stateName, stateParams] = createProviderState(movieExists(movieWithId)); await pact .addInteraction() .given(stateName, stateParams) .uponReceiving('a request to get movie by ID') .withRequest( 'GET', '/movies/1', setJsonContent({ headers: { Accept: 'application/json' }, }), ) .willRespondWith( 200, setJsonBody( like({ id: integer(1), name: string('The Matrix'), year: integer(1999), rating: like(8.7), director: string('Wachowskis'), }), ), ) .executeTest(async (mockServer: V3MockServer) => { // Inject mock server URL into the REAL consumer code setApiUrl(mockServer.url); // Call the REAL consumer function — this is what CDC testing validates const movie = await getMovieById(1); expect(movie.id).toBe(1); expect(movie.name).toBe('The Matrix'); }); }); it('should handle movie not found', async () => { await pact .addInteraction() .given('No movies exist') .uponReceiving('a request for a non-existent movie') .withRequest('GET', '/movies/999') .willRespondWith(404, setJsonBody({ error: 'Movie not found' })) .executeTest(async (mockServer: V3MockServer) => { setApiUrl(mockServer.url); await expect(getMovieById(999)).rejects.toThrow(); }); }); }); ``` **Key Points**: - **CRITICAL**: Always test your REAL consumer code — import and call actual API client functions, never raw `fetch()` - Using `fetch()` directly only tests that Pact's mock server works, which is meaningless - Consumer code MUST expose a URL injection mechanism: `setApiUrl()`, env var override, or constructor parameter - If the consumer code doesn't support URL injection, add it — this is a design prerequisite for CDC testing - Use PactV4 `addInteraction()` builder (not PactV3 fluent API with `withRequest({...})` object) - **Interaction naming convention**: Use the pattern `"a request to []"` for `uponReceiving()`. Examples: `"a request to get a movie by ID"`, `"a request to delete a non-existing movie"`, `"a request to create a movie that already exists"`. These names appear in Pact Broker UI and verification logs — keep them descriptive and unique within the consumer-provider pair. - Use `setJsonContent` for request/response builder callbacks with query/header/body concerns; use `setJsonBody` for body-only response callbacks - Provider state factory functions (`movieExists`) return `ProviderStateInput` objects - `createProviderState` converts to `[stateName, stateParams]` tuple for `.given()` **Common URL injection patterns** (pick whichever fits your consumer architecture): | Pattern | Example | Best For | | -------------------- | -------------------------------------------- | --------------------- | | `setApiUrl(url)` | Mutates axios instance `baseURL` | Singleton HTTP client | | Constructor param | `new ApiClient({ baseUrl: mockServer.url })` | Class-based clients | | Environment variable | `process.env.API_URL = mockServer.url` | Config-driven apps | | Factory function | `createApi({ baseUrl: mockServer.url })` | Functional patterns | --- ### Example 8: Support Files #### Pact Config Factory ```typescript // tests/contract/support/pact-config.ts import path from 'node:path'; import { PactV4 } from '@pact-foundation/pact'; export const createPact = (overrides?: { consumer?: string; provider?: string }) => new PactV4({ dir: path.resolve(process.cwd(), 'pacts'), consumer: overrides?.consumer ?? 'MyConsumerApp', provider: overrides?.provider ?? 'MyProviderAPI', logLevel: 'warn', }); ``` #### Provider State Factories ```typescript // tests/contract/support/provider-states.ts import type { ProviderStateInput } from './consumer-helpers'; export const movieExists = (movie: { id: number; name: string; year: number; rating: number; director: string }): ProviderStateInput => ({ name: 'An existing movie exists', params: movie, }); export const hasMovieWithId = (id: number): ProviderStateInput => ({ name: 'Has a movie with a specific ID', params: { id }, }); ``` #### Local Consumer Helpers Shim ```typescript // tests/contract/support/consumer-helpers.ts // TODO(temporary scaffolding): Replace local TemplateHeaders/TemplateQuery types // with '@seontechnologies/pactjs-utils' exports when available. type TemplateHeaders = Record; type TemplateQueryValue = string | number | boolean | Array; type TemplateQuery = Record; export type ProviderStateInput = { name: string; params: Record; }; type JsonMap = { [key: string]: boolean | number | string | null | JsonMap | Array }; type JsonContentBuilder = { headers: (headers: TemplateHeaders) => unknown; jsonBody: (body: unknown) => unknown; query?: (query: TemplateQuery) => unknown; }; export type JsonContentInput = { body?: unknown; headers?: TemplateHeaders; query?: TemplateQuery; }; export const toJsonMap = (obj: Record): JsonMap => Object.fromEntries( Object.entries(obj).map(([key, value]) => { if (value === null || value === undefined) return [key, 'null']; if (typeof value === 'object' && !(value instanceof Date) && !Array.isArray(value)) return [key, JSON.stringify(value)]; if (typeof value === 'number' || typeof value === 'boolean') return [key, value]; if (value instanceof Date) return [key, value.toISOString()]; return [key, String(value)]; }), ); export const createProviderState = ({ name, params }: ProviderStateInput): [string, JsonMap] => [name, toJsonMap(params)]; export const setJsonContent = ({ body, headers, query }: JsonContentInput) => (builder: JsonContentBuilder): void => { if (query && builder.query) { builder.query(query); } if (headers) { builder.headers(headers); } if (body !== undefined) { builder.jsonBody(body); } }; export const setJsonBody = (body: unknown) => setJsonContent({ body }); ``` **Key Points**: - If `@seontechnologies/pactjs-utils` is not yet installed, create a local shim that mirrors the API - Add a TODO comment noting to swap for the published package when available - The shim exports `createProviderState`, `toJsonMap`, `setJsonContent`, `setJsonBody`, and helper input types - Keep shim types local (or sourced from public exports only); do not import from internal Pact paths like `@pact-foundation/pact/src/*` --- ### Example 9: .gitignore Entries **Context**: Pact-specific entries to add to `.gitignore`. ``` # Pact contract testing artifacts /pacts/ pact-logs/ ``` --- ## Validation Checklist Before presenting the consumer CDC framework to the user, verify: - [ ] `vitest.config.pact.ts` is minimal (no pool/coverage/setup copied from unit config) - [ ] Script names match pactjs-utils (`test:pact:consumer`, `publish:pact`, `can:i:deploy:consumer`, `record:consumer:deployment`) - [ ] Scripts source `env-setup.sh` inline in package.json - [ ] Shell scripts use `pact-broker` not `npx pact-broker` - [ ] Shell scripts use `PACTICIPANT` env var pattern - [ ] `can-i-deploy.sh` has `--retry-while-unknown=10 --retry-interval=30` - [ ] `record-deployment.sh` has branch guard - [ ] `env-setup.sh` uses `set -eu`; broker scripts use `set -euo pipefail` — each with explanatory comment - [ ] CI workflow named `contract-test-consumer.yml` - [ ] CI has workflow-level env block (not per-step) - [ ] CI has `detect-breaking-change` step before install - [ ] CI step numbering skips (3) — webhook-triggered provider verification - [ ] CI can-i-deploy has `PACT_BREAKING_CHANGE != 'true'` condition - [ ] CI has NO upload-artifact step - [ ] `.github/actions/detect-breaking-change/action.yml` exists - [ ] Consumer tests use `.pacttest.ts` extension - [ ] Consumer tests use PactV4 `addInteraction()` builder - [ ] `uponReceiving()` names follow `"a request to []"` pattern and are unique within the consumer-provider pair - [ ] Interaction callbacks use `setJsonContent` for query/header/body and `setJsonBody` for body-only responses - [ ] Request bodies use exact values (no `like()` wrapper) — Postel's Law: be strict in what you send - [ ] `like()`, `eachLike()`, `string()`, `integer()` matchers are only used in `willRespondWith` (responses), not in `withRequest` (requests) — matchers check type/shape, not exact values - [ ] Consumer tests call REAL consumer code (actual API client functions), NOT raw `fetch()` - [ ] Consumer code exposes URL injection mechanism (`setApiUrl()`, env var, or constructor param) - [ ] Local consumer-helpers shim present if pactjs-utils not installed - [ ] `.gitignore` includes `/pacts/` and `pact-logs/` ## Related Fragments - `pactjs-utils-overview.md` — Library decision tree and installation - `pactjs-utils-consumer-helpers.md` — `createProviderState`, `toJsonMap`, `setJsonContent`, and `setJsonBody` API details - `pactjs-utils-provider-verifier.md` — Provider-side verification patterns - `pactjs-utils-request-filter.md` — Auth injection for provider verification - `contract-testing.md` — Foundational CDC patterns and resilience coverage