Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions packages/app/src/cli/commands/app/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {validateApp} from '../../../services/validate.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error'
import {outputResult, stringifyMessage, unstyled} from '@shopify/cli-kit/node/output'

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

const {app} = await linkedAppContext({
directory: flags.path,
clientId: flags['client-id'],
forceRelink: flags.reset,
userProvidedConfigName: flags.config,
unsafeTolerateErrors: true,
})
let app
try {
const context = await linkedAppContext({
directory: flags.path,
clientId: flags['client-id'],
forceRelink: flags.reset,
userProvidedConfigName: flags.config,
unsafeTolerateErrors: true,
})
app = context.app
} catch (err) {
if (err instanceof AbortError && flags.json) {
const message = unstyled(stringifyMessage(err.message)).trim()
outputResult(JSON.stringify({valid: false, errors: [{message}]}, null, 2))
throw new AbortSilentError()
}
throw err
}

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

Expand Down
50 changes: 49 additions & 1 deletion packages/app/src/cli/models/app/error-parsing.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {parseHumanReadableError} from './error-parsing.js'
import {parseHumanReadableError, parseStructuredErrors} from './error-parsing.js'
import {describe, expect, test} from 'vitest'

describe('parseHumanReadableError', () => {
Expand Down Expand Up @@ -235,3 +235,51 @@ describe('parseHumanReadableError', () => {
expect(result).not.toContain('Union validation failed')
})
})

describe('parseStructuredErrors', () => {
test('converts regular issues to structured format', () => {
const issues = [
{path: ['name'], message: 'Required', code: 'invalid_type'},
{path: ['version'], message: 'Must be a string', code: 'invalid_type'},
]

const result = parseStructuredErrors(issues)

expect(result).toEqual([
{path: ['name'], message: 'Required', code: 'invalid_type'},
{path: ['version'], message: 'Must be a string', code: 'invalid_type'},
])
})

test('selects best union variant', () => {
const issues = [
{
code: 'invalid_union',
unionErrors: [
{
issues: [{path: ['field'], message: 'Required'}],
name: 'ZodError',
},
{
issues: [{path: ['type'], message: 'Expected string, received number'}],
name: 'ZodError',
},
],
path: [],
message: 'Invalid input',
},
]

const result = parseStructuredErrors(issues)

expect(result).toEqual([{path: ['field'], message: 'Required', code: 'invalid_union'}])
})

test('handles missing message with fallback', () => {
const issues = [{path: ['x'], code: 'custom'}]

const result = parseStructuredErrors(issues)

expect(result).toEqual([{path: ['x'], message: 'Unknown error', code: 'custom'}])
})
})
23 changes: 23 additions & 0 deletions packages/app/src/cli/models/app/error-parsing.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Suggestion: parseHumanReadableError is no longer imported by any production code — the only references are in error-parsing.ts (definition) and error-parsing.test.ts (tests). It was replaced by parseStructuredErrors in loader.ts. Keeping dead code with active tests is worse than no code — it signals the function is important when it isn't, and the two parallel implementations of union-variant error parsing have subtly different fallback behavior (see the missing fallback bug above). If it's intentionally retained for a follow-up PR, a comment noting the planned usage would prevent someone from removing it prematurely.

Suggestion: Verify with a grep that there are no remaining callers, then remove parseHumanReadableError, formatErrorLine, and their tests. If kept intentionally, add a comment explaining why.

Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
interface ParsedIssue {
path?: (string | number)[]
message: string
code?: string
}

