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
49 changes: 49 additions & 0 deletions packages/app/src/cli/commands/app/validate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Validate from './validate.js'
import {linkedAppContext} from '../../services/app-context.js'
import {validateApp} from '../../services/validate.js'
import {testAppLinked} from '../../models/app/app.test-data.js'
import {describe, expect, test, vi} from 'vitest'

vi.mock('../../services/app-context.js')
vi.mock('../../services/validate.js')

describe('app validate command', () => {
test('calls validateApp with json: false by default', async () => {
// Given
const app = testAppLinked()
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)
vi.mocked(validateApp).mockResolvedValue()

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

// Then
expect(validateApp).toHaveBeenCalledWith(app, {json: false})
})

test('calls validateApp with json: true when --json flag is passed', async () => {
// Given
const app = testAppLinked()
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)
vi.mocked(validateApp).mockResolvedValue()

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

// Then
expect(validateApp).toHaveBeenCalledWith(app, {json: true})
})

test('calls validateApp with json: true when -j flag is passed', async () => {
// Given
const app = testAppLinked()
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)
vi.mocked(validateApp).mockResolvedValue()

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

// Then
expect(validateApp).toHaveBeenCalledWith(app, {json: true})
})
})
5 changes: 3 additions & 2 deletions packages/app/src/cli/commands/app/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {appFlags} from '../../flags.js'
import {validateApp} from '../../services/validate.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js'
import {linkedAppContext} from '../../services/app-context.js'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'

export default class Validate extends AppLinkedCommand {
static summary = 'Validate your app configuration and extensions.'
Expand All @@ -14,6 +14,7 @@ export default class Validate extends AppLinkedCommand {
static flags = {
...globalFlags,
...appFlags,
...jsonFlag,
}

public async run(): Promise<AppLinkedCommandOutput> {
Expand All @@ -27,7 +28,7 @@ export default class Validate extends AppLinkedCommand {
unsafeReportMode: true,
})

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

return {app}
}
Expand Down
48 changes: 48 additions & 0 deletions packages/app/src/cli/services/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@ import {validateApp} from './validate.js'
import {testAppLinked} from '../models/app/app.test-data.js'
import {AppErrors} from '../models/app/loader.js'
import {describe, expect, test, vi} from 'vitest'
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'

vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => {
const actual = await importOriginal<typeof import('@shopify/cli-kit/node/output')>()
return {
...actual,
outputResult: vi.fn(),
}
})
vi.mock('@shopify/cli-kit/node/ui')

describe('validateApp', () => {
Expand All @@ -18,6 +26,20 @@ describe('validateApp', () => {
// Then
expect(renderSuccess).toHaveBeenCalledWith({headline: 'App configuration is valid.'})
expect(renderError).not.toHaveBeenCalled()
expect(outputResult).not.toHaveBeenCalled()
})

test('outputs json success when --json is enabled and there are no errors', async () => {
// Given
const app = testAppLinked()

// When
await validateApp(app, {json: true})

// Then
expect(outputResult).toHaveBeenCalledWith(JSON.stringify({valid: true, errors: []}, null, 2))
expect(renderSuccess).not.toHaveBeenCalled()
expect(renderError).not.toHaveBeenCalled()
})

test('renders errors and throws when there are validation errors', async () => {
Expand All @@ -35,6 +57,31 @@ describe('validateApp', () => {
body: expect.stringContaining('client_id is required'),
})
expect(renderSuccess).not.toHaveBeenCalled()
expect(outputResult).not.toHaveBeenCalled()
})

test('outputs json errors and throws when --json is enabled and there are validation errors', async () => {
// Given
const errors = new AppErrors()
errors.addError('/path/to/shopify.app.toml', 'client_id is required')
errors.addError('/path/to/extensions/my-ext/shopify.extension.toml', 'invalid type "unknown"')
const app = testAppLinked()
app.errors = errors

// When / Then
await expect(validateApp(app, {json: true})).rejects.toThrow(AbortSilentError)
expect(outputResult).toHaveBeenCalledWith(
JSON.stringify(
{
valid: false,
errors: ['client_id is required', 'invalid type "unknown"'],
},
null,
2,
),
)
expect(renderError).not.toHaveBeenCalled()
expect(renderSuccess).not.toHaveBeenCalled()
})

test('renders success when errors object exists but is empty', async () => {
Expand All @@ -48,5 +95,6 @@ describe('validateApp', () => {

// Then
expect(renderSuccess).toHaveBeenCalledWith({headline: 'App configuration is valid.'})
expect(outputResult).not.toHaveBeenCalled()
})
})
18 changes: 16 additions & 2 deletions packages/app/src/cli/services/validate.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
import {AppLinkedInterface} from '../models/app/app.js'
import {stringifyMessage} from '@shopify/cli-kit/node/output'
import {outputResult, stringifyMessage} from '@shopify/cli-kit/node/output'
import {renderError, renderSuccess} from '@shopify/cli-kit/node/ui'
import {AbortSilentError} from '@shopify/cli-kit/node/error'

export async function validateApp(app: AppLinkedInterface): Promise<void> {
interface ValidateAppOptions {
json: boolean
}

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

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

renderSuccess({headline: 'App configuration is valid.'})
return
}

const errorMessages = errors.toJSON().map((error) => stringifyMessage(error).trim())

if (options.json) {
outputResult(JSON.stringify({valid: false, errors: errorMessages}, null, 2))
throw new AbortSilentError()
}

renderError({
headline: 'Validation errors found.',
body: errorMessages.join('\n\n'),
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -927,10 +927,12 @@ Validate your app configuration and extensions.

```
USAGE
$ shopify app validate [--client-id <value> | -c <value>] [--no-color] [--path <value>] [--reset | ] [--verbose]
$ shopify app validate [--client-id <value> | -c <value>] [-j] [--no-color] [--path <value>] [--reset | ]
[--verbose]

FLAGS
-c, --config=<value> [env: SHOPIFY_FLAG_APP_CONFIG] The name of the app configuration.
-j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON.
--client-id=<value> [env: SHOPIFY_FLAG_CLIENT_ID] The Client ID of your app.
--no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output.
--path=<value> [env: SHOPIFY_FLAG_PATH] The path to your app directory.
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2883,6 +2883,15 @@
"name": "config",
"type": "option"
},
"json": {
"allowNo": false,
"char": "j",
"description": "Output the result as JSON.",
"env": "SHOPIFY_FLAG_JSON",
"hidden": false,
"name": "json",
"type": "boolean"
},
"no-color": {
"allowNo": false,
"description": "Disable color output.",
Expand Down
Loading