Skip to content

Commit bf2e75c

Browse files
kevinccbsgclaude
andauthored
Fix mock overlap in contract validation (#4)
* feat: set test.status to running in onStart callback Enables twd-js to find the currently executing test by scanning handlers for status === 'running' during mock collection. * feat: add buildTestPath utility Walks the handler parent chain to build a full test path string like 'Cart > Checkout > should submit order' from a testId. * feat: fix mock dedup key and add occurrence tracking Change mock collection key from alias to method:url:status:testId:occurrence. Track occurrence count per alias:testId combination. Enrich collected mocks with full test path names from handlers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add formatMockLabel utility for report display Formats mock labels with alias, ordinal occurrence suffix (2nd, 3rd), and test name context for contract validation reports. * feat: show test name and occurrence in console contract report Use formatMockLabel to display test context and ordinal occurrence in error, pass, and warning lines. * feat: show test name and occurrence in markdown contract report Use formatMockLabelMd to display test context and ordinal occurrence in the failure details section of the markdown report. * docs: update README with test context in contract reports Update contract validation example output to show test name and occurrence count in mock labels. Add note about automatic handling of mock overlaps (same alias, different endpoints). * chore: update dependencies * fix: reset test.status and pass testName/occurrence through validation Two bugs prevented test names from showing in contract reports: 1. onStart set test.status="running" but never reset it, so __twdCollectMock always found the first test as "running" 2. validateMocks didn't forward testName/occurrence to result objects Also bump test-example-app twd-js to ^1.6.5 (sends testId). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 232129a commit bf2e75c

16 files changed

Lines changed: 475 additions & 164 deletions

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ Create a `twd.config.json` file in your project root:
6060

6161
**Important**: Puppeteer is **not** used as a testing framework here. It simply provides a headless browser to load your application — the same way a user would open Chrome. Once the page loads, all test execution happens inside the real browser context through the [TWD runner](https://brikev.github.io/twd/). Your tests interact with real DOM, real components, and real browser APIs — Puppeteer just opens the door and gets out of the way.
6262

63+
**Contract Validation**: Mock overlaps are automatically handled — if multiple tests or calls use the same alias but with different HTTP methods/URLs/statuses, all are validated separately (no silent drops).
64+
6365
1. Launches a headless browser via Puppeteer (the only thing Puppeteer does)
6466
2. Navigates to your dev server URL
6567
3. Waits for the app and TWD sidebar to be ready
@@ -188,12 +190,12 @@ Validate your test mocks against OpenAPI specs to catch drift between your mocks
188190
```
189191
Source: ./contracts/users-3.0.json ERROR
190192
191-
✓ GET /users (200) — mock "getUsers"
192-
✗ GET /users/{userId} (200) — mock "getUserBadAddress"
193+
✓ GET /users (200) — mock "getUsers" — in "UserList > should display all users"
194+
✗ GET /users/{userId} (200) — mock "getUserBadAddress" — in "UserDetails > should fetch user details"
193195
→ response.address.city: missing required property
194196
→ response.address.country: missing required property
195197
196-
⚠ GET /users/{userId} (404) — mock "getUserNotFound"
198+
⚠ GET /users/{userId} (404) — mock "getUserNotFound" 2nd time — in "UserDetails > should show not found"
197199
Status 404 not documented for GET /users/{userId}
198200
```
199201

package-lock.json

Lines changed: 158 additions & 147 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,14 @@
2525
"dependencies": {
2626
"openapi-mock-validator": "^0.1.4",
2727
"puppeteer": "^24.40.0",
28-
"twd-js": "^1.6.4"
28+
"twd-js": "^1.6.5"
2929
},
3030
"engines": {
3131
"node": ">=18.0.0"
3232
},
3333
"devDependencies": {
34-
"@vitest/coverage-v8": "^4.1.2",
34+
"@vitest/coverage-v8": "^4.1.4",
3535
"conventional-changelog": "^7.2.0",
36-
"vitest": "^4.1.2"
36+
"vitest": "^4.1.4"
3737
}
3838
}

src/buildTestPath.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export function buildTestPath(testId, handlers) {
2+
const handlerMap = new Map(handlers.map(h => [h.id, h]));
3+
const parts = [];
4+
let current = handlerMap.get(testId);
5+
if (!current) return null;
6+
while (current) {
7+
parts.unshift(current.name);
8+
current = current.parent ? handlerMap.get(current.parent) : null;
9+
}
10+
return parts.join(' > ');
11+
}

src/contractMarkdown.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
import { formatMockLabel } from './formatMockLabel.js';
2+
3+
function formatMockLabelMd(result) {
4+
// Reuse the plain-text label but replace quoted alias with backtick-wrapped alias
5+
return formatMockLabel(result).replace(`"${result.alias}"`, `\`${result.alias}\``);
6+
}
7+
18
export function generateContractMarkdown(output) {
29
const { results, skipped } = output;
310
const lines = [];
@@ -85,7 +92,7 @@ export function generateContractMarkdown(output) {
8592
lines.push('');
8693

8794
for (const r of failures) {
88-
lines.push(`- \`${r.method} ${r.matchedPath}\` (${r.status}) — mock \`${r.alias}\``);
95+
lines.push(`- \`${r.method} ${r.matchedPath}\` (${r.status}) — ${formatMockLabelMd(r)}`);
8996
for (const err of r.validation.errors) {
9097
lines.push(` - \`${err.path}\`: ${err.message}`);
9198
}

src/contractReport.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { formatMockLabel } from './formatMockLabel.js';
2+
13
const red = (s) => `\x1b[31m${s}\x1b[0m`;
24
const boldRed = (s) => `\x1b[1;31m${s}\x1b[0m`;
35
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
@@ -45,7 +47,7 @@ export function printContractReport(output) {
4547

4648
if (!result.validation.valid) {
4749
errorCount += result.validation.errors.length;
48-
console.log(failColor(` ✗ ${result.method} ${result.matchedPath} (${result.status}) — mock "${result.alias}"`));
50+
console.log(failColor(` ✗ ${result.method} ${result.matchedPath} (${result.status}) — ${formatMockLabel(result)}`));
4951
for (const err of result.validation.errors) {
5052
console.log(detailColor(` → ${err.path}: ${err.message}`));
5153
}
@@ -54,12 +56,12 @@ export function printContractReport(output) {
5456
hasContractErrors = true;
5557
}
5658
} else if (result.validation.warnings.length === 0) {
57-
console.log(green(` ✓ ${result.method} ${result.matchedPath} (${result.status}) — mock "${result.alias}"`));
59+
console.log(green(` ✓ ${result.method} ${result.matchedPath} (${result.status}) — ${formatMockLabel(result)}`));
5860
}
5961

6062
for (const warning of result.validation.warnings) {
6163
warningCount++;
62-
console.log(yellow(` ⚠ ${result.method} ${result.matchedPath} (${result.status}) — mock "${result.alias}"`));
64+
console.log(yellow(` ⚠ ${result.method} ${result.matchedPath} (${result.status}) — ${formatMockLabel(result)}`));
6365
console.log(yellow(` ${warning.message}`));
6466
console.log('');
6567
}

src/contracts.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ export function validateMocks(collectedMocks, contracts) {
8383
specSource: contract.source,
8484
matchedPath: pathMatch.path,
8585
mode: contract.mode,
86+
testName: mock.testName,
87+
occurrence: mock.occurrence,
8688
validation,
8789
});
8890

src/formatMockLabel.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
function ordinal(n) {
2+
if (n === 2) return '2nd';
3+
if (n === 3) return '3rd';
4+
return `${n}th`;
5+
}
6+
7+
export function formatMockLabel(result) {
8+
let label = `mock "${result.alias}"`;
9+
if (result.occurrence && result.occurrence > 1) {
10+
label += ` ${ordinal(result.occurrence)} time`;
11+
}
12+
if (result.testName) {
13+
label += ` — in "${result.testName}"`;
14+
}
15+
return label;
16+
}

src/index.js

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { loadConfig } from './config.js';
66
import { loadContracts, validateMocks } from './contracts.js';
77
import { printContractReport } from './contractReport.js';
88
import { generateContractMarkdown } from './contractMarkdown.js';
9+
import { buildTestPath } from './buildTestPath.js';
910

1011
export async function runTests() {
1112
let browser;
@@ -32,9 +33,15 @@ export async function runTests() {
3233

3334
// Register mock collector for contract validation
3435
const collectedMocks = new Map();
36+
const occurrenceCounters = new Map();
3537
if (config.contracts && config.contracts.length > 0) {
3638
await page.exposeFunction('__twdCollectMock', (mock) => {
37-
collectedMocks.set(mock.alias, mock);
39+
const occKey = `${mock.alias}:${mock.testId}`;
40+
const count = (occurrenceCounters.get(occKey) || 0) + 1;
41+
occurrenceCounters.set(occKey, count);
42+
43+
const dedupKey = `${mock.method}:${mock.url}:${mock.status}:${mock.testId}:${count}`;
44+
collectedMocks.set(dedupKey, { ...mock, occurrence: count });
3845
});
3946
}
4047

@@ -51,16 +58,21 @@ export async function runTests() {
5158
const TestRunner = window.__testRunner;
5259
const testStatus = [];
5360
const runner = new TestRunner({
54-
onStart: () => {},
61+
onStart: (test) => {
62+
test.status = "running";
63+
},
5564
onPass: (test, retryAttempt) => {
65+
test.status = "done";
5666
const entry = { id: test.id, status: "pass" };
5767
if (retryAttempt !== undefined) entry.retryAttempt = retryAttempt;
5868
testStatus.push(entry);
5969
},
6070
onFail: (test, err) => {
71+
test.status = "done";
6172
testStatus.push({ id: test.id, status: "fail", error: `${err.message} (at ${window.location.href})` });
6273
},
6374
onSkip: (test) => {
75+
test.status = "done";
6476
testStatus.push({ id: test.id, status: "skip" });
6577
},
6678
}, { retryCount });
@@ -89,6 +101,13 @@ export async function runTests() {
89101
let hasFailures = testStatus.some(test => test.status === 'fail');
90102
console.timeEnd('Total Test Time');
91103

104+
// Enrich collected mocks with full test path names
105+
for (const [, mock] of collectedMocks) {
106+
if (mock.testId) {
107+
mock.testName = buildTestPath(mock.testId, handlers);
108+
}
109+
}
110+
92111
// Contract validation
93112
if (config.contracts && config.contracts.length > 0) {
94113
if (collectedMocks.size === 0) {

test-example-app/package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)