Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

pact-consumer-framework-setup.md 35KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757
  1. # Pact Consumer CDC — Framework Setup
  2. ## Principle
  3. 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.
  4. ## Rationale
  5. 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.
  6. ## Pattern Examples
  7. ### Example 1: Directory Structure & File Naming
  8. **Context**: Consumer contract test project layout using pactjs-utils conventions.
  9. **Implementation**:
  10. ```
  11. tests/contract/
  12. ├── consumer/
  13. │ ├── get-filter-fields.pacttest.ts # Consumer test (one per endpoint group)
  14. │ ├── filter-transactions.pacttest.ts
  15. │ └── get-transaction-stats.pacttest.ts
  16. └── support/
  17. ├── pact-config.ts # PactV4 factory (consumer/provider names, output dir)
  18. ├── provider-states.ts # Provider state factory functions
  19. └── consumer-helpers.ts # Local shim (until pactjs-utils is published)
  20. scripts/
  21. ├── env-setup.sh # Shared env loader (sourced by all broker scripts)
  22. ├── publish-pact.sh # Publish pact files to broker
  23. ├── can-i-deploy.sh # Deployment safety check
  24. └── record-deployment.sh # Record deployment after merge
  25. .github/
  26. ├── actions/
  27. │ └── detect-breaking-change/
  28. │ └── action.yml # PR checkbox-driven breaking change detection
  29. └── workflows/
  30. └── contract-test-consumer.yml # Consumer CDC CI workflow
  31. ```
  32. **Key Points**:
  33. - Consumer tests use `.pacttest.ts` extension (not `.pact.spec.ts` or `.contract.ts`)
  34. - Support files live in `tests/contract/support/`, not mixed with consumer tests
  35. - Shell scripts live in `scripts/` at project root, not nested inside test directories
  36. - CI workflow named `contract-test-consumer.yml` (not `pact-consumer.yml` or other variants)
  37. ---
  38. ### Example 2: Vitest Configuration for Pact
  39. **Context**: Minimal vitest config dedicated to contract tests — do NOT copy settings from the project's main `vitest.config.ts`.
  40. **Implementation**:
  41. ```typescript
  42. // vitest.config.pact.ts
  43. // See pact-consumer-framework-setup.md Example 2 "Key Points" for rationale on
  44. // fileParallelism + pool:forks + singleFork. Do not remove those three settings.
  45. import { defineConfig } from 'vitest/config';
  46. export default defineConfig({
  47. test: {
  48. environment: 'node',
  49. include: ['tests/contract/**/*.pacttest.ts'],
  50. testTimeout: 30000,
  51. fileParallelism: false,
  52. pool: 'forks',
  53. poolOptions: { forks: { singleFork: true } },
  54. },
  55. });
  56. ```
  57. **Key Points**:
  58. - **`fileParallelism: false` is required** — primary defense against non-deterministic pact generation. Without it, parallel workers race on the shared pact JSON file and corrupt interactions. Symptom: local runs pass, CI randomly fails with `Cannot change pact content for already published pact`. See Example 10 for the determinism gate that enforces byte-stability across re-runs.
  59. - **`pool: 'forks'` + `singleFork: true` is required for multi-file consumer suites** — same config the provider side uses (`pactjs-utils-provider-verifier.md` Example 7). Best current understanding: the `@pact-foundation/pact` napi-rs binding is not robust across Vitest worker threads sharing a process; with the default threads pool (Vitest v1) and multiple `.pacttest.ts` files on the same consumer+provider pair, we observed reproducible "request was expected but not received" flakes on Linux CI only. `singleFork: true` serializes every pact file into one forked subprocess and eliminated the flake on two repos (`pactjs-utils`, `seon-mcp-server`). Vitest v2+ defaults to `forks`, but set the pool explicitly so the contract does not drift with Vitest version bumps.
  60. - **Single-file consumer suites** (one `.pacttest.ts` per consumer+provider pair) have not been observed to flake under default threads pool, because FFI state is not shared across files when there is only one file. Adding `pool: 'forks'` is still recommended — it future-proofs you the moment a second file is added — but a suite passing today with only `fileParallelism: false` is not broken.
  61. - **Interacting settings**: leave `isolate` at its default (`true`). Do NOT set `sequence.concurrent: true`, `maxConcurrency > 1`, or `maxWorkers > 1` in this config — they defeat the serialization this rule relies on. `hookTimeout` may be raised if mock-server startup is slow, but keep `testTimeout` ≥ `hookTimeout`.
  62. - Do NOT add `setupFiles`, `coverage`, or other settings from the unit test config
  63. - Keep it minimal — Pact tests run in Node environment with extended timeout
  64. - 30 second timeout accommodates Pact mock server startup and interaction verification
  65. - Use a dedicated config file (`vitest.config.pact.ts`), not the main vitest config
  66. ---
  67. ### Example 3: Package.json Script Naming
  68. **Context**: Colon-separated naming matching pactjs-utils exactly. Scripts source `env-setup.sh` inline.
  69. **Implementation**:
  70. ```json
  71. {
  72. "scripts": {
  73. "test:pact:consumer": "./scripts/check-pact-determinism.sh 'npm run test:pact:consumer:run' 3 ./pacts",
  74. "test:pact:consumer:run": "vitest run --config vitest.config.pact.ts",
  75. "publish:pact": ". ./scripts/env-setup.sh && ./scripts/publish-pact.sh",
  76. "can:i:deploy:consumer": ". ./scripts/env-setup.sh && PACTICIPANT=<service-name> ./scripts/can-i-deploy.sh",
  77. "record:consumer:deployment": ". ./scripts/env-setup.sh && PACTICIPANT=<service-name> ./scripts/record-deployment.sh"
  78. }
  79. }
  80. ```
  81. Replace `<service-name>` with the consumer's pacticipant name (e.g., `my-frontend-app`).
  82. **Key Points**:
  83. - **`test:pact:consumer` IS the determinism gate** — it runs the inner command 3× and fails if pact output is not byte-stable. This is the command CI and developers run before pushing. See Example 10 for the `check-pact-determinism.sh` script itself.
  84. - **`test:pact:consumer:run` is the fast inner command** for TDD loops (a single pass of the suite, no gate). Developers can iterate with this; CI always goes through the outer gated script.
  85. - Use colon-separated naming: `test:pact:consumer`, NOT `test:contract` or `test:contract:consumer`
  86. - Broker scripts source `env-setup.sh` inline in package.json (`. ./scripts/env-setup.sh && ...`)
  87. - `PACTICIPANT` is set per-script invocation, not globally
  88. - Do NOT use `npx pact-broker` — use `pact-broker` directly (installed as a dependency)
  89. ---
  90. ### Example 4: Shell Scripts
  91. **Context**: Reusable bash scripts aligned with pactjs-utils conventions.
  92. #### `scripts/env-setup.sh` — Shared Environment Loader
  93. ```bash
  94. #!/bin/bash
  95. # -e: exit on error -u: error on undefined vars (catches typos/missing env vars in CI)
  96. set -eu
  97. if [ -f .env ]; then
  98. set -a
  99. source .env
  100. set +a
  101. fi
  102. export GITHUB_SHA="${GITHUB_SHA:-$(git rev-parse --short HEAD)}"
  103. export GITHUB_BRANCH="${GITHUB_BRANCH:-$(git rev-parse --abbrev-ref HEAD)}"
  104. ```
  105. #### `scripts/publish-pact.sh` — Publish Pacts to Broker (with defense-in-depth normalization)
  106. ```bash
  107. #!/bin/bash
  108. # Publish generated pact files to PactFlow/Pact Broker.
  109. #
  110. # Before publish, normalize each pact JSON: sort interactions by (description, provider state name,
  111. # method, path) and sort object keys via `jq -S`. This gives byte-stable output to the broker even
  112. # if the PactV4 generator produces ordering drift between runs. Paired with scripts/check-pact-determinism.sh
  113. # as defense-in-depth — the gate catches drift pre-publish; normalization ensures "Cannot change pact
  114. # content" from PactFlow never fires on ordering-only changes that slip past the gate.
  115. #
  116. # Requires: PACT_BROKER_BASE_URL, PACT_BROKER_TOKEN, GITHUB_SHA, GITHUB_BRANCH, jq
  117. # -e: exit on error -u: error on undefined vars -o pipefail: fail if any pipe segment fails
  118. set -euo pipefail
  119. . ./scripts/env-setup.sh
  120. PACT_DIR="./pacts"
  121. # Defense-in-depth: normalize interaction order for byte-stable publishes.
  122. for f in "$PACT_DIR"/*.json; do
  123. tmp="$(mktemp)"
  124. jq -S '.interactions |= sort_by(.description, (.providerStates[0].name // ""), .request.method, .request.path)' \
  125. "$f" > "$tmp"
  126. mv "$tmp" "$f"
  127. done
  128. pact-broker publish "$PACT_DIR" \
  129. --consumer-app-version="$GITHUB_SHA" \
  130. --branch="$GITHUB_BRANCH" \
  131. --broker-base-url="$PACT_BROKER_BASE_URL" \
  132. --broker-token="$PACT_BROKER_TOKEN"
  133. ```
  134. #### `scripts/can-i-deploy.sh` — Deployment Safety Check
  135. ```bash
  136. #!/bin/bash
  137. # Check if a pacticipant version can be safely deployed
  138. #
  139. # Requires: PACTICIPANT (set by caller), PACT_BROKER_BASE_URL, PACT_BROKER_TOKEN, GITHUB_SHA
  140. # -e: exit on error -u: error on undefined vars -o pipefail: fail if any pipe segment fails
  141. set -euo pipefail
  142. . ./scripts/env-setup.sh
  143. PACTICIPANT="${PACTICIPANT:?PACTICIPANT env var is required}"
  144. ENVIRONMENT="${ENVIRONMENT:-dev}"
  145. pact-broker can-i-deploy \
  146. --pacticipant "$PACTICIPANT" \
  147. --version="$GITHUB_SHA" \
  148. --to-environment "$ENVIRONMENT" \
  149. --retry-while-unknown=10 \
  150. --retry-interval=30
  151. ```
  152. #### `scripts/record-deployment.sh` — Record Deployment
  153. ```bash
  154. #!/bin/bash
  155. # Record a deployment to an environment in Pact Broker
  156. # Only records on main/master branch (skips feature branches)
  157. #
  158. # Requires: PACTICIPANT, PACT_BROKER_BASE_URL, PACT_BROKER_TOKEN, GITHUB_SHA, GITHUB_BRANCH
  159. # -e: exit on error -u: error on undefined vars -o pipefail: fail if any pipe segment fails
  160. set -euo pipefail
  161. . ./scripts/env-setup.sh
  162. PACTICIPANT="${PACTICIPANT:?PACTICIPANT env var is required}"
  163. if [ "$GITHUB_BRANCH" = "main" ] || [ "$GITHUB_BRANCH" = "master" ]; then
  164. pact-broker record-deployment \
  165. --pacticipant "$PACTICIPANT" \
  166. --version "$GITHUB_SHA" \
  167. --environment "${npm_config_env:-dev}"
  168. else
  169. echo "Skipping record-deployment: not on main branch (current: $GITHUB_BRANCH)"
  170. fi
  171. ```
  172. **Key Points**:
  173. - `env-setup.sh` uses `set -eu` (no pipefail — it only sources `.env`, no pipes); broker scripts use `set -euo pipefail`
  174. - Use `pact-broker` directly, NOT `npx pact-broker`
  175. - Use `PACTICIPANT` env var (required via `${PACTICIPANT:?...}`), not hardcoded service names
  176. - `can-i-deploy` includes `--retry-while-unknown=10 --retry-interval=30` (waits for provider verification)
  177. - `record-deployment` has branch guard (only records on main/master)
  178. - **`publish-pact.sh` normalizes interactions with `jq -S` + `sort_by(...)` before publishing** — defense-in-depth alongside the determinism gate (Example 10). The gate catches drift; normalization ensures byte-stable payload to the broker regardless of generator quirks. Keep both; they protect against different failure modes.
  179. - 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
  180. ---
  181. ### Example 5: CI Workflow (`contract-test-consumer.yml`)
  182. **Context**: GitHub Actions workflow for consumer CDC, matching pactjs-utils structure exactly.
  183. **Implementation**:
  184. ```yaml
  185. name: Contract Test - Consumer
  186. on:
  187. pull_request:
  188. types: [opened, synchronize, reopened, edited]
  189. push:
  190. branches: [main]
  191. env:
  192. PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
  193. PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
  194. GITHUB_SHA: ${{ github.sha }}
  195. GITHUB_BRANCH: ${{ github.head_ref || github.ref_name }}
  196. concurrency:
  197. group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
  198. cancel-in-progress: true
  199. jobs:
  200. consumer-contract-test:
  201. if: github.actor != 'dependabot[bot]'
  202. runs-on: ubuntu-latest
  203. steps:
  204. - uses: actions/checkout@v6
  205. - uses: actions/setup-node@v6
  206. with:
  207. node-version-file: '.nvmrc'
  208. cache: 'npm'
  209. - name: Detect Pact breaking change
  210. uses: ./.github/actions/detect-breaking-change
  211. - name: Install dependencies
  212. run: npm ci
  213. # (1) Generate pact files — runs the determinism gate (3 runs + byte-stable check via jq)
  214. - name: Consumer pact tests (determinism gate)
  215. run: npm run test:pact:consumer
  216. # (2) Publish pacts to broker (publish-pact.sh also normalizes interaction order as defense-in-depth)
  217. - name: Publish pacts to PactFlow
  218. run: npm run publish:pact
  219. # After publish, PactFlow fires a webhook that triggers
  220. # the provider's contract-test-provider.yml workflow.
  221. # can-i-deploy retries while waiting for provider verification.
  222. # (4) Check deployment safety (main only — on PRs, local verification is the gate)
  223. - name: Can I deploy consumer? (main only)
  224. if: github.ref == 'refs/heads/main' && env.PACT_BREAKING_CHANGE != 'true'
  225. run: npm run can:i:deploy:consumer
  226. # (5) Record deployment (main only)
  227. - name: Record consumer deployment (main only)
  228. if: github.ref == 'refs/heads/main'
  229. run: npm run record:consumer:deployment --env=dev
  230. ```
  231. **Key Points**:
  232. - **1:1 local/CI parity is a hard rule**: every CI step is `npm run <same-name-a-dev-uses>`. Never let CI invoke `vitest` or `pact-broker` directly — that divergence is how "works on my machine" slips in. The determinism gate, publish, can-i-deploy, and record-deployment are all the same commands a developer runs locally.
  233. - **The determinism gate is its own visible step, not a side-effect of publish.** A failing gate must be debuggable from the CI log without re-running. Do not fold it into a `prepublish:pact` hook — folding hides the failure inside a publish log and makes attribution harder.
  234. - **Workflow-level `env` block** for broker secrets and git vars — not per-step
  235. - **`detect-breaking-change` step** runs before install to set `PACT_BREAKING_CHANGE` env var
  236. - **Step numbering skips (3)** — step 3 is the webhook-triggered provider verification (happens externally)
  237. - **can-i-deploy condition**: `github.ref == 'refs/heads/main' && env.PACT_BREAKING_CHANGE != 'true'`
  238. - **Comment on (4)**: "on PRs, local verification is the gate"
  239. - **No upload-artifact step** — the broker is the source of truth for pact files
  240. - **`dependabot[bot]` skip** on the job (contract tests don't run for dependency updates)
  241. - **PR types include `edited`** — needed for breaking change checkbox detection in PR body
  242. - **`GITHUB_BRANCH`** uses `${{ github.head_ref || github.ref_name }}` — `head_ref` for PRs, `ref_name` for pushes
  243. ---
  244. ### Example 6: Detect Breaking Change Composite Action
  245. **Context**: GitHub composite action that reads a `[x] Pact breaking change` checkbox from the PR body.
  246. **Implementation**:
  247. Create `.github/actions/detect-breaking-change/action.yml`:
  248. ```yaml
  249. name: 'Detect Pact Breaking Change'
  250. description: 'Reads the PR template checkbox to determine if this change is a Pact breaking change. Sets PACT_BREAKING_CHANGE env var.'
  251. outputs:
  252. is_breaking_change:
  253. description: 'Whether the change is a breaking change (true/false)'
  254. value: ${{ steps.result.outputs.is_breaking_change }}
  255. runs:
  256. using: 'composite'
  257. steps:
  258. # PR event path: read checkbox directly from current PR body.
  259. - name: Set PACT_BREAKING_CHANGE from PR description (PR only)
  260. if: github.event_name == 'pull_request'
  261. uses: actions/github-script@v7
  262. with:
  263. script: |
  264. const prBody = context.payload.pull_request.body || '';
  265. const breakingChangePattern = /\[\s*[xX]\s*\]\s*Pact breaking change/i;
  266. const isBreakingChange = breakingChangePattern.test(prBody);
  267. core.exportVariable('PACT_BREAKING_CHANGE', isBreakingChange ? 'true' : 'false');
  268. console.log(`PACT_BREAKING_CHANGE=${isBreakingChange ? 'true' : 'false'} (from PR description checkbox).`);
  269. # Push-to-main path: resolve the merged PR and read the same checkbox.
  270. - name: Set PACT_BREAKING_CHANGE from merged PR (push to main)
  271. if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  272. uses: actions/github-script@v7
  273. with:
  274. script: |
  275. const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
  276. owner: context.repo.owner,
  277. repo: context.repo.repo,
  278. commit_sha: context.sha,
  279. });
  280. const merged = prs.find(pr => pr.merged_at);
  281. const mergedBody = merged?.body || '';
  282. const breakingChangePattern = /\[\s*[xX]\s*\]\s*Pact breaking change/i;
  283. const isBreakingChange = breakingChangePattern.test(mergedBody);
  284. core.exportVariable('PACT_BREAKING_CHANGE', isBreakingChange ? 'true' : 'false');
  285. console.log(`PACT_BREAKING_CHANGE=${isBreakingChange ? 'true' : 'false'} (from merged PR lookup).`);
  286. - name: Export result
  287. id: result
  288. shell: bash
  289. run: echo "is_breaking_change=${PACT_BREAKING_CHANGE:-false}" >> "$GITHUB_OUTPUT"
  290. ```
  291. **Key Points**:
  292. - Two separate conditional steps (better CI log readability than single if/else)
  293. - PR path: reads checkbox directly from PR body
  294. - Push-to-main path: resolves merged PR via GitHub API, reads same checkbox
  295. - Exports `PACT_BREAKING_CHANGE` env var for downstream steps
  296. - `outputs.is_breaking_change` available for consuming workflows
  297. - Uses a case-insensitive checkbox regex (`/\[\s*[xX]\s*\]\s*Pact breaking change/i`) to detect checked states robustly
  298. ---
  299. ### Example 7: Consumer Test Using PactV4 Builder
  300. **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.
  301. **Implementation**:
  302. 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.
  303. ```typescript
  304. // src/api/movie-client.ts — The REAL consumer code (already exists in your project)
  305. import axios from 'axios';
  306. const axiosInstance = axios.create({
  307. baseURL: process.env.API_URL || 'http://localhost:3001',
  308. });
  309. // Expose a way to override the base URL for Pact testing
  310. export const setApiUrl = (url: string) => {
  311. axiosInstance.defaults.baseURL = url;
  312. };
  313. export const getMovies = async () => {
  314. const res = await axiosInstance.get('/movies');
  315. return res.data;
  316. };
  317. export const getMovieById = async (id: number) => {
  318. const res = await axiosInstance.get(`/movies/${id}`);
  319. return res.data;
  320. };
  321. ```
  322. ```typescript
  323. // tests/contract/consumer/get-movies.pacttest.ts
  324. import { MatchersV3 } from '@pact-foundation/pact';
  325. import type { V3MockServer } from '@pact-foundation/pact';
  326. import { createProviderState, setJsonBody, setJsonContent } from '../support/consumer-helpers';
  327. import { movieExists } from '../support/provider-states';
  328. import { createPact } from '../support/pact-config';
  329. // Import REAL consumer code — this is what we're actually testing
  330. import { getMovies, getMovieById, setApiUrl } from '../../../src/api/movie-client';
  331. const { like, integer, string } = MatchersV3;
  332. const pact = createPact();
  333. describe('Movies API Consumer Contract', () => {
  334. const movieWithId = { id: 1, name: 'The Matrix', year: 1999, rating: 8.7, director: 'Wachowskis' };
  335. it('should get a movie by ID', async () => {
  336. const [stateName, stateParams] = createProviderState(movieExists(movieWithId));
  337. await pact
  338. .addInteraction()
  339. .given(stateName, stateParams)
  340. .uponReceiving('a request to get movie by ID')
  341. .withRequest(
  342. 'GET',
  343. '/movies/1',
  344. setJsonContent({
  345. headers: { Accept: 'application/json' },
  346. }),
  347. )
  348. .willRespondWith(
  349. 200,
  350. setJsonBody(
  351. like({
  352. id: integer(1),
  353. name: string('The Matrix'),
  354. year: integer(1999),
  355. rating: like(8.7),
  356. director: string('Wachowskis'),
  357. }),
  358. ),
  359. )
  360. .executeTest(async (mockServer: V3MockServer) => {
  361. // Inject mock server URL into the REAL consumer code
  362. setApiUrl(mockServer.url);
  363. // Call the REAL consumer function — this is what CDC testing validates
  364. const movie = await getMovieById(1);
  365. expect(movie.id).toBe(1);
  366. expect(movie.name).toBe('The Matrix');
  367. });
  368. });
  369. it('should handle movie not found', async () => {
  370. await pact
  371. .addInteraction()
  372. .given('No movies exist')
  373. .uponReceiving('a request for a non-existent movie')
  374. .withRequest('GET', '/movies/999')
  375. .willRespondWith(404, setJsonBody({ error: 'Movie not found' }))
  376. .executeTest(async (mockServer: V3MockServer) => {
  377. setApiUrl(mockServer.url);
  378. await expect(getMovieById(999)).rejects.toThrow();
  379. });
  380. });
  381. });
  382. ```
  383. **Key Points**:
  384. - **CRITICAL**: Always test your REAL consumer code — import and call actual API client functions, never raw `fetch()`
  385. - Using `fetch()` directly only tests that Pact's mock server works, which is meaningless
  386. - Consumer code MUST expose a URL injection mechanism: `setApiUrl()`, env var override, or constructor parameter
  387. - If the consumer code doesn't support URL injection, add it — this is a design prerequisite for CDC testing
  388. - Use PactV4 `addInteraction()` builder (not PactV3 fluent API with `withRequest({...})` object)
  389. - **Interaction naming convention**: Use the pattern `"a request to <action> <resource> [<condition>]"` 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.
  390. - Use `setJsonContent` for request/response builder callbacks with query/header/body concerns; use `setJsonBody` for body-only response callbacks
  391. - Provider state factory functions (`movieExists`) return `ProviderStateInput` objects
  392. - `createProviderState` converts to `[stateName, stateParams]` tuple for `.given()`
  393. **Common URL injection patterns** (pick whichever fits your consumer architecture):
  394. | Pattern | Example | Best For |
  395. | -------------------- | -------------------------------------------- | --------------------- |
  396. | `setApiUrl(url)` | Mutates axios instance `baseURL` | Singleton HTTP client |
  397. | Constructor param | `new ApiClient({ baseUrl: mockServer.url })` | Class-based clients |
  398. | Environment variable | `process.env.API_URL = mockServer.url` | Config-driven apps |
  399. | Factory function | `createApi({ baseUrl: mockServer.url })` | Functional patterns |
  400. ---
  401. ### Example 8: Support Files
  402. #### Pact Config Factory
  403. ```typescript
  404. // tests/contract/support/pact-config.ts
  405. import path from 'node:path';
  406. import { PactV4 } from '@pact-foundation/pact';
  407. export const createPact = (overrides?: { consumer?: string; provider?: string }) =>
  408. new PactV4({
  409. dir: path.resolve(process.cwd(), 'pacts'),
  410. consumer: overrides?.consumer ?? 'MyConsumerApp',
  411. provider: overrides?.provider ?? 'MyProviderAPI',
  412. logLevel: 'warn',
  413. });
  414. ```
  415. #### Provider State Factories
  416. ```typescript
  417. // tests/contract/support/provider-states.ts
  418. import type { ProviderStateInput } from './consumer-helpers';
  419. export const movieExists = (movie: { id: number; name: string; year: number; rating: number; director: string }): ProviderStateInput => ({
  420. name: 'An existing movie exists',
  421. params: movie,
  422. });
  423. export const hasMovieWithId = (id: number): ProviderStateInput => ({
  424. name: 'Has a movie with a specific ID',
  425. params: { id },
  426. });
  427. ```
  428. #### Local Consumer Helpers Shim
  429. ```typescript
  430. // tests/contract/support/consumer-helpers.ts
  431. // TODO(temporary scaffolding): Replace local TemplateHeaders/TemplateQuery types
  432. // with '@seontechnologies/pactjs-utils' exports when available.
  433. type TemplateHeaders = Record<string, string | number | boolean>;
  434. type TemplateQueryValue = string | number | boolean | Array<string | number | boolean>;
  435. type TemplateQuery = Record<string, TemplateQueryValue>;
  436. export type ProviderStateInput = {
  437. name: string;
  438. params: Record<string, unknown>;
  439. };
  440. type JsonMap = { [key: string]: boolean | number | string | null | JsonMap | Array<unknown> };
  441. type JsonContentBuilder = {
  442. headers: (headers: TemplateHeaders) => unknown;
  443. jsonBody: (body: unknown) => unknown;
  444. query?: (query: TemplateQuery) => unknown;
  445. };
  446. export type JsonContentInput = {
  447. body?: unknown;
  448. headers?: TemplateHeaders;
  449. query?: TemplateQuery;
  450. };
  451. export const toJsonMap = (obj: Record<string, unknown>): JsonMap =>
  452. Object.fromEntries(
  453. Object.entries(obj).map(([key, value]) => {
  454. if (value === null || value === undefined) return [key, 'null'];
  455. if (typeof value === 'object' && !(value instanceof Date) && !Array.isArray(value)) return [key, JSON.stringify(value)];
  456. if (typeof value === 'number' || typeof value === 'boolean') return [key, value];
  457. if (value instanceof Date) return [key, value.toISOString()];
  458. return [key, String(value)];
  459. }),
  460. );
  461. export const createProviderState = ({ name, params }: ProviderStateInput): [string, JsonMap] => [name, toJsonMap(params)];
  462. export const setJsonContent =
  463. ({ body, headers, query }: JsonContentInput) =>
  464. (builder: JsonContentBuilder): void => {
  465. if (query && builder.query) {
  466. builder.query(query);
  467. }
  468. if (headers) {
  469. builder.headers(headers);
  470. }
  471. if (body !== undefined) {
  472. builder.jsonBody(body);
  473. }
  474. };
  475. export const setJsonBody = (body: unknown) => setJsonContent({ body });
  476. ```
  477. **Key Points**:
  478. - If `@seontechnologies/pactjs-utils` is not yet installed, create a local shim that mirrors the API
  479. - Add a TODO comment noting to swap for the published package when available
  480. - The shim exports `createProviderState`, `toJsonMap`, `setJsonContent`, `setJsonBody`, and helper input types
  481. - Keep shim types local (or sourced from public exports only); do not import from internal Pact paths like `@pact-foundation/pact/src/*`
  482. ---
  483. ### Example 9: .gitignore Entries
  484. **Context**: Pact-specific entries to add to `.gitignore`.
  485. ```
  486. # Pact contract testing artifacts
  487. /pacts/
  488. pact-logs/
  489. ```
  490. ---
  491. ### Example 10: Determinism Gate Script (Primary Defense)
  492. **Context**: Even with `fileParallelism: false` (Example 2) and one-interaction-per-`it()` (see `pactjs-utils-consumer-helpers.md`), the PactV4 Rust FFI layer can occasionally produce byte-different pact JSON between runs — interaction ordering drift, nested matcher serialization quirks, or `Date` / random-value matchers that weren't locked down. This causes PactFlow to reject re-publishes of the same consumer SHA with `Cannot change pact content for already published pact`. The determinism gate runs the consumer suite N times locally and in CI, hashes the normalized pact files, and fails fast if drift is detected — before any publish is attempted.
  493. **Implementation**:
  494. #### `scripts/check-pact-determinism.sh`
  495. ```bash
  496. #!/bin/bash
  497. # Run a pact consumer command N times and fail if the generated pact files are not byte-stable.
  498. # Primary defense against PactV4 non-deterministic output.
  499. #
  500. # Usage: ./scripts/check-pact-determinism.sh "<cmd>" [runs] [pact-dir]
  501. # Example: ./scripts/check-pact-determinism.sh 'npm run test:pact:consumer:run' 3 ./pacts
  502. #
  503. # Requires: jq installed on the runner (ubuntu-latest has it; macOS users need `brew install jq`).
  504. set -euo pipefail
  505. CMD="${1:?usage: ./scripts/check-pact-determinism.sh \"<cmd>\" [runs] [pact-dir]}"
  506. RUNS="${PACT_DETERMINISM_RUNS:-${2:-3}}"
  507. PACT_DIR="${3:-./pacts}"
  508. TMP_DIR="$(mktemp -d)"
  509. trap 'rm -rf "$TMP_DIR"' EXIT
  510. hash_pact_file() {
  511. # Sort interactions by (description, first provider state name, method, path), sort keys with -S.
  512. # The sorted output is what we hash — so ordering-only drift does NOT count as non-determinism here.
  513. # (The gate catches deeper drift; ordering drift is handled by publish-pact.sh normalization.)
  514. jq -S '.interactions |= sort_by(.description, (.providerStates[0].name // ""), .request.method, .request.path)' "$1" \
  515. | shasum -a 256 | awk '{print $1}'
  516. }
  517. for run in $(seq 1 "$RUNS"); do
  518. echo "→ determinism run $run/$RUNS"
  519. rm -f "$PACT_DIR"/*.json 2>/dev/null || true
  520. eval "$CMD" >"$TMP_DIR/run-$run.log" 2>&1 || {
  521. echo "❌ run $run failed — dumping log:"
  522. cat "$TMP_DIR/run-$run.log"
  523. exit 1
  524. }
  525. : > "$TMP_DIR/run-$run.hashes"
  526. for f in "$PACT_DIR"/*.json; do
  527. [ -f "$f" ] || continue
  528. printf '%s %s\n' "$(hash_pact_file "$f")" "$(basename "$f")" >> "$TMP_DIR/run-$run.hashes"
  529. done
  530. sort -o "$TMP_DIR/run-$run.hashes" "$TMP_DIR/run-$run.hashes"
  531. done
  532. # Compare every subsequent run against run 1.
  533. FAIL=0
  534. for run in $(seq 2 "$RUNS"); do
  535. if ! diff -q "$TMP_DIR/run-1.hashes" "$TMP_DIR/run-$run.hashes" >/dev/null; then
  536. FAIL=1
  537. echo ""
  538. echo "❌ Pact output differs between run 1 and run $run:"
  539. diff "$TMP_DIR/run-1.hashes" "$TMP_DIR/run-$run.hashes" || true
  540. fi
  541. done
  542. if [ "$FAIL" -ne 0 ]; then
  543. echo ""
  544. echo "Pact output is non-deterministic across $RUNS runs. Likely causes:"
  545. echo " • multiple .addInteraction() chained in a single it() block (PactV4 FFI drops one non-deterministically)"
  546. echo " • fileParallelism: true in vitest.config.pact.ts (workers race on shared pact JSON)"
  547. echo " • missing pool: 'forks' + singleFork: true (threads pool shares FFI state across files on Linux CI)"
  548. echo " • Date / random matchers that don't lock a stable example value"
  549. echo " • provider state params mutating between runs (e.g. Date.now())"
  550. exit 1
  551. fi
  552. echo "✅ Pact output is byte-stable across $RUNS runs."
  553. ```
  554. **Key Points**:
  555. - **Wire this script into `test:pact:consumer`** (see Example 3). The outer script IS the gate; the inner `test:pact:consumer:run` is the single-pass command for TDD loops.
  556. - **Default 3 runs** is the sweet spot — 2 runs miss intermittent drops, >3 slows CI without catching more. Override with an env var or the positional arg if you're actively debugging a flake.
  557. - **Treat gate failures as a P0 bug, not a "retry until green" condition.** Find the source of non-determinism (chained `addInteraction`, unsorted interactions, Date-dependent matchers). Do not raise `RUNS` to 10 to mask the symptom.
  558. - **Requires `jq`** — installed by default on `ubuntu-latest`. For macOS local dev, document `brew install jq` in the project README.
  559. - **In CI, make this its own visible step** (see Example 5 step (1) naming). Do not fold into a `prepublish:pact` hook — that hides the failure inside a publish log.
  560. - **Defense-in-depth with `publish-pact.sh` normalization** (Example 4): the gate catches pre-publish drift; the publish-time `jq` sort ensures any ordering-only drift that slipped past the gate still produces a byte-stable payload to PactFlow.
  561. ---
  562. ## Validation Checklist
  563. Before presenting the consumer CDC framework to the user, verify:
  564. - [ ] `vitest.config.pact.ts` is minimal **and sets `fileParallelism: false` AND `pool: 'forks'` with `poolOptions.forks.singleFork: true`** (`fileParallelism: false` prevents shared pact JSON corruption from parallel workers; forks + `singleFork: true` eliminates the Linux-CI "request was expected but not received" flake observed once a second `.pacttest.ts` is added — see Example 2 Key Points for evidence, mechanism qualifier, and single-file exception)
  565. - [ ] `vitest.config.pact.ts` does NOT set `sequence.concurrent: true`, `maxConcurrency > 1`, `maxWorkers > 1`, or `isolate: false` — all four defeat the serialization the rule relies on
  566. - [ ] `package.json` splits `test:pact:consumer` (gated determinism runner) and `test:pact:consumer:run` (inner single-pass command)
  567. - [ ] `scripts/check-pact-determinism.sh` is present, hashes via `jq -S` + `sort_by`, defaults to 3 runs, and is the body of the `test:pact:consumer` script
  568. - [ ] `scripts/publish-pact.sh` normalizes interactions with `jq -S '.interactions |= sort_by(.description, (.providerStates[0].name // ""), .request.method, .request.path)'` before the `pact-broker publish` call (defense-in-depth alongside the gate)
  569. - [ ] Script names match pactjs-utils (`test:pact:consumer`, `test:pact:consumer:run`, `publish:pact`, `can:i:deploy:consumer`, `record:consumer:deployment`)
  570. - [ ] Scripts source `env-setup.sh` inline in package.json
  571. - [ ] Shell scripts use `pact-broker` not `npx pact-broker`
  572. - [ ] Shell scripts use `PACTICIPANT` env var pattern
  573. - [ ] `can-i-deploy.sh` has `--retry-while-unknown=10 --retry-interval=30`
  574. - [ ] `record-deployment.sh` has branch guard
  575. - [ ] `env-setup.sh` uses `set -eu`; broker scripts use `set -euo pipefail` — each with explanatory comment
  576. - [ ] CI workflow named `contract-test-consumer.yml`
  577. - [ ] CI has workflow-level env block (not per-step)
  578. - [ ] CI has `detect-breaking-change` step before install
  579. - [ ] CI step (1) is the determinism gate (calls `npm run test:pact:consumer`) — its own visible step, not folded into publish
  580. - [ ] CI steps are 1:1 with developer commands — every CI step calls `npm run <same-name>` a dev would run locally (no direct `vitest` or `pact-broker` invocation)
  581. - [ ] CI step numbering skips (3) — webhook-triggered provider verification
  582. - [ ] CI can-i-deploy has `PACT_BREAKING_CHANGE != 'true'` condition
  583. - [ ] CI has NO upload-artifact step
  584. - [ ] `.github/actions/detect-breaking-change/action.yml` exists
  585. - [ ] Consumer tests use `.pacttest.ts` extension
  586. - [ ] Consumer tests use PactV4 `addInteraction()` builder
  587. - [ ] `uponReceiving()` names follow `"a request to <action> <resource> [<condition>]"` pattern and are unique within the consumer-provider pair
  588. - [ ] Interaction callbacks use `setJsonContent` for query/header/body and `setJsonBody` for body-only responses
  589. - [ ] Request bodies use exact values (no `like()` wrapper) — Postel's Law: be strict in what you send
  590. - [ ] `like()`, `eachLike()`, `string()`, `integer()` matchers are only used in `willRespondWith` (responses), not in `withRequest` (requests) — matchers check type/shape, not exact values
  591. - [ ] Consumer tests call REAL consumer code (actual API client functions), NOT raw `fetch()`
  592. - [ ] Consumer code exposes URL injection mechanism (`setApiUrl()`, env var, or constructor param)
  593. - [ ] Local consumer-helpers shim present if pactjs-utils not installed
  594. - [ ] `.gitignore` includes `/pacts/` and `pact-logs/`
  595. ## Related Fragments
  596. - `pactjs-utils-overview.md` — Library decision tree and installation
  597. - `pactjs-utils-consumer-helpers.md` — `createProviderState`, `toJsonMap`, `setJsonContent`, `setJsonBody`, **one-interaction-per-`it()` rule**
  598. - `pactjs-utils-provider-verifier.md` — Provider-side verification patterns; consumer and provider BOTH require `pool: 'forks'` + `singleFork: true` — same FFI-safety rule applies on both sides
  599. - `pactjs-utils-request-filter.md` — Auth injection for provider verification
  600. - `pact-broker-webhooks.md` — PactFlow → GitHub webhook auth pattern (dedicated user, classic PAT, PactFlow secret) and staleness monitoring
  601. - `contract-testing.md` — Foundational CDC patterns and resilience coverage