Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .changeset/typed-app-config-validate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
'@shopify/cli': minor
---
Expose the JSON output schema for app config validate.
41 changes: 28 additions & 13 deletions packages/app/src/cli/commands/app/config/validate.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Validate from './validate.js'
import {appConfigValidateJsonOutputSchema} from '../../../services/validate/types.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {validateApp} from '../../../services/validate.js'
import {testAppLinked} from '../../../models/app/app.test-data.js'
Expand Down Expand Up @@ -40,39 +41,39 @@ describe('app config validate command', () => {
expect(Validate.flags['client-id']?.exclusive).toEqual(['config'])
})

test('calls validateApp with json: false by default', async () => {
test('returns validation facts and presents text by default', async () => {
const app = testAppLinked()
mockHealthyProject()
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)
vi.mocked(validateApp).mockResolvedValue()
vi.mocked(validateApp).mockResolvedValue({valid: true, issues: []})

await Validate.run([], import.meta.url)

expect(validateApp).toHaveBeenCalledWith(app, {json: false})
expect(validateApp).toHaveBeenCalledWith(app)
await expectValidationMetadataCalls({cmd_app_validate_json: false})
})

test('calls validateApp with json: true when --json flag is passed', async () => {
test('encodes validation facts when --json is passed', async () => {
const app = testAppLinked()
mockHealthyProject()
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)
vi.mocked(validateApp).mockResolvedValue()
vi.mocked(validateApp).mockResolvedValue({valid: true, issues: []})

await Validate.run(['--json'], import.meta.url)

expect(validateApp).toHaveBeenCalledWith(app, {json: true})
expect(validateApp).toHaveBeenCalledWith(app)
await expectValidationMetadataCalls({cmd_app_validate_json: true})
})

test('calls validateApp with json: true when -j flag is passed', async () => {
test('accepts the -j alias', async () => {
const app = testAppLinked()
mockHealthyProject()
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)
vi.mocked(validateApp).mockResolvedValue()
vi.mocked(validateApp).mockResolvedValue({valid: true, issues: []})

await Validate.run(['-j'], import.meta.url)

expect(validateApp).toHaveBeenCalledWith(app, {json: true})
expect(validateApp).toHaveBeenCalledWith(app)
await expectValidationMetadataCalls({cmd_app_validate_json: true})
})

Expand All @@ -83,7 +84,7 @@ describe('app config validate command', () => {
file: new TomlFile('shopify.app.staging.toml', {}),
} as any)
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)
vi.mocked(validateApp).mockResolvedValue()
vi.mocked(validateApp).mockResolvedValue({valid: true, issues: []})

await Validate.run(['--client-id', 'api-key'], import.meta.url)

Expand All @@ -98,23 +99,23 @@ describe('app config validate command', () => {
userProvidedConfigName: 'shopify.app.staging.toml',
unsafeTolerateErrors: true,
})
expect(validateApp).toHaveBeenCalledWith(app, {json: false})
expect(validateApp).toHaveBeenCalledWith(app)
await expectValidationMetadataCalls({cmd_app_validate_json: false})
})

test('keeps active config prompts enabled when --client-id is not passed', async () => {
const app = testAppLinked()
mockHealthyProject()
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)
vi.mocked(validateApp).mockResolvedValue()
vi.mocked(validateApp).mockResolvedValue({valid: true, issues: []})

await Validate.run([], import.meta.url)

expect(selectActiveConfig).toHaveBeenCalledWith(expect.anything(), undefined, {
clientId: undefined,
skipPrompts: false,
})
expect(validateApp).toHaveBeenCalledWith(app, {json: false})
expect(validateApp).toHaveBeenCalledWith(app)
await expectValidationMetadataCalls({cmd_app_validate_json: false})
})

Expand Down Expand Up @@ -214,3 +215,17 @@ describe('app config validate command', () => {
)
})
})

