Skip to content

Commit b699a0b

Browse files
lunataoodelongGao
andcommitted
refactor error parsing into its own class
Co-authored-by: Delong Gao <[email protected]>
1 parent 48a3613 commit b699a0b

File tree

4 files changed

+340
-340
lines changed

4 files changed

+340
-340
lines changed
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import {parseHumanReadableError} from './error-parsing.js'
2+
import {describe, expect, test} from 'vitest'
3+
4+
describe('parseHumanReadableError', () => {
5+
test('formats union errors with smart variant detection', () => {
6+
const unionErrorObject = [
7+
{
8+
code: 'invalid_union',
9+
unionErrors: [
10+
{
11+
issues: [
12+
{
13+
code: 'invalid_type',
14+
expected: 'array',
15+
received: 'number',
16+
path: ['web', 'roles'],
17+
message: 'Expected array, received number',
18+
},
19+
{
20+
code: 'invalid_type',
21+
expected: 'string',
22+
received: 'undefined',
23+
path: ['web', 'commands', 'build'],
24+
message: 'Required',
25+
},
26+
],
27+
name: 'ZodError',
28+
},
29+
{
30+
issues: [
31+
{
32+
code: 'invalid_literal',
33+
expected: "'frontend'",
34+
received: 'number',
35+
path: ['web', 'type'],
36+
message: "Invalid literal value, expected 'frontend'",
37+
},
38+
],
39+
name: 'ZodError',
40+
},
41+
],
42+
path: ['web'],
43+
message: 'Invalid input',
44+
},
45+
]
46+
47+
const result = parseHumanReadableError(unionErrorObject)
48+
49+
// Verify the enhanced format shows only the best matching variant's errors
50+
// (Option 1 has both missing field + type error, so it's likely the intended one)
51+
expect(result).toContain('[web.roles]: Expected array, received number')
52+
expect(result).toContain('[web.commands.build]: Required')
53+
54+
// Should NOT show confusing union variant breakdown
55+
expect(result).not.toContain('Union validation failed')
56+
expect(result).not.toContain('Option 1:')
57+
expect(result).not.toContain('Option 2:')
58+
59+
// Should NOT show errors from the less likely option 2
60+
expect(result).not.toContain("[web.type]: Invalid literal value, expected 'frontend'")
61+
})
62+
63+
test('handles regular non-union errors', () => {
64+
const regularErrorObject = [
65+
{
66+
path: ['name'],
67+
message: 'Required field is missing',
68+
},
69+
{
70+
path: ['version'],
71+
message: 'Must be a valid semver string',
72+
},
73+
]
74+
75+
const result = parseHumanReadableError(regularErrorObject)
76+
77+
// Verify regular errors still work as expected
78+
expect(result).toBe('• [name]: Required field is missing\n• [version]: Must be a valid semver string\n')
79+
expect(result).not.toContain('Union validation failed')
80+
})
81+
82+
test('handles edge cases for union error detection', () => {
83+
// Test case 1: Union error with no unionErrors array
84+
const noUnionErrors = [
85+
{
86+
code: 'invalid_union',
87+
path: ['root'],
88+
message: 'Invalid input',
89+
},
90+
]
91+
92+
const result1 = parseHumanReadableError(noUnionErrors)
93+
expect(result1).toBe('• [root]: Invalid input\n')
94+
95+
// Test case 2: Union error with empty unionErrors array - should fall back to showing full union error
96+
const emptyUnionErrors = [
97+
{
98+
code: 'invalid_union',
99+
unionErrors: [],
100+
path: ['root'],
101+
message: 'Invalid input',
102+
},
103+
]
104+
105+
const result2 = parseHumanReadableError(emptyUnionErrors)
106+
expect(result2).toContain("Configuration doesn't match any expected format:")
107+
108+
// Test case 3: Union errors with variants that have no issues - results in empty string
109+
const noIssuesVariants = [
110+
{
111+
code: 'invalid_union',
112+
unionErrors: [
113+
{issues: [], name: 'ZodError'},
114+
{issues: [], name: 'ZodError'},
115+
],
116+
path: ['root'],
117+
message: 'Invalid input',
118+
},
119+
]
120+
121+
const result3 = parseHumanReadableError(noIssuesVariants)
122+
// When all variants have no issues, the best variant selection returns null issues
123+
// resulting in no output, which falls back to the union error display
124+
expect(result3).toContain("Configuration doesn't match any expected format:")
125+
})
126+
127+
test('findBestMatchingVariant scoring logic works correctly', () => {
128+
// Test various scoring scenarios by creating mock union errors
129+
const scenarioWithMissingFields = [
130+
{
131+
code: 'invalid_union',
132+
unionErrors: [
133+
{
134+
// Variant with missing fields - should score highest
135+
issues: [
136+
{path: ['supports_moto'], message: 'Required'},
137+
{path: ['merchant_label'], message: 'Required'},
138+
],
139+
name: 'ZodError',
140+
},
141+
{
142+
// Variant with only type errors - should score lower
143+
issues: [
144+
{path: ['type'], message: 'Expected string, received number'},
145+
{path: ['id'], message: 'Expected number, received string'},
146+
],
147+
name: 'ZodError',
148+
},
149+
{
150+
// Variant with other errors - should score lowest
151+
issues: [{path: ['url'], message: 'Invalid URL format'}],
152+
name: 'ZodError',
153+
},
154+
],
155+
path: [],
156+
message: 'Invalid input',
157+
},
158+
]
159+
160+
const result = parseHumanReadableError(scenarioWithMissingFields)
161+
162+
// Should show only the variant with missing fields (highest score)
163+
expect(result).toContain('[supports_moto]: Required')
164+
expect(result).toContain('[merchant_label]: Required')
165+
166+
// Should NOT show errors from other variants
167+
expect(result).not.toContain('Expected string, received number')
168+
expect(result).not.toContain('Invalid URL format')
169+
expect(result).not.toContain('Union validation failed')
170+
})
171+
172+
test('handles undefined messages gracefully', () => {
173+
const undefinedMessageError = [
174+
{
175+
path: ['field'],
176+
message: undefined,
177+
},
178+
{
179+
path: [],
180+
// message is completely missing
181+
},
182+
]
183+
184+
const result = parseHumanReadableError(undefinedMessageError)
185+
186+
expect(result).toBe('• [field]: Unknown error\n• [root]: Unknown error\n')
187+
})
188+
189+
test('handles mixed scoring scenarios', () => {
190+
// Test scenario where we need to pick between variants with different error combinations
191+
const mixedScenario = [
192+
{
193+
code: 'invalid_union',
194+
unionErrors: [
195+
{
196+
// Mix of missing fields and type errors - this should win due to missing fields
197+
issues: [
198+
{path: ['required_field'], message: 'Required'},
199+
{path: ['wrong_type'], message: 'Expected string, received number'},
200+
],
201+
name: 'ZodError',
202+
},
203+
{
204+
// Only type errors - should lose
205+
issues: [
206+
{path: ['field1'], message: 'Expected boolean, received string'},
207+
{path: ['field2'], message: 'Expected array, received object'},
208+
{path: ['field3'], message: 'Expected number, received string'},
209+
],
210+
name: 'ZodError',
211+
},
212+
{
213+
// Only other validation errors - should score lowest
214+
issues: [
215+
{path: ['url'], message: 'Must be valid URL'},
216+
{path: ['email'], message: 'Must be valid email'},
217+
],
218+
name: 'ZodError',
219+
},
220+
],
221+
path: [],
222+
message: 'Invalid input',
223+
},
224+
]
225+
226+
const result = parseHumanReadableError(mixedScenario)
227+
228+
// Should pick the variant with missing field (even though it has fewer total errors)
229+
expect(result).toContain('[required_field]: Required')
230+
expect(result).toContain('[wrong_type]: Expected string, received number')
231+
232+
// Should not show errors from other variants
233+
expect(result).not.toContain('Expected boolean, received string')
234+
expect(result).not.toContain('Must be valid URL')
235+
expect(result).not.toContain('Union validation failed')
236+
})
237+
})
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
interface UnionError {
2+
issues?: {path?: (string | number)[]; message: string}[]
3+
name: string
4+
}
5+
6+
interface ExtendedZodIssue {
7+
path?: (string | number)[]
8+
message?: string
9+
code?: string
10+
unionErrors?: UnionError[]
11+
}
12+
13+
/**
14+
* Finds the best matching variant from a union error by scoring each variant
15+
* based on how close it is to the user's likely intent.
16+
*/
17+
function findBestMatchingVariant(unionErrors: UnionError[]): UnionError | null {
18+
if (!unionErrors?.length) return null
19+
20+
let bestVariant: UnionError | null = null
21+
let bestScore = -1
22+
23+
for (const variant of unionErrors) {
24+
if (!variant.issues?.length) continue
25+
26+
let missingFieldCount = 0
27+
let typeErrorCount = 0
28+
let otherErrorCount = 0
29+
30+
for (const issue of variant.issues) {
31+
if (issue.message?.includes('Required') || issue.message?.includes('required')) {
32+
missingFieldCount++
33+
} else if (issue.message?.includes('Expected') && issue.message?.includes('received')) {
34+
typeErrorCount++
35+
} else {
36+
otherErrorCount++
37+
}
38+
}
39+
40+
// Score variants: prefer those with missing fields over type errors
41+
let score
42+
if (missingFieldCount > 0) {
43+
score = 1000 - missingFieldCount * 10 - typeErrorCount - otherErrorCount
44+
} else if (typeErrorCount > 0) {
45+
score = 100 - typeErrorCount * 5 - otherErrorCount
46+
} else {
47+
score = 50 - otherErrorCount
48+
}
49+
50+
if (score > bestScore) {
51+
bestScore = score
52+
bestVariant = variant
53+
}
54+
}
55+
56+
return bestVariant
57+
}
58+
59+
/**
60+
* Formats an issue into a human-readable error line
61+
*/
62+
function formatErrorLine(issue: {path?: (string | number)[]; message?: string}, indent = '') {
63+
const path = issue.path && issue.path.length > 0 ? issue.path.map(String).join('.') : 'root'
64+
const message = issue.message ?? 'Unknown error'
65+
return `${indent}• [${path}]: ${message}\n`
66+
}
67+
68+
export function parseHumanReadableError(issues: ExtendedZodIssue[]) {
69+
let humanReadableError = ''
70+
71+
issues.forEach((issue) => {
72+
// Handle union errors with smart variant detection
73+
if (issue.code === 'invalid_union' && issue.unionErrors) {
74+
// Find the variant that's most likely the intended one
75+
const bestVariant = findBestMatchingVariant(issue.unionErrors)
76+
77+
if (bestVariant?.issues?.length) {
78+
// Show errors only for the best matching variant
79+
bestVariant.issues.forEach((nestedIssue) => {
80+
humanReadableError += formatErrorLine(nestedIssue)
81+
})
82+
} else {
83+
// Fallback: show all variants if we can't determine the best one
84+
humanReadableError += `• Configuration doesn't match any expected format:\n`
85+
issue.unionErrors.forEach((unionError, index: number) => {
86+
humanReadableError += ` Option ${index + 1}:\n`
87+
unionError.issues?.forEach((nestedIssue) => {
88+
humanReadableError += formatErrorLine(nestedIssue, ' ')
89+
})
90+
})
91+
}
92+
} else {
93+
// Handle regular issues
94+
humanReadableError += formatErrorLine(issue)
95+
}
96+
})
97+
98+
return humanReadableError
99+
}

0 commit comments

Comments
 (0)