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
157 changes: 157 additions & 0 deletions packages/app/src/cli/commands/app/config/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,20 @@ 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 {LocalConfigError} from '../../../models/app/local-config-error.js'
import {describe, expect, test, vi} from 'vitest'
import {AbortError} from '@shopify/cli-kit/node/error'
import {outputResult} from '@shopify/cli-kit/node/output'

vi.mock('../../../services/app-context.js')
vi.mock('../../../services/validate.js')
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(),
}
})

describe('app config validate command', () => {
test('calls validateApp with json: false by default', async () => {
Expand Down Expand Up @@ -46,4 +56,151 @@ describe('app config validate command', () => {
// Then
expect(validateApp).toHaveBeenCalledWith(app, {json: true})
})

test('rethrows LocalConfigError in non-json mode without emitting json', async () => {
// Given
vi.mocked(linkedAppContext).mockRejectedValue(
new LocalConfigError('Validation errors in /tmp/shopify.app.toml', '/tmp/shopify.app.toml'),
)

// When / Then
await expect(Validate.run(['--path=/tmp/app'], import.meta.url)).rejects.toThrow()
expect(outputResult).not.toHaveBeenCalled()
expect(validateApp).not.toHaveBeenCalled()
})

test('outputs structured configuration issues from app loading before validateApp runs', async () => {
// Given
vi.mocked(linkedAppContext).mockRejectedValue(
new LocalConfigError(
'Validation errors in /tmp/shopify.app.toml:\n\n• [name]: String is required',
'/tmp/shopify.app.toml',
[
{
filePath: '/tmp/shopify.app.toml',
path: ['name'],
pathString: 'name',
message: 'String is required',
},
],
),
)

// When / Then
await expect(Validate.run(['--json', '--path=/tmp/app'], import.meta.url)).rejects.toThrow(
'process.exit unexpectedly called with "1"',
)
expect(outputResult).toHaveBeenCalledTimes(1)
expect(outputResult).toHaveBeenCalledWith(
JSON.stringify(
{
valid: false,
issues: [
{
filePath: '/tmp/shopify.app.toml',
path: ['name'],
pathString: 'name',
message: 'String is required',
},
],
},
null,
2,
),
)
expect(validateApp).not.toHaveBeenCalled()
})

test('outputs a root json issue when app loading fails without structured issues', async () => {
// Given
vi.mocked(linkedAppContext).mockRejectedValue(
new LocalConfigError("Couldn't find an app toml file at /tmp/app", '/tmp/app'),
)

// When / Then
await expect(Validate.run(['--json', '--path=/tmp/app'], import.meta.url)).rejects.toThrow(
'process.exit unexpectedly called with "1"',
)
expect(outputResult).toHaveBeenCalledTimes(1)
expect(outputResult).toHaveBeenCalledWith(
JSON.stringify(
{
valid: false,
issues: [
{
filePath: '/tmp/app',
path: [],
pathString: 'root',
message: "Couldn't find an app toml file at /tmp/app",
},
],
},
null,
2,
),
)
expect(validateApp).not.toHaveBeenCalled()
})

test('outputs json when validateApp throws a structured configuration abort', async () => {
// Given
const app = testAppLinked()
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)
vi.mocked(validateApp).mockRejectedValue(
new LocalConfigError(
'Validation errors in /tmp/shopify.app.toml:\n\n• [name]: String is required',
'/tmp/shopify.app.toml',
[
{
filePath: '/tmp/shopify.app.toml',
path: ['name'],
pathString: 'name',
message: 'String is required',
},
],
),
)

// When / Then
await expect(Validate.run(['--json'], import.meta.url)).rejects.toThrow('process.exit unexpectedly called with "1"')
expect(outputResult).toHaveBeenCalledTimes(1)
expect(outputResult).toHaveBeenCalledWith(
JSON.stringify(
{
valid: false,
issues: [
{
filePath: '/tmp/shopify.app.toml',
path: ['name'],
pathString: 'name',
message: 'String is required',
},
],
},
null,
2,
),
)
})

test('rethrows non-configuration errors from validateApp in json mode without converting them to validation json', async () => {
// Given
const app = testAppLinked()
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)
vi.mocked(validateApp).mockRejectedValue(new AbortError('network problem'))

// When / Then
await expect(Validate.run(['--json'], import.meta.url)).rejects.toThrow()
expect(outputResult).not.toHaveBeenCalled()
})

test('rethrows unrelated abort errors in json mode without converting them to validation json', async () => {
// Given
vi.mocked(linkedAppContext).mockRejectedValue(new AbortError('Could not find store for domain shop.example.com'))

// When / Then
await expect(Validate.run(['--json', '--path=/tmp/app'], import.meta.url)).rejects.toThrow()
expect(outputResult).not.toHaveBeenCalled()
expect(validateApp).not.toHaveBeenCalled()
})
})
40 changes: 29 additions & 11 deletions packages/app/src/cli/commands/app/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ 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 {toRootValidationIssue} from '../../../models/app/error-parsing.js'
import {LocalConfigError} from '../../../models/app/local-config-error.js'
import {invalidAppValidationResult, stringifyAppValidationResult} from '../../../services/validation-result.js'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {AbortSilentError} from '@shopify/cli-kit/node/error'
import {outputResult, stringifyMessage} from '@shopify/cli-kit/node/output'

