Skip to content

Commit 956c985

Browse files
committed
fix(assertions): better assertion error messages for regexes
1 parent 9e11a4e commit 956c985

File tree

7 files changed

+288
-77
lines changed

7 files changed

+288
-77
lines changed

src/diff.ts

Lines changed: 114 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export type { DiffOptions };
1818
const { isArray } = Array;
1919
const { max } = Math;
2020
const { stringify } = JSON;
21-
const { entries } = Object;
21+
const { entries, getOwnPropertyNames } = Object;
2222

2323
/**
2424
* Result of extracting diff values from a `ZodError`
@@ -59,6 +59,25 @@ export const extractDiffValues = (
5959
);
6060

6161
switch (issue.code) {
62+
case 'invalid_format': {
63+
// For format errors (e.g., regex patterns), show what the pattern expects
64+
const actualValue = getValueAtPath(actual, filteredPath);
65+
let correctedValue = actualValue;
66+
67+
// Try to extract pattern info from the message
68+
const regexMatch = issue.message.match(/pattern (.+)/);
69+
if (regexMatch) {
70+
// For regex patterns, create a placeholder that indicates the expected pattern
71+
correctedValue = `<string matching ${regexMatch[1]}>`;
72+
} else {
73+
// Fallback for other format errors
74+
correctedValue = '<string in valid format>';
75+
}
76+
77+
expected = setValueAtPath(expected, filteredPath, correctedValue);
78+
break;
79+
}
80+
6281
case 'invalid_type': {
6382
const correctedValue = createCorrectValueForType(
6483
issue.expected,
@@ -68,6 +87,34 @@ export const extractDiffValues = (
6887
break;
6988
}
7089

90+
case 'invalid_union': {
91+
// For union errors, we can't easily determine the "correct" value
92+
// but we can try to provide a hint based on the error context
93+
const actualValue = getValueAtPath(actual, filteredPath);
94+
95+
// Check if this is a top-level union error (like regex OR object)
96+
if (filteredPath.length === 0) {
97+
// For top-level union errors, try to suggest an alternative format
98+
if (typeof actualValue === 'object' && actualValue !== null) {
99+
// If actual is an object, the union might expect a string pattern
100+
expected = '<string matching pattern>';
101+
} else if (typeof actualValue === 'string') {
102+
// If actual is a string, the union might expect an object structure
103+
expected = '<object satisfying schema>';
104+
} else {
105+
expected = '<value matching union schema>';
106+
}
107+
} else {
108+
// For nested union errors, provide a generic placeholder
109+
expected = setValueAtPath(
110+
expected,
111+
filteredPath,
112+
'<value matching union schema>',
113+
);
114+
}
115+
break;
116+
}
117+
71118
case 'invalid_value': {
72119
// For literal/enum errors, use the first valid value
73120
const correctedValue =
@@ -251,7 +298,28 @@ const customDeepClone = (value: unknown): unknown => {
251298
if (isArray(value)) {
252299
return value.map(customDeepClone);
253300
}
254-
// For objects, create a new object and copy properties
301+
302+
// Special handling for Error objects to preserve non-enumerable properties
303+
if (value instanceof Error) {
304+
const cloned: Record<string, unknown> = {};
305+
// Copy enumerable properties
306+
for (const [key, val] of entries(value)) {
307+
cloned[key] = customDeepClone(val);
308+
}
309+
// Copy non-enumerable properties that are important for Error objects
310+
for (const prop of getOwnPropertyNames(value)) {
311+
if (!(prop in cloned)) {
312+
try {
313+
cloned[prop] = (value as unknown as Record<string, unknown>)[prop];
314+
} catch {
315+
// Skip properties that can't be accessed
316+
}
317+
}
318+
}
319+
return cloned;
320+
}
321+
322+
// For regular objects, create a new object and copy properties
255323
const cloned: Record<string, unknown> = {};
256324
for (const [key, val] of entries(value)) {
257325
cloned[key] = customDeepClone(val);
@@ -322,22 +390,52 @@ const setValueAtPath = (
322390
obj = typeof path[0] === 'number' ? [] : {};
323391
}
324392

325-
const result = isArray(obj)
326-
? [...(obj as unknown[])]
327-
: { ...(obj as Record<string, unknown>) };
328-
const [head, ...tail] = path;
393+
if (isArray(obj)) {
394+
const result = [...(obj as unknown[])];
395+
const [head, ...tail] = path;
396+
397+
if (head !== undefined) {
398+
if (tail.length === 0) {
399+
result[head as number] = value;
400+
} else {
401+
result[head as number] = setValueAtPath(
402+
result[head as number],
403+
tail,
404+
value,
405+
);
406+
}
407+
}
329408

330-
if (head !== undefined) {
331-
if (tail.length === 0) {
332-
(result as Record<number | string, unknown>)[head] = value;
409+
return result;
410+
} else {
411+
let result: Record<number | string, unknown>;
412+
413+
// Handle Error objects and other objects with non-enumerable properties
414+
if (obj instanceof Error) {
415+
// For Error objects, copy all own properties (including non-enumerable ones)
416+
result = {};
417+
for (const prop of getOwnPropertyNames(obj)) {
418+
if (prop !== 'stack') {
419+
// Skip stack to reduce noise
420+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
421+
result[prop] = (obj as any)[prop];
422+
}
423+
}
333424
} else {
334-
(result as Record<number | string, unknown>)[head] = setValueAtPath(
335-
(result as Record<number | string, unknown>)[head],
336-
tail,
337-
value,
338-
);
425+
// For regular objects, use spread operator
426+
result = { ...(obj as Record<string, unknown>) };
339427
}
340-
}
341428

342-
return result;
429+
const [head, ...tail] = path;
430+
431+
if (head !== undefined) {
432+
if (tail.length === 0) {
433+
result[head] = value;
434+
} else {
435+
result[head] = setValueAtPath(result[head], tail, value);
436+
}
437+
}
438+
439+
return result;
440+
}
343441
};

test/assertion-error/async-parametric-error.test.ts

Lines changed: 90 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,33 @@ import { type AnyAssertion } from '../../src/types.js';
1313
import { expect, expectAsync } from '../custom-assertions.js';
1414
import { takeErrorSnapshot } from './error-snapshot-util.js';
1515

16-
const failingAssertions = new Map<AnyAssertion, () => Promise<void>>([
17-
[
18-
assertions.functionFulfillWithValueSatisfyingAssertion,
19-
async () => {
16+
interface TestCase {
17+
assertion: AnyAssertion;
18+
description?: string;
19+
testFn: () => Promise<void>;
20+
}
21+
22+
const failingAssertions: TestCase[] = [
23+
{
24+
assertion: assertions.functionFulfillWithValueSatisfyingAssertion,
25+
testFn: async () => {
2026
await expectAsync(
2127
async () => 'wrong',
2228
'to fulfill with value satisfying',
2329
42,
2430
);
2531
},
26-
],
27-
[
28-
assertions.functionRejectAssertion,
29-
async () => {
32+
},
33+
{
34+
assertion: assertions.functionRejectAssertion,
35+
testFn: async () => {
3036
await expectAsync(async () => 'success', 'to reject');
3137
},
32-
],
33-
[
34-
assertions.functionRejectWithErrorSatisfyingAssertion,
35-
async () => {
38+
},
39+
{
40+
assertion: assertions.functionRejectWithErrorSatisfyingAssertion,
41+
description: 'with object parameter',
42+
testFn: async () => {
3643
await expectAsync(
3744
async () => {
3845
throw new Error('wrong message');
@@ -41,10 +48,36 @@ const failingAssertions = new Map<AnyAssertion, () => Promise<void>>([
4148
{ message: 'expected message' },
4249
);
4350
},
44-
],
45-
[
46-
assertions.functionRejectWithTypeAssertion,
47-
async () => {
51+
},
52+
{
53+
assertion: assertions.functionRejectWithErrorSatisfyingAssertion,
54+
description: 'with object regex parameter',
55+
testFn: async () => {
56+
await expectAsync(
57+
async () => {
58+
throw new Error('wrong message');
59+
},
60+
'to reject with error satisfying',
61+
{ message: /expected message/ },
62+
);
63+
},
64+
},
65+
{
66+
assertion: assertions.functionRejectWithErrorSatisfyingAssertion,
67+
description: 'with regex parameter',
68+
testFn: async () => {
69+
await expectAsync(
70+
async () => {
71+
throw new Error('wrong message');
72+
},
73+
'to reject with error satisfying',
74+
/expected message/,
75+
);
76+
},
77+
},
78+
{
79+
assertion: assertions.functionRejectWithTypeAssertion,
80+
testFn: async () => {
4881
await expectAsync(
4982
async () => {
5083
throw new Error('error');
@@ -53,24 +86,24 @@ const failingAssertions = new Map<AnyAssertion, () => Promise<void>>([
5386
TypeError,
5487
);
5588
},
56-
],
57-
[
58-
assertions.functionResolveAssertion,
59-
async () => {
89+
},
90+
{
91+
assertion: assertions.functionResolveAssertion,
92+
testFn: async () => {
6093
await expectAsync(async () => {
6194
throw new Error('failure');
6295
}, 'to resolve');
6396
},
64-
],
65-
[
66-
assertions.promiseRejectAssertion,
67-
async () => {
97+
},
98+
{
99+
assertion: assertions.promiseRejectAssertion,
100+
testFn: async () => {
68101
await expectAsync(Promise.resolve('success'), 'to reject');
69102
},
70-
],
71-
[
72-
assertions.promiseRejectWithErrorSatisfyingAssertion,
73-
async () => {
103+
},
104+
{
105+
assertion: assertions.promiseRejectWithErrorSatisfyingAssertion,
106+
testFn: async () => {
74107
// Use thenable object to avoid unhandled rejection
75108
const rejectingThenable = {
76109
then(_resolve: (value: any) => void, reject: (reason: any) => void) {
@@ -81,10 +114,10 @@ const failingAssertions = new Map<AnyAssertion, () => Promise<void>>([
81114
message: 'expected message',
82115
});
83116
},
84-
],
85-
[
86-
assertions.promiseRejectWithTypeAssertion,
87-
async () => {
117+
},
118+
{
119+
assertion: assertions.promiseRejectWithTypeAssertion,
120+
testFn: async () => {
88121
// Use thenable object to avoid unhandled rejection
89122
const rejectingThenable = {
90123
then(_resolve: (value: any) => void, reject: (reason: any) => void) {
@@ -93,10 +126,10 @@ const failingAssertions = new Map<AnyAssertion, () => Promise<void>>([
93126
};
94127
await expectAsync(rejectingThenable, 'to reject with a', TypeError);
95128
},
96-
],
97-
[
98-
assertions.promiseResolveAssertion,
99-
async () => {
129+
},
130+
{
131+
assertion: assertions.promiseResolveAssertion,
132+
testFn: async () => {
100133
// Use thenable object to avoid unhandled rejection
101134
const rejectingThenable = {
102135
then(_resolve: (value: any) => void, reject: (reason: any) => void) {
@@ -105,38 +138,46 @@ const failingAssertions = new Map<AnyAssertion, () => Promise<void>>([
105138
};
106139
await expectAsync(rejectingThenable, 'to resolve');
107140
},
108-
],
109-
[
110-
assertions.promiseResolveWithValueSatisfyingAssertion,
111-
async () => {
141+
},
142+
{
143+
assertion: assertions.promiseResolveWithValueSatisfyingAssertion,
144+
testFn: async () => {
112145
await expectAsync(
113146
Promise.resolve('wrong'),
114147
'to fulfill with value satisfying',
115148
42,
116149
);
117150
},
118-
],
119-
]);
151+
},
152+
];
120153

121154
describe('Async Parametric Assertion Error Snapshots', () => {
122-
it(`should test all available assertions in SyncCollectionAssertions`, () => {
155+
it(`should test all available assertions in AsyncParametricAssertions`, () => {
156+
// Create a Map from unique assertions in our test cases
157+
const assertionMap = new Map();
158+
for (const testCase of failingAssertions) {
159+
assertionMap.set(testCase.assertion, testCase.testFn);
160+
}
161+
123162
expect(
124-
failingAssertions,
163+
assertionMap,
125164
'to exhaustively test collection',
126165
'AsyncParametricAssertions',
127166
'from',
128167
AsyncParametricAssertions,
129168
);
130169
});
131170

132-
for (const assertion of Object.values(assertions)) {
133-
const { id } = assertion;
134-
describe(`${assertion} [${id}]`, () => {
135-
const failingAssertion = failingAssertions.get(assertion)!;
171+
for (const testCase of failingAssertions) {
172+
const { assertion, description, testFn } = testCase;
173+
const testName = description
174+
? `${assertion} [${assertion.id}] (${description})`
175+
: `${assertion} [${assertion.id}]`;
136176

177+
describe(testName, () => {
137178
it(
138179
`should throw a consistent AssertionError [${assertion.id}] <snapshot>`,
139-
takeErrorSnapshot(failingAssertion),
180+
takeErrorSnapshot(testFn),
140181
);
141182
});
142183
}

0 commit comments

Comments
 (0)