Skip to content

Commit 9c4c2c4

Browse files
authored
Merge pull request #7126 from Shopify/ryan/structured-app-errors
Refactor app validation errors: result-based parsing, structured error data
2 parents 8675dae + 21329b0 commit 9c4c2c4

12 files changed

Lines changed: 337 additions & 504 deletions

File tree

packages/app/src/cli/commands/app/config/validate.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {validateApp} from '../../../services/validate.js'
33
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
44
import {linkedAppContext} from '../../../services/app-context.js'
55
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
6+
import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error'
7+
import {outputResult, stringifyMessage, unstyled} from '@shopify/cli-kit/node/output'
68

79
export default class Validate extends AppLinkedCommand {
810
static summary = 'Validate your app configuration and extensions.'
@@ -20,13 +22,24 @@ export default class Validate extends AppLinkedCommand {
2022
public async run(): Promise<AppLinkedCommandOutput> {
2123
const {flags} = await this.parse(Validate)
2224

23-
const {app} = await linkedAppContext({
24-
directory: flags.path,
25-
clientId: flags['client-id'],
26-
forceRelink: flags.reset,
27-
userProvidedConfigName: flags.config,
28-
unsafeTolerateErrors: true,
29-
})
25+
let app
26+
try {
27+
const context = await linkedAppContext({
28+
directory: flags.path,
29+
clientId: flags['client-id'],
30+
forceRelink: flags.reset,
31+
userProvidedConfigName: flags.config,
32+
unsafeTolerateErrors: true,
33+
})
34+
app = context.app
35+
} catch (err) {
36+
if (err instanceof AbortError && flags.json) {
37+
const message = unstyled(stringifyMessage(err.message)).trim()
38+
outputResult(JSON.stringify({valid: false, errors: [{message}]}, null, 2))
39+
throw new AbortSilentError()
40+
}
41+
throw err
42+
}
3043

3144
await validateApp(app, {json: flags.json})
3245

Lines changed: 41 additions & 187 deletions
Original file line numberDiff line numberDiff line change
@@ -1,237 +1,91 @@
1-
import {parseHumanReadableError} from './error-parsing.js'
1+
import {parseStructuredErrors} from './error-parsing.js'
22
import {describe, expect, test} from 'vitest'
33

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-
},
4+
describe('parseStructuredErrors', () => {
5+
test('converts regular issues to structured format', () => {
6+
const issues = [
7+
{path: ['name'], message: 'Required', code: 'invalid_type'},
8+
{path: ['version'], message: 'Must be a string', code: 'invalid_type'},
459
]
4610

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:')
11+
const result = parseStructuredErrors(issues)
5812

59-
// Should NOT show errors from the less likely option 2
60-
expect(result).not.toContain("[web.type]: Invalid literal value, expected 'frontend'")
13+
expect(result).toEqual([
14+
{path: ['name'], message: 'Required', code: 'invalid_type'},
15+
{path: ['version'], message: 'Must be a string', code: 'invalid_type'},
16+
])
6117
})
6218

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 = [
19+
test('selects best union variant based on scoring', () => {
20+
const issues = [
13021
{
13122
code: 'invalid_union',
13223
unionErrors: [
13324
{
134-
// Variant with missing fields - should score highest
13525
issues: [
13626
{path: ['supports_moto'], message: 'Required'},
13727
{path: ['merchant_label'], message: 'Required'},
13828
],
13929
name: 'ZodError',
14030
},
14131
{
142-
// Variant with only type errors - should score lower
14332
issues: [
14433
{path: ['type'], message: 'Expected string, received number'},
14534
{path: ['id'], message: 'Expected number, received string'},
14635
],
14736
name: 'ZodError',
14837
},
149-
{
150-
// Variant with other errors - should score lowest
151-
issues: [{path: ['url'], message: 'Invalid URL format'}],
152-
name: 'ZodError',
153-
},
15438
],
15539
path: [],
15640
message: 'Invalid input',
15741
},
15842
]
15943

160-
const result = parseHumanReadableError(scenarioWithMissingFields)
44+
const result = parseStructuredErrors(issues)
16145

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')
46+
expect(result).toEqual([
47+
{path: ['supports_moto'], message: 'Required', code: 'invalid_union'},
48+
{path: ['merchant_label'], message: 'Required', code: 'invalid_union'},
49+
])
17050
})
17151

172-
test('handles undefined messages gracefully', () => {
173-
const undefinedMessageError = [
174-
{
175-
path: ['field'],
176-
message: undefined,
177-
},
52+
test('falls back when all union variants have empty issues', () => {
53+
const issues = [
17854
{
179-
path: [],
180-
// message is completely missing
55+
code: 'invalid_union',
56+
unionErrors: [
57+
{issues: [], name: 'ZodError'},
58+
{issues: [], name: 'ZodError'},
59+
],
60+
path: ['root'],
61+
message: 'Invalid input',
18162
},
18263
]
18364

184-
const result = parseHumanReadableError(undefinedMessageError)
65+
const result = parseStructuredErrors(issues)
18566

186-
expect(result).toBe('• [field]: Unknown error\n• [root]: Unknown error\n')
67+
expect(result).toEqual([{path: ['root'], message: 'Invalid input', code: 'invalid_union'}])
18768
})
18869

189-
test('handles mixed scoring scenarios', () => {
190-
// Test scenario where we need to pick between variants with different error combinations
191-
const mixedScenario = [
70+
test('falls back when union has no unionErrors array', () => {
71+
const issues = [
19272
{
19373
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: [],
74+
path: ['field'],
22275
message: 'Invalid input',
22376
},
22477
]
22578

226-
const result = parseHumanReadableError(mixedScenario)
79+
const result = parseStructuredErrors(issues)
80+
81+
expect(result).toEqual([{path: ['field'], message: 'Invalid input', code: 'invalid_union'}])
82+
})
83+
84+
test('handles missing message with fallback', () => {
85+
const issues = [{path: ['x'], code: 'custom'}]
22786

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')
87+
const result = parseStructuredErrors(issues)
23188

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')
89+
expect(result).toEqual([{path: ['x'], message: 'Unknown error', code: 'custom'}])
23690
})
23791
})

packages/app/src/cli/models/app/error-parsing.ts

Lines changed: 19 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
interface ParsedIssue {
2+
path?: (string | number)[]
3+
message: string
4+
code?: string
5+
}
6+
17
interface UnionError {
28
issues?: {path?: (string | number)[]; message: string}[]
39
name: string
@@ -56,44 +62,25 @@ function findBestMatchingVariant(unionErrors: UnionError[]): UnionError | null {
5662
return bestVariant
5763
}
5864

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
65+
export function parseStructuredErrors(issues: ExtendedZodIssue[]): ParsedIssue[] {
66+
const result: ParsedIssue[] = []
67+
for (const issue of issues) {
7368
if (issue.code === 'invalid_union' && issue.unionErrors) {
74-
// Find the variant that's most likely the intended one
7569
const bestVariant = findBestMatchingVariant(issue.unionErrors)
76-
7770
if (bestVariant?.issues?.length) {
78-
// Show errors only for the best matching variant
79-
bestVariant.issues.forEach((nestedIssue) => {
80-
humanReadableError += formatErrorLine(nestedIssue)
81-
})
71+
for (const nested of bestVariant.issues) {
72+
result.push({path: nested.path, message: nested.message, code: issue.code})
73+
}
8274
} 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-
})
75+
result.push({
76+
path: issue.path,
77+
message: issue.message ?? "Configuration doesn't match any expected format",
78+
code: issue.code,
9079
})
9180
}
9281
} else {
93-
// Handle regular issues
94-
humanReadableError += formatErrorLine(issue)
82+
result.push({path: issue.path, message: issue.message ?? 'Unknown error', code: issue.code})
9583
}
96-
})
97-
98-
return humanReadableError
84+
}
85+
return result
9986
}

0 commit comments

Comments
 (0)