interface UnionError {
issues?: {path?: (string | number)[]; message: string}[]
name: string
Expand Down Expand Up @@ -65,6 +71,23 @@ function formatErrorLine(issue: {path?: (string | number)[]; message?: string},
return `${indent}• [${path}]: ${message}\n`
}

export function parseStructuredErrors(issues: ExtendedZodIssue[]): ParsedIssue[] {
const result: ParsedIssue[] = []
for (const issue of issues) {
if (issue.code === 'invalid_union' && issue.unionErrors) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Bug: When findBestMatchingVariant returns null (e.g., all union variants have empty issues arrays), parseStructuredErrors adds nothing to the result — the union error vanishes entirely. The existing parseHumanReadableError at line 105-114 has an explicit fallback that handles this case, indicating it occurs in practice. The new function has no equivalent fallback, meaning certain malformed union validation errors will produce an empty error array for genuinely invalid input.

Suggestion: Add a fallback when bestVariant is null or has no issues, mirroring the existing handling:

Suggested change
if (issue.code === 'invalid_union' && issue.unionErrors) {
const bestVariant = findBestMatchingVariant(issue.unionErrors)
if (bestVariant?.issues?.length) {
for (const nested of bestVariant.issues) {
result.push({path: nested.path, message: nested.message, code: issue.code})
}
} else {
result.push({path: issue.path, message: issue.message ?? "Configuration doesn't match any expected format", code: issue.code})
}

const bestVariant = findBestMatchingVariant(issue.unionErrors)
if (bestVariant?.issues?.length) {
for (const nested of bestVariant.issues) {
result.push({path: nested.path, message: nested.message, code: issue.code})
}
}
} else {
result.push({path: issue.path, message: issue.message ?? 'Unknown error', code: issue.code})
}
}
return result
}

export function parseHumanReadableError(issues: ExtendedZodIssue[]) {
let humanReadableError = ''

Expand Down
48 changes: 19 additions & 29 deletions packages/app/src/cli/models/app/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
getAppConfigurationContext,
loadConfigForAppCreation,
reloadApp,
ConfigurationError,
} from './loader.js'
import {App, AppInterface, AppLinkedInterface, AppSchema, WebConfigurationSchema} from './app.js'
import {DEFAULT_CONFIG, buildVersionedAppSchema, getWebhookConfig} from './app.test-data.js'
Expand Down Expand Up @@ -2767,45 +2766,38 @@ describe('parseConfigurationObject', () => {
},
]
const {schema} = await buildVersionedAppSchema()
expect(() => parseConfigurationObject(schema, 'tmp', configurationObject)).toThrow(ConfigurationError)
const result = parseConfigurationObject(schema, 'tmp', configurationObject)
expect(result.errors).toBeDefined()
})

test('throws an error when client_id is missing in app schema TOML file', async () => {
test('returns errors when client_id is missing in app schema TOML file', async () => {
const configurationObject = {
scopes: [],
}

expect(() => parseConfigurationObject(AppSchema, 'tmp', configurationObject)).toThrow(ConfigurationError)
expect(() => parseConfigurationObject(AppSchema, 'tmp', configurationObject)).toThrow('[client_id]: Required')
const result = parseConfigurationObject(AppSchema, 'tmp', configurationObject)
expect(result.errors).toBeDefined()
expect(result.errors!.some((err) => err.message === 'Required' && err.path?.includes('client_id'))).toBe(true)
})

test('throws an error if fields are missing in a frontend config web TOML file', async () => {
test('returns errors if fields are missing in a frontend config web TOML file', async () => {
const configurationObject = {
type: 11,
commands: {dev: ''},
roles: 1,
}

let thrown: ConfigurationError | undefined
try {
parseConfigurationObject(WebConfigurationSchema, 'tmp', configurationObject)
expect.fail('Expected ConfigurationError to be thrown')
} catch (err) {
if (err instanceof ConfigurationError) {
thrown = err
} else {
throw err
}
}
const result = parseConfigurationObject(WebConfigurationSchema, 'tmp', configurationObject)
expect(result.errors).toBeDefined()

// The enhanced union handling should show only the most relevant errors
// instead of showing all variants, making it much more user-friendly
expect(thrown.message).toContain('[roles]: Expected array, received number')
const messages = result.errors!.map((err) => err.message)
expect(messages.some((msg) => msg.includes('Expected array, received number'))).toBe(true)

// Should NOT show the confusing union variant breakdown
expect(thrown.message).not.toContain('Union validation failed')
expect(thrown.message).not.toContain('Option 1:')
expect(thrown.message).not.toContain('Option 2:')
// Should NOT show confusing union variant breakdown
expect(messages.join('\n')).not.toContain('Union validation failed')
expect(messages.join('\n')).not.toContain('Option 1:')
expect(messages.join('\n')).not.toContain('Option 2:')
})
})

Expand Down Expand Up @@ -3327,13 +3319,11 @@ describe('WebhooksSchema', () => {

async function setupParsing(errorObj: zod.ZodIssue | {}, webhookConfigOverrides: WebhooksConfig) {
const toParse = getWebhookConfig(webhookConfigOverrides)
try {
const parsedConfiguration = parseConfigurationObject(WebhooksSchema, 'tmp', toParse)
return {threw: false as const, parsedConfiguration, error: undefined}
} catch (err) {
if (!(err instanceof ConfigurationError)) throw err
return {threw: true as const, parsedConfiguration: undefined as any, error: err}
const result = parseConfigurationObject(WebhooksSchema, 'tmp', toParse)
if (result.errors) {
return {threw: true as const, parsedConfiguration: undefined as any, error: result.errors}
}
return {threw: false as const, parsedConfiguration: result.data, error: undefined}
}
})

Expand Down
Loading
Loading