test('exposes the validation schema and JSON flag', () => {
expect(Validate.jsonOutputSchema).toBe(appConfigValidateJsonOutputSchema)
expect(Validate.flags.json).toBeDefined()
expect(Validate.description).toContain('AppConfigValidateResult')
})

test('does not convert authentication failures into validation results', async () => {
mockHealthyProject()
vi.mocked(linkedAppContext).mockRejectedValue(new AbortError('Authentication failed'))
await expect(Validate.run(['--json'], import.meta.url)).rejects.toThrow()
expect(outputResult).not.toHaveBeenCalled()
await expectValidationMetadataCalls({cmd_app_validate_json: true})
})
17 changes: 12 additions & 5 deletions packages/app/src/cli/commands/app/config/validate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {appFlags} from '../../../flags.js'
import {appConfigValidateJsonOutputSchema} from '../../../services/validate/types.js'
import {renderAppConfigValidateResult} from '../../../services/validate/result.js'
import {validateApp} from '../../../services/validate.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
import {linkedAppContext} from '../../../services/app-context.js'
Expand All @@ -25,6 +27,10 @@ export default class Validate extends AppLinkedCommand {

static descriptionWithMarkdown = `Validates the selected app configuration file and all extension configurations against their schemas and reports any errors found.`

static get jsonOutputSchema() {
return appConfigValidateJsonOutputSchema
}

static description = this.descriptionForHelp()

static flags = {
Expand All @@ -48,7 +54,7 @@ export default class Validate extends AppLinkedCommand {
if (err instanceof AbortError && flags.json) {
await recordValidationFailure(1, 1)
const message = unstyled(stringifyMessage(err.message)).trim()
outputResult(JSON.stringify({valid: false, issues: [{message}]}, null, 2))
outputResult(appConfigValidateJsonOutputSchema.encode({valid: false, issues: [{message}]}))
throw new AbortSilentError()
}
throw err
Expand All @@ -65,7 +71,7 @@ export default class Validate extends AppLinkedCommand {
if (err instanceof AbortError && flags.json) {
await recordValidationFailure(1, 1)
const message = unstyled(stringifyMessage(err.message)).trim()
outputResult(JSON.stringify({valid: false, issues: [{message}]}, null, 2))
outputResult(appConfigValidateJsonOutputSchema.encode({valid: false, issues: [{message}]}))
throw new AbortSilentError()
}
throw err
Expand All @@ -77,7 +83,7 @@ export default class Validate extends AppLinkedCommand {
const fileCount = new Set(configErrors.map((err) => err.path)).size
await recordValidationFailure(issues.length, fileCount)
if (flags.json) {
outputResult(JSON.stringify({valid: false, issues}, null, 2))
outputResult(appConfigValidateJsonOutputSchema.encode({valid: false, issues}))
throw new AbortSilentError()
}
renderError({
Expand Down Expand Up @@ -105,13 +111,14 @@ export default class Validate extends AppLinkedCommand {
const isValidationError = message.startsWith('Validation errors in ')
if (isValidationError && flags.json) {
await recordValidationFailure(1, 1)
outputResult(JSON.stringify({valid: false, issues: [{message}]}, null, 2))
outputResult(appConfigValidateJsonOutputSchema.encode({valid: false, issues: [{message}]}))
throw new AbortSilentError()
}
throw err
}

await validateApp(app, {json: flags.json})
const result = await validateApp(app)
renderAppConfigValidateResult(result, app.configPath, flags.json ? 'json' : 'text')

return {app}
}
Expand Down
20 changes: 19 additions & 1 deletion packages/app/src/cli/services/validate.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {validateApp} from './validate.js'
import {validateApp as validateAppData} from './validate.js'
import {renderAppConfigValidateResult} from './validate/result.js'
import {testAppLinked} from '../models/app/app.test-data.js'
import {AppErrors, formatConfigurationError} from '../models/app/loader.js'
import metadata from '../metadata.js'
Expand Down Expand Up @@ -225,3 +226,20 @@ describe('validateApp', () => {
})
})
})

// Keep the existing presentation assertions at the new service/presenter boundary.
async function validateApp(app: Parameters<typeof validateAppData>[0], options = {json: false}) {
const result = await validateAppData(app)
renderAppConfigValidateResult(result, app.configPath, options.json ? 'json' : 'text')
}

test('returns validation facts without presenting or throwing for an invalid app', async () => {
const app = testAppLinked()
app.errors.addError({file: app.configPath, message: 'Required', path: ['name'], code: 'invalid_type'})
await expect(validateAppData(app)).resolves.toEqual({
valid: false,
issues: [{file: app.configPath, message: 'Required', path: ['name'], code: 'invalid_type'}],
})
expect(outputResult).not.toHaveBeenCalled()
expect(renderError).not.toHaveBeenCalled()
})
45 changes: 7 additions & 38 deletions packages/app/src/cli/services/validate.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
import {type AppConfigValidateResult} from './validate/types.js'
import {AppLinkedInterface} from '../models/app/app.js'
import {formatConfigurationError} from '../models/app/loader.js'
import metadata from '../metadata.js'
import {outputResult} from '@shopify/cli-kit/node/output'
import {renderError, renderSuccess} from '@shopify/cli-kit/node/ui'
import {AbortSilentError} from '@shopify/cli-kit/node/error'
import {basename} from '@shopify/cli-kit/node/path'

interface ValidateAppOptions {
json: boolean
}

async function recordValidationMetadata(valid: boolean, errors: {file: string}[]) {
const fileCount = new Set(errors.map((error) => error.file)).size
Expand All @@ -20,34 +12,11 @@ async function recordValidationMetadata(valid: boolean, errors: {file: string}[]
}))
}

export async function validateApp(app: AppLinkedInterface, options: ValidateAppOptions = {json: false}): Promise<void> {
const appErrors = app.errors

if (!appErrors || appErrors.isEmpty()) {
await recordValidationMetadata(true, [])

if (options.json) {
outputResult(JSON.stringify({valid: true, issues: []}, null, 2))
return
}

renderSuccess({headline: `App configuration '${basename(app.configPath)}' is valid.`})
return
export async function validateApp(app: AppLinkedInterface): Promise<AppConfigValidateResult> {
const errors = app.errors?.getErrors() ?? []
await recordValidationMetadata(errors.length === 0, errors)
return {
valid: errors.length === 0,
issues: errors.map(({file, message, path, code}) => ({file, message, path, code})),
}

const errors = appErrors.getErrors()
await recordValidationMetadata(false, errors)

if (options.json) {
const issues = errors.map(({file, message, path, code}) => ({file, message, path, code}))
outputResult(JSON.stringify({valid: false, issues}, null, 2))
throw new AbortSilentError()
}

renderError({
headline: 'Validation errors found.',
body: errors.map((err) => `• ${formatConfigurationError(err)}`).join('\n'),
})

throw new AbortSilentError()
}
58 changes: 58 additions & 0 deletions packages/app/src/cli/services/validate/result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {appConfigValidateJsonOutputSchema} from './types.js'
import {describe, expect, test, vi} from 'vitest'
import type * as Errors from '@shopify/cli-kit/node/error'
import type * as Output from '@shopify/cli-kit/node/output'
import type * as CommandEvents from '@shopify/cli-kit/node/command-events'
import type * as OutputTesting from '@shopify/cli-kit/node/testing/output'

const cases = [
{valid: true, issues: []},
{valid: false, issues: [{message: 'No config found'}]},
{valid: false, issues: [{file: '/app/shopify.app.toml', message: 'Invalid TOML'}]},
{valid: false, issues: [{file: '/app/shopify.app.toml', message: 'Required', path: ['name'], code: 'invalid_type'}]},
]

describe('validation contract', () => {
test.each(cases)('preserves exact JSON for %j', (result) => {
expect(appConfigValidateJsonOutputSchema.encode(result)).toBe(JSON.stringify(result, null, 2))
})

test.each([
{valid: 'true', issues: []},
{valid: true, issues: [{message: 123}]},
{valid: false, issues: [{message: 'Required', path: [false]}]},
{valid: false, issues: [{message: 'Required', code: null}]},
])('rejects malformed data %j', (result) => {
expect(() => appConfigValidateJsonOutputSchema.validate(result)).toThrow()
})

test.each(cases)('writes exactly one result and routes diagnostics separately for %j', async (result) => {
vi.stubEnv('SHOPIFY_UNIT_TEST', '0')
vi.resetModules()
const {mockAndCaptureStandardStreams} = await vi.importActual<typeof OutputTesting>(
'@shopify/cli-kit/node/testing/output',
)
const {runWithCommandEventsForCommand} = await vi.importActual<typeof CommandEvents>(
'@shopify/cli-kit/node/command-events',
)
const {outputInfo} = await vi.importActual<typeof Output>('@shopify/cli-kit/node/output')
const {AbortSilentError} = await vi.importActual<typeof Errors>('@shopify/cli-kit/node/error')
const {renderAppConfigValidateResult} = await import('./result.js')
const streams = mockAndCaptureStandardStreams()
try {
const render = () =>
runWithCommandEventsForCommand(['--json'], () => {
outputInfo('Checking configuration')
renderAppConfigValidateResult(result, '/app/shopify.app.toml', 'json')
})
if (result.valid) render()
else expect(render).toThrow(AbortSilentError)
expect(streams.stdout()).toBe(`${JSON.stringify(result, null, 2)}\n`)
expect(JSON.parse(streams.stderr())).toMatchObject({type: 'diagnostic', message: 'Checking configuration'})
} finally {
streams.restore()
vi.unstubAllEnvs()
vi.resetModules()
}
})
})
26 changes: 26 additions & 0 deletions packages/app/src/cli/services/validate/result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {appConfigValidateJsonOutputSchema, type AppConfigValidateResult} from './types.js'
import {formatConfigurationError} from '../../models/app/loader.js'
import {outputResult} from '@shopify/cli-kit/node/output'
import {renderError, renderSuccess} from '@shopify/cli-kit/node/ui'
import {AbortSilentError} from '@shopify/cli-kit/node/error'
import {basename} from '@shopify/cli-kit/node/path'

export function renderAppConfigValidateResult(
result: AppConfigValidateResult,
configPath: string,
format: 'json' | 'text',
): void {
if (format === 'json') {
outputResult(appConfigValidateJsonOutputSchema.encode(result))
} else if (result.valid) {
renderSuccess({headline: `App configuration '${basename(configPath)}' is valid.`})
} else {
renderError({
headline: 'Validation errors found.',
body: result.issues
.map((issue) => `• ${formatConfigurationError({...issue, file: issue.file ?? configPath})}`)
.join('\n'),
})
}
if (!result.valid) throw new AbortSilentError()
}
18 changes: 18 additions & 0 deletions packages/app/src/cli/services/validate/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema'
import {zod} from '@shopify/cli-kit/node/schema'

// Keep field order aligned with the existing validation JSON producer.
const validationIssueSchema = zod.object({
file: zod.string().optional(),
message: zod.string(),
path: zod.array(zod.union([zod.string(), zod.number()])).optional(),
code: zod.string().optional(),
})

export const appConfigValidateJsonOutputSchema = defineJsonOutputSchema({
name: 'AppConfigValidateResult',
schema: zod.object({valid: zod.boolean(), issues: zod.array(validationIssueSchema)}),
definitions: {ValidationIssue: validationIssueSchema},
})

export type AppConfigValidateResult = InferJsonOutputSchema<typeof appConfigValidateJsonOutputSchema>
Loading
Loading