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
77 changes: 64 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 All @@ -9,6 +10,7 @@ import metadata from '../../../metadata.js'
import {outputResult} from '@shopify/cli-kit/node/output'
import {AbortError} from '@shopify/cli-kit/node/error'
import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file'
import {renderError, renderSuccess} from '@shopify/cli-kit/node/ui'
import {describe, expect, test, vi} from 'vitest'

vi.mock('../../../services/app-context.js')
Expand Down Expand Up @@ -40,39 +42,74 @@ 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)
expect(renderSuccess).toHaveBeenCalledWith({headline: "App configuration 'shopify.app.toml' is valid."})
expect(outputResult).not.toHaveBeenCalled()
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)
expect(outputResult).toHaveBeenCalledWith(appConfigValidateJsonOutputSchema.encode({valid: true, issues: []}))
expect(renderSuccess).not.toHaveBeenCalled()
await expectValidationMetadataCalls({cmd_app_validate_json: true})
})

test('calls validateApp with json: true when -j flag is passed', async () => {
test('outputs the encoded invalid payload and aborts when validation fails with --json', async () => {
const app = testAppLinked()
mockHealthyProject()
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)
vi.mocked(validateApp).mockResolvedValue()
const issues = [{file: '/app/shopify.app.toml', message: 'Required', path: ['name'], code: 'invalid_type'}]
vi.mocked(validateApp).mockResolvedValue({valid: false, issues})

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

expect(outputResult).toHaveBeenCalledWith(appConfigValidateJsonOutputSchema.encode({valid: false, issues}))
expect(renderError).not.toHaveBeenCalled()
})

test('renders validation errors and aborts when validation fails in text mode', async () => {
const app = testAppLinked()
mockHealthyProject()
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)
vi.mocked(validateApp).mockResolvedValue({
valid: false,
issues: [{file: '/app/shopify.app.toml', message: 'client_id is required'}],
})

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

expect(renderError).toHaveBeenCalledWith({
headline: 'Validation errors found.',
body: expect.stringContaining('client_id is required'),
})
expect(outputResult).not.toHaveBeenCalled()
})

test('accepts the -j alias', async () => {
const app = testAppLinked()
mockHealthyProject()
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)
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 +120,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 +135,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 +251,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})
})
38 changes: 22 additions & 16 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, type AppConfigValidateResult} 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 @@ -20,11 +22,22 @@ async function recordValidationFailure(issueCount: number, fileCount: number) {
}))
}

async function failJsonValidation(issues: AppConfigValidateResult['issues']): Promise<never> {
const fileCount = new Set(issues.map((issue) => issue.file)).size
await recordValidationFailure(issues.length, fileCount)
outputResult(appConfigValidateJsonOutputSchema.encode({valid: false, issues}))
throw new AbortSilentError()
}

export default class Validate extends AppLinkedCommand {
static summary = 'Validate your app configuration and extensions.'

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 @@ -46,10 +59,7 @@ export default class Validate extends AppLinkedCommand {
project = await Project.load(flags.path)
} catch (err) {
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))
throw new AbortSilentError()
await failJsonValidation([{message: unstyled(stringifyMessage(err.message)).trim()}])
}
throw err
}
Expand All @@ -63,23 +73,19 @@ export default class Validate extends AppLinkedCommand {
})
} catch (err) {
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))
throw new AbortSilentError()
await failJsonValidation([{message: unstyled(stringifyMessage(err.message)).trim()}])
}
throw err
}

const configErrors = errorsForConfig(project, activeConfig.file)
if (configErrors.length > 0) {
const issues = configErrors.map((err) => ({file: err.path, message: err.message}))
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))
throw new AbortSilentError()
await failJsonValidation(issues)
}
const fileCount = new Set(configErrors.map((err) => err.path)).size
await recordValidationFailure(issues.length, fileCount)
renderError({
headline: 'Validation errors found.',
body: issues.map((issue) => `• ${issue.message}`).join('\n'),
Expand All @@ -104,14 +110,14 @@ export default class Validate extends AppLinkedCommand {
const message = err instanceof AbortError ? unstyled(stringifyMessage(err.message)).trim() : ''
const isValidationError = message.startsWith('Validation errors in ')
if (isValidationError && flags.json) {
await recordValidationFailure(1, 1)
outputResult(JSON.stringify({valid: false, issues: [{message}]}, null, 2))
throw new AbortSilentError()
await failJsonValidation([{message}])
}
throw err
}

await validateApp(app, {json: flags.json})
const result = await validateApp(app)
renderAppConfigValidateResult(result, app.configPath, flags.json ? 'json' : 'text')
if (!result.valid) throw new AbortSilentError()

return {app}
}
Expand Down
Loading
Loading