export default class Validate extends AppLinkedCommand {
static summary = 'Validate your app configuration and extensions.'
Expand All @@ -20,16 +25,29 @@ 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,
})

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

return {app}
try {
const {app} = await linkedAppContext({
directory: flags.path,
clientId: flags['client-id'],
forceRelink: flags.reset,
userProvidedConfigName: flags.config,
unsafeTolerateErrors: true,
})

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

return {app}
} catch (error) {
if (flags.json && error instanceof LocalConfigError) {
const issues =
error.issues.length > 0
? error.issues
: [toRootValidationIssue(error.configurationPath, stringifyMessage(error.message).trim())]
outputResult(stringifyAppValidationResult(invalidAppValidationResult(issues)))
throw new AbortSilentError()
}

throw error
}
}
}
13 changes: 12 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, parseStructuredErrors} from './error-parsing.js'
import {parseHumanReadableError, parseStructuredErrors, toRootValidationIssue} from './error-parsing.js'
import {describe, expect, test} from 'vitest'

describe('parseHumanReadableError', () => {
Expand Down Expand Up @@ -236,6 +236,17 @@ describe('parseHumanReadableError', () => {
})
})

describe('toRootValidationIssue', () => {
test('creates a root issue for a file path and message', () => {
expect(toRootValidationIssue('/tmp/app', "Couldn't find an app toml file at /tmp/app")).toEqual({
filePath: '/tmp/app',
path: [],
pathString: 'root',
message: "Couldn't find an app toml file at /tmp/app",
})
})
})

describe('parseStructuredErrors', () => {
test('preserves regular issues with file path and path string', () => {
const issues = [
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/cli/models/app/error-parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ export interface AppValidationFileIssues {
issues: AppValidationIssue[]
}

export function toRootValidationIssue(filePath: string, message: string): AppValidationIssue {
return {
filePath,
path: [],
pathString: 'root',
message,
}
}

/**
* Finds the best matching variant from a union error by scoring each variant
* based on how close it is to the user's likely intent.
Expand Down
5 changes: 4 additions & 1 deletion packages/app/src/cli/models/app/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -970,7 +970,10 @@ async function getConfigurationPath(appDirectory: string, configName: string | u
if (await fileExists(configurationPath)) {
return {configurationPath, configurationFileName}
} else {
throw new AbortError(outputContent`Couldn't find ${configurationFileName} in ${outputToken.path(appDirectory)}.`)
throw new LocalConfigError(
outputContent`Couldn't find ${configurationFileName} in ${outputToken.path(appDirectory)}.`,
configurationPath,
)
}
}

Expand Down
Loading
Loading