Skip to content

Commit 48a3613

Browse files
lunataoodelongGao
andcommitted
Enhance union validation error messages with smart variant detection
Co-authored-by: Delong Gao <delong.gao@shopify.com>
1 parent 7e6a097 commit 48a3613

2 files changed

Lines changed: 322 additions & 15 deletions

File tree

packages/app/src/cli/models/app/loader.test.ts

Lines changed: 224 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2814,7 +2814,29 @@ describe('parseConfigurationObject', () => {
28142814
roles: 1,
28152815
}
28162816

2817-
const errorObject = [
2817+
const abortOrReport = vi.fn()
2818+
await parseConfigurationObject(WebConfigurationSchema, 'tmp', configurationObject, abortOrReport)
2819+
2820+
// Verify the function was called and capture the actual error structure
2821+
expect(abortOrReport).toHaveBeenCalledOnce()
2822+
const callArgs = abortOrReport.mock.calls[0]!
2823+
const actualErrorMessage = callArgs[0]
2824+
2825+
// Convert TokenizedString to regular string for testing
2826+
const errorString = actualErrorMessage.value
2827+
2828+
// The enhanced union handling should show only the most relevant errors
2829+
// instead of showing all variants, making it much more user-friendly
2830+
expect(errorString).toContain('[roles]: Expected array, received number')
2831+
2832+
// Should NOT show the confusing union variant breakdown
2833+
expect(errorString).not.toContain('Union validation failed')
2834+
expect(errorString).not.toContain('Variant 1:')
2835+
expect(errorString).not.toContain('Variant 2:')
2836+
})
2837+
2838+
test('parseHumanReadableError formats union errors with smart variant detection', () => {
2839+
const unionErrorObject = [
28182840
{
28192841
code: 'invalid_union',
28202842
unionErrors: [
@@ -2824,36 +2846,226 @@ describe('parseConfigurationObject', () => {
28242846
code: 'invalid_type',
28252847
expected: 'array',
28262848
received: 'number',
2827-
path: ['roles'],
2849+
path: ['web', 'roles'],
28282850
message: 'Expected array, received number',
28292851
},
2852+
{
2853+
code: 'invalid_type',
2854+
expected: 'string',
2855+
received: 'undefined',
2856+
path: ['web', 'commands', 'build'],
2857+
message: 'Required',
2858+
},
28302859
],
28312860
name: 'ZodError',
28322861
},
28332862
{
28342863
issues: [
28352864
{
2836-
expected: "'frontend' | 'backend' | 'background'",
2865+
code: 'invalid_literal',
2866+
expected: "'frontend'",
28372867
received: 'number',
2838-
code: 'invalid_type',
2839-
path: ['type'],
2840-
message: "Expected 'frontend' | 'backend' | 'background', received number",
2868+
path: ['web', 'type'],
2869+
message: "Invalid literal value, expected 'frontend'",
28412870
},
28422871
],
28432872
name: 'ZodError',
28442873
},
28452874
],
2875+
path: ['web'],
2876+
message: 'Invalid input',
2877+
},
2878+
]
2879+
2880+
const result = parseHumanReadableError(unionErrorObject)
2881+
2882+
// Verify the enhanced format shows only the best matching variant's errors
2883+
// (Variant 1 has both missing field + type error, so it's likely the intended one)
2884+
expect(result).toContain('[web.roles]: Expected array, received number')
2885+
expect(result).toContain('[web.commands.build]: Required')
2886+
2887+
// Should NOT show confusing union variant breakdown
2888+
expect(result).not.toContain('Union validation failed')
2889+
expect(result).not.toContain('Variant 1:')
2890+
expect(result).not.toContain('Variant 2:')
2891+
2892+
// Should NOT show errors from the less likely variant 2
2893+
expect(result).not.toContain("[web.type]: Invalid literal value, expected 'frontend'")
2894+
})
2895+
2896+
test('parseHumanReadableError handles regular non-union errors', () => {
2897+
const regularErrorObject = [
2898+
{
2899+
path: ['name'],
2900+
message: 'Required field is missing',
2901+
},
2902+
{
2903+
path: ['version'],
2904+
message: 'Must be a valid semver string',
2905+
},
2906+
]
2907+
2908+
const result = parseHumanReadableError(regularErrorObject)
2909+
2910+
// Verify regular errors still work as expected
2911+
expect(result).toBe('• [name]: Required field is missing\n• [version]: Must be a valid semver string\n')
2912+
expect(result).not.toContain('Union validation failed')
2913+
})
2914+
2915+
test('parseHumanReadableError handles edge cases for union error detection', () => {
2916+
// Test case 1: Union error with no unionErrors array
2917+
const noUnionErrors = [
2918+
{
2919+
code: 'invalid_union',
2920+
path: ['root'],
2921+
message: 'Invalid input',
2922+
},
2923+
]
2924+
2925+
const result1 = parseHumanReadableError(noUnionErrors)
2926+
expect(result1).toBe('• [root]: Invalid input\n')
2927+
2928+
// Test case 2: Union error with empty unionErrors array - should fall back to showing full union error
2929+
const emptyUnionErrors = [
2930+
{
2931+
code: 'invalid_union',
2932+
unionErrors: [],
2933+
path: ['root'],
2934+
message: 'Invalid input',
2935+
},
2936+
]
2937+
2938+
const result2 = parseHumanReadableError(emptyUnionErrors)
2939+
expect(result2).toContain('Union validation failed. None of the possible variants matched:')
2940+
2941+
// Test case 3: Union errors with variants that have no issues - results in empty string
2942+
const noIssuesVariants = [
2943+
{
2944+
code: 'invalid_union',
2945+
unionErrors: [
2946+
{issues: [], name: 'ZodError'},
2947+
{issues: [], name: 'ZodError'},
2948+
],
2949+
path: ['root'],
2950+
message: 'Invalid input',
2951+
},
2952+
]
2953+
2954+
const result3 = parseHumanReadableError(noIssuesVariants)
2955+
// When all variants have no issues, the best variant selection returns null issues
2956+
// resulting in no output, which falls back to the union error display
2957+
expect(result3).toContain('Union validation failed. None of the possible variants matched:')
2958+
})
2959+
2960+
test('findBestMatchingVariant scoring logic works correctly', () => {
2961+
// Test various scoring scenarios by creating mock union errors
2962+
const scenarioWithMissingFields = [
2963+
{
2964+
code: 'invalid_union',
2965+
unionErrors: [
2966+
{
2967+
// Variant with missing fields - should score highest
2968+
issues: [
2969+
{path: ['supports_moto'], message: 'Required'},
2970+
{path: ['merchant_label'], message: 'Required'},
2971+
],
2972+
name: 'ZodError',
2973+
},
2974+
{
2975+
// Variant with only type errors - should score lower
2976+
issues: [
2977+
{path: ['type'], message: 'Expected string, received number'},
2978+
{path: ['id'], message: 'Expected number, received string'},
2979+
],
2980+
name: 'ZodError',
2981+
},
2982+
{
2983+
// Variant with other errors - should score lowest
2984+
issues: [{path: ['url'], message: 'Invalid URL format'}],
2985+
name: 'ZodError',
2986+
},
2987+
],
28462988
path: [],
28472989
message: 'Invalid input',
28482990
},
28492991
]
2850-
const expectedFormatted = outputContent`\n${outputToken.errorText(
2851-
'Validation errors',
2852-
)} in tmp:\n\n${parseHumanReadableError(errorObject)}`
2853-
const abortOrReport = vi.fn()
2854-
await parseConfigurationObject(WebConfigurationSchema, 'tmp', configurationObject, abortOrReport)
28552992

2856-
expect(abortOrReport).toHaveBeenCalledWith(expectedFormatted, {}, 'tmp')
2993+
const result = parseHumanReadableError(scenarioWithMissingFields)
2994+
2995+
// Should show only the variant with missing fields (highest score)
2996+
expect(result).toContain('[supports_moto]: Required')
2997+
expect(result).toContain('[merchant_label]: Required')
2998+
2999+
// Should NOT show errors from other variants
3000+
expect(result).not.toContain('Expected string, received number')
3001+
expect(result).not.toContain('Invalid URL format')
3002+
expect(result).not.toContain('Union validation failed')
3003+
})
3004+
3005+
test('parseHumanReadableError handles undefined messages gracefully', () => {
3006+
const undefinedMessageError = [
3007+
{
3008+
path: ['field'],
3009+
message: undefined,
3010+
},
3011+
{
3012+
path: [],
3013+
// message is completely missing
3014+
},
3015+
]
3016+
3017+
const result = parseHumanReadableError(undefinedMessageError)
3018+
3019+
expect(result).toBe('• [field]: Unknown error\n• [root]: Unknown error\n')
3020+
})
3021+
3022+
test('parseHumanReadableError handles mixed scoring scenarios', () => {
3023+
// Test scenario where we need to pick between variants with different error combinations
3024+
const mixedScenario = [
3025+
{
3026+
code: 'invalid_union',
3027+
unionErrors: [
3028+
{
3029+
// Mix of missing fields and type errors - this should win due to missing fields
3030+
issues: [
3031+
{path: ['required_field'], message: 'Required'},
3032+
{path: ['wrong_type'], message: 'Expected string, received number'},
3033+
],
3034+
name: 'ZodError',
3035+
},
3036+
{
3037+
// Only type errors - should lose
3038+
issues: [
3039+
{path: ['field1'], message: 'Expected boolean, received string'},
3040+
{path: ['field2'], message: 'Expected array, received object'},
3041+
{path: ['field3'], message: 'Expected number, received string'},
3042+
],
3043+
name: 'ZodError',
3044+
},
3045+
{
3046+
// Only other validation errors - should score lowest
3047+
issues: [
3048+
{path: ['url'], message: 'Must be valid URL'},
3049+
{path: ['email'], message: 'Must be valid email'},
3050+
],
3051+
name: 'ZodError',
3052+
},
3053+
],
3054+
path: [],
3055+
message: 'Invalid input',
3056+
},
3057+
]
3058+
3059+
const result = parseHumanReadableError(mixedScenario)
3060+
3061+
// Should pick the variant with missing field (even though it has fewer total errors)
3062+
expect(result).toContain('[required_field]: Required')
3063+
expect(result).toContain('[wrong_type]: Expected string, received number')
3064+
3065+
// Should not show errors from other variants
3066+
expect(result).not.toContain('Expected boolean, received string')
3067+
expect(result).not.toContain('Must be valid URL')
3068+
expect(result).not.toContain('Union validation failed')
28573069
})
28583070
})
28593071

packages/app/src/cli/models/app/loader.ts

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,107 @@ export async function parseConfigurationFile<TSchema extends zod.ZodType>(
126126
return {...configuration, path: filepath}
127127
}
128128

129-
export function parseHumanReadableError(issues: Pick<zod.ZodIssueBase, 'path' | 'message'>[]) {
129+
interface UnionError {
130+
issues?: {path?: (string | number)[]; message: string}[]
131+
name: string
132+
}
133+
134+
interface ExtendedZodIssue {
135+
path?: (string | number)[]
136+
message?: string
137+
code?: string
138+
unionErrors?: UnionError[]
139+
}
140+
141+
/**
142+
* Finds the best matching variant from a union error by scoring each variant
143+
* based on how close it is to the user's likely intent.
144+
*/
145+
function findBestMatchingVariant(unionErrors: UnionError[]): UnionError | null {
146+
if (!unionErrors || unionErrors.length === 0) return null
147+
148+
let bestVariant: UnionError | null = null
149+
let bestScore = -1
150+
151+
for (const variant of unionErrors) {
152+
if (!variant.issues) continue
153+
154+
// Score based on error types - prioritize variants with fewer missing fields
155+
let score = 0
156+
let missingFieldCount = 0
157+
let typeErrorCount = 0
158+
let otherErrorCount = 0
159+
160+
for (const issue of variant.issues) {
161+
if (issue.message?.includes('Required') || issue.message?.includes('required')) {
162+
missingFieldCount++
163+
} else if (issue.message?.includes('Expected') && issue.message?.includes('received')) {
164+
typeErrorCount++
165+
} else {
166+
otherErrorCount++
167+
}
168+
}
169+
170+
// Scoring logic: prefer variants with fewer missing fields
171+
// Missing fields are more likely to indicate the intended variant
172+
// than type mismatches (which could indicate wrong variant entirely)
173+
if (missingFieldCount > 0) {
174+
// If there are missing fields, this could be the intended variant
175+
score = 1000 - missingFieldCount * 10 - typeErrorCount - otherErrorCount
176+
} else if (typeErrorCount > 0) {
177+
// If only type errors, less likely to be the intended variant
178+
score = 100 - typeErrorCount * 5 - otherErrorCount
179+
} else {
180+
// Other errors
181+
score = 50 - otherErrorCount
182+
}
183+
184+
if (score > bestScore) {
185+
bestScore = score
186+
bestVariant = variant
187+
}
188+
}
189+
190+
return bestVariant
191+
}
192+
193+
export function parseHumanReadableError(issues: ExtendedZodIssue[]) {
130194
let humanReadableError = ''
195+
131196
issues.forEach((issue) => {
132-
const path = issue.path ? issue?.path.join('.') : 'n/a'
133-
humanReadableError += `• [${path}]: ${issue.message}\n`
197+
// Handle union errors with smart variant detection
198+
if (issue.code === 'invalid_union' && issue.unionErrors) {
199+
// Find the variant that's most likely the intended one
200+
const bestVariant = findBestMatchingVariant(issue.unionErrors)
201+
202+
if (bestVariant && bestVariant.issues && bestVariant.issues.length > 0) {
203+
// Show errors only for the best matching variant
204+
bestVariant.issues.forEach((nestedIssue) => {
205+
const path = nestedIssue.path && nestedIssue.path.length > 0 ? nestedIssue.path.map(String).join('.') : 'root'
206+
humanReadableError += `• [${path}]: ${nestedIssue.message}\n`
207+
})
208+
} else {
209+
// Fallback to showing all variants if we can't determine the best one
210+
humanReadableError += `• Union validation failed. None of the possible variants matched:\n`
211+
issue.unionErrors.forEach((unionError, index: number) => {
212+
humanReadableError += ` Variant ${index + 1}:\n`
213+
if (unionError.issues) {
214+
unionError.issues.forEach((nestedIssue) => {
215+
const path =
216+
nestedIssue.path && nestedIssue.path.length > 0 ? nestedIssue.path.map(String).join('.') : 'root'
217+
humanReadableError += ` • [${path}]: ${nestedIssue.message}\n`
218+
})
219+
}
220+
})
221+
}
222+
} else {
223+
// Handle regular issues
224+
const path = issue.path && issue.path.length > 0 ? issue.path.map(String).join('.') : 'root'
225+
const message = issue.message ?? 'Unknown error'
226+
humanReadableError += `• [${path}]: ${message}\n`
227+
}
134228
})
229+
135230
return humanReadableError
136231
}
137232

0 commit comments

Comments
 (0)