Skip to content

Commit d3dea9e

Browse files
authored
Merge pull request #7130 from Shopify/ryan/surface-discovery-errors
Add errors to TomlFile and Project for TOML parse failure tracking
2 parents 612ab75 + 5fcaa9f commit d3dea9e

10 files changed

Lines changed: 215 additions & 92 deletions

File tree

packages/app/src/cli/commands/app/config/validate.test.ts

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,48 +2,74 @@ import Validate from './validate.js'
22
import {linkedAppContext} from '../../../services/app-context.js'
33
import {validateApp} from '../../../services/validate.js'
44
import {testAppLinked} from '../../../models/app/app.test-data.js'
5+
import {Project} from '../../../models/project/project.js'
6+
import {selectActiveConfig} from '../../../models/project/active-config.js'
7+
import {errorsForConfig} from '../../../models/project/config-selection.js'
8+
import {outputResult} from '@shopify/cli-kit/node/output'
9+
import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file'
510
import {describe, expect, test, vi} from 'vitest'
611

712
vi.mock('../../../services/app-context.js')
813
vi.mock('../../../services/validate.js')
14+
vi.mock('../../../models/project/project.js')
15+
vi.mock('../../../models/project/active-config.js')
16+
vi.mock('../../../models/project/config-selection.js')
17+
vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => {
18+
const actual = await importOriginal<typeof import('@shopify/cli-kit/node/output')>()
19+
return {...actual, outputResult: vi.fn()}
20+
})
21+
vi.mock('@shopify/cli-kit/node/ui')
22+
23+
function mockHealthyProject() {
24+
vi.mocked(Project.load).mockResolvedValue({errors: []} as unknown as Project)
25+
vi.mocked(selectActiveConfig).mockResolvedValue({file: new TomlFile('shopify.app.toml', {})} as any)
26+
vi.mocked(errorsForConfig).mockReturnValue([])
27+
}
928

1029
describe('app config validate command', () => {
1130
test('calls validateApp with json: false by default', async () => {
12-
// Given
1331
const app = testAppLinked()
32+
mockHealthyProject()
1433
vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited<ReturnType<typeof linkedAppContext>>)
1534
vi.mocked(validateApp).mockResolvedValue()
1635

17-
// When
1836
await Validate.run([], import.meta.url)
1937

20-
// Then
2138
expect(validateApp).toHaveBeenCalledWith(app, {json: false})
2239
})
2340

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

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

33-
// Then
3449
expect(validateApp).toHaveBeenCalledWith(app, {json: true})
3550
})
3651

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

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

46-
// Then
4760
expect(validateApp).toHaveBeenCalledWith(app, {json: true})
4861
})
62+
63+
test('outputs JSON issues when active config has TOML parse errors', async () => {
64+
vi.mocked(Project.load).mockResolvedValue({errors: []} as unknown as Project)
65+
vi.mocked(selectActiveConfig).mockResolvedValue({file: new TomlFile('shopify.app.toml', {})} as any)
66+
vi.mocked(errorsForConfig).mockReturnValue([
67+
{path: '/app/shopify.app.toml', message: 'Unexpected character at row 1, col 5'} as any,
68+
])
69+
70+
await expect(Validate.run(['--json'], import.meta.url)).rejects.toThrow()
71+
72+
expect(outputResult).toHaveBeenCalledWith(expect.stringContaining('"valid": false'))
73+
expect(linkedAppContext).not.toHaveBeenCalled()
74+
})
4975
})

packages/app/src/cli/commands/app/config/validate.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@ import {appFlags} from '../../../flags.js'
22
import {validateApp} from '../../../services/validate.js'
33
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
44
import {linkedAppContext} from '../../../services/app-context.js'
5+
import {selectActiveConfig} from '../../../models/project/active-config.js'
6+
import {errorsForConfig} from '../../../models/project/config-selection.js'
7+
import {Project} from '../../../models/project/project.js'
58
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
69
import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error'
710
import {outputResult, stringifyMessage, unstyled} from '@shopify/cli-kit/node/output'
11+
import {renderError} from '@shopify/cli-kit/node/ui'
812

913
export default class Validate extends AppLinkedCommand {
1014
static summary = 'Validate your app configuration and extensions.'
@@ -22,6 +26,47 @@ export default class Validate extends AppLinkedCommand {
2226
public async run(): Promise<AppLinkedCommandOutput> {
2327
const {flags} = await this.parse(Validate)
2428

29+
// Stage 1: Load project
30+
let project: Project
31+
try {
32+
project = await Project.load(flags.path)
33+
} catch (err) {
34+
if (err instanceof AbortError && flags.json) {
35+
const message = unstyled(stringifyMessage(err.message)).trim()
36+
outputResult(JSON.stringify({valid: false, issues: [{message}]}, null, 2))
37+
throw new AbortSilentError()
38+
}
39+
throw err
40+
}
41+
42+
// Stage 2: Select active config and check for TOML parse errors scoped to it
43+
let activeConfig
44+
try {
45+
activeConfig = await selectActiveConfig(project, flags.config)
46+
} catch (err) {
47+
if (err instanceof AbortError && flags.json) {
48+
const message = unstyled(stringifyMessage(err.message)).trim()
49+
outputResult(JSON.stringify({valid: false, issues: [{message}]}, null, 2))
50+
throw new AbortSilentError()
51+
}
52+
throw err
53+
}
54+
55+
const configErrors = errorsForConfig(project, activeConfig.file)
56+
if (configErrors.length > 0) {
57+
const issues = configErrors.map((err) => ({file: err.path, message: err.message}))
58+
if (flags.json) {
59+
outputResult(JSON.stringify({valid: false, issues}, null, 2))
60+
throw new AbortSilentError()
61+
}
62+
renderError({
63+
headline: 'Validation errors found.',
64+
body: issues.map((issue) => `• ${issue.message}`).join('\n'),
65+
})
66+
throw new AbortSilentError()
67+
}
68+
69+
// Stage 3: Load app (link + remote fetch + schema validation)
2570
let app
2671
try {
2772
const context = await linkedAppContext({
@@ -33,9 +78,12 @@ export default class Validate extends AppLinkedCommand {
3378
})
3479
app = context.app
3580
} catch (err) {
36-
if (err instanceof AbortError && flags.json) {
37-
const message = unstyled(stringifyMessage(err.message)).trim()
38-
outputResult(JSON.stringify({valid: false, errors: [{message}]}, null, 2))
81+
// Only catch config validation errors for JSON output. Auth/linking/remote
82+
// failures should propagate normally — they aren't validation results.
83+
const message = err instanceof AbortError ? unstyled(stringifyMessage(err.message)).trim() : ''
84+
const isValidationError = message.startsWith('Validation errors in ')
85+
if (isValidationError && flags.json) {
86+
outputResult(JSON.stringify({valid: false, issues: [{message}]}, null, 2))
3987
throw new AbortSilentError()
4088
}
4189
throw err

packages/app/src/cli/models/app/loader.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import {
3838
} from '../project/config-selection.js'
3939
import {showMultipleCLIWarningIfNeeded} from '@shopify/cli-kit/node/multiple-installation-warning'
4040
import {fileExists, readFile, fileExistsSync} from '@shopify/cli-kit/node/fs'
41-
import {TomlFile, TomlFileNotFoundError, TomlParseError} from '@shopify/cli-kit/node/toml/toml-file'
41+
import {TomlFile, TomlFileError} from '@shopify/cli-kit/node/toml/toml-file'
4242
import {zod} from '@shopify/cli-kit/node/schema'
4343
import {PackageManager} from '@shopify/cli-kit/node/node-package-manager'
4444
import {resolveFramework} from '@shopify/cli-kit/node/framework'
@@ -96,10 +96,7 @@ export async function parseConfigurationFile<TSchema extends zod.ZodType>(
9696
const file = await TomlFile.read(filepath)
9797
content = file.content
9898
} catch (err) {
99-
if (err instanceof TomlFileNotFoundError) {
100-
return {errors: [{file: filepath, message: `Couldn't find the configuration file at ${filepath}`}]}
101-
}
102-
if (err instanceof TomlParseError) {
99+
if (err instanceof TomlFileError) {
103100
return {errors: [{file: filepath, message: err.message}]}
104101
}
105102
throw err

packages/app/src/cli/models/project/active-config.test.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -164,25 +164,29 @@ describe('selectActiveConfig', () => {
164164
})
165165
})
166166

167-
test('throws when the only app config is malformed (no valid configs to fall back to)', async () => {
167+
test('loads with errors when the only app config is malformed', async () => {
168168
await inTemporaryDirectory(async (dir) => {
169-
// The only config is broken TOML — Project.load skips it and finds 0 valid configs
169+
// The only config is broken TOML — Project.load includes it with errors
170170
await writeFile(joinPath(dir, 'shopify.app.toml'), '{{invalid toml')
171171

172-
await expect(Project.load(dir)).rejects.toThrow(/Could not find/)
172+
const project = await Project.load(dir)
173+
expect(project.appConfigFiles).toHaveLength(1)
174+
expect(project.appConfigFiles[0]!.errors).toHaveLength(1)
175+
expect(project.errors).toHaveLength(1)
176+
expect(project.errors[0]!.path).toContain('shopify.app.toml')
173177
})
174178
})
175179

176-
test('surfaces parse error when selecting a broken config while a valid one exists', async () => {
180+
test('selects a broken config and exposes its errors', async () => {
177181
await inTemporaryDirectory(async (dir) => {
178-
// Two configs: one good, one broken. Selecting the broken one by name should
179-
// surface the real parse error via the fallback re-read, not a generic "not found".
182+
// Two configs: one good, one broken. Selecting the broken one succeeds
183+
// but the selected file has errors.
180184
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "good"')
181185
await writeFile(joinPath(dir, 'shopify.app.broken.toml'), '{{invalid toml')
182186

183187
const project = await Project.load(dir)
184-
185-
await expect(selectActiveConfig(project, 'shopify.app.broken.toml')).rejects.toThrow()
188+
const activeConfig = await selectActiveConfig(project, 'broken')
189+
expect(activeConfig.file.errors).toHaveLength(1)
186190
})
187191
})
188192

packages/app/src/cli/models/project/config-selection.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {getAppConfigurationShorthand} from '../app/loader.js'
44
import {dotEnvFileNames} from '../../constants.js'
55
import {patchAppHiddenConfigFile} from '../../services/app/patch-app-configuration-file.js'
66
import {getOrCreateAppConfigHiddenPath} from '../../utilities/app/config/hidden-app-config.js'
7-
import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file'
7+
import {TomlFile, TomlFileError} from '@shopify/cli-kit/node/toml/toml-file'
88
import {DotEnvFile} from '@shopify/cli-kit/node/dot-env'
99
import {matchGlob} from '@shopify/cli-kit/node/fs'
1010
import {relativePath} from '@shopify/cli-kit/node/path'
@@ -112,3 +112,29 @@ export function webFilesForConfig(project: Project, activeConfig: TomlFile): Tom
112112
return globPatterns.some((pattern) => matchGlob(relPath, pattern))
113113
})
114114
}
115+
116+
/**
117+
* Filter project.errors to only those relevant to the active config:
118+
* the active config file itself, plus extension/web TOMLs in its directories.
119+
* @public
120+
*/
121+
export function errorsForConfig(project: Project, activeConfig: TomlFile): TomlFileError[] {
122+
const extDirs = activeConfig.content.extension_directories
123+
const extPatterns = (Array.isArray(extDirs) && extDirs.length > 0 ? (extDirs as string[]) : ['extensions/*']).map(
124+
(dir) => `${dir}/*.extension.toml`,
125+
)
126+
127+
const webDirs = activeConfig.content.web_directories
128+
const webPatterns =
129+
Array.isArray(webDirs) && webDirs.length > 0
130+
? (webDirs as string[]).map((dir) => `${dir}/shopify.web.toml`)
131+
: ['**/shopify.web.toml']
132+
133+
const allPatterns = [...extPatterns, ...webPatterns]
134+
135+
return project.errors.filter((err) => {
136+
if (err.path === activeConfig.path) return true
137+
const relPath = relativePath(project.directory, err.path).replace(/\\/g, '/')
138+
return allPatterns.some((pattern) => matchGlob(relPath, pattern))
139+
})
140+
}

packages/app/src/cli/models/project/project.test.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -159,44 +159,54 @@ describe('Project', () => {
159159
})
160160
})
161161

162-
test('skips malformed inactive app config without blocking active config', async () => {
162+
test('includes malformed inactive app config with errors without blocking active config', async () => {
163163
await inTemporaryDirectory(async (dir) => {
164164
await writeAppToml(dir, 'client_id = "good"')
165165
await writeAppToml(dir, '{{invalid toml content', 'shopify.app.broken.toml')
166166

167167
const project = await Project.load(dir)
168168

169-
// The broken config is skipped, but the valid one is loaded
170-
expect(project.appConfigFiles).toHaveLength(1)
171-
expect(project.appConfigFiles[0]!.content.client_id).toBe('good')
169+
expect(project.appConfigFiles).toHaveLength(2)
170+
const good = project.appConfigFiles.find((file) => file.content.client_id === 'good')
171+
const broken = project.appConfigFiles.find((file) => file.errors.length > 0)
172+
expect(good).toBeDefined()
173+
expect(broken).toBeDefined()
174+
expect(broken!.path).toContain('shopify.app.broken.toml')
175+
expect(project.errors).toHaveLength(1)
172176
})
173177
})
174178

175-
test('skips malformed extension TOML without blocking project load', async () => {
179+
test('includes malformed extension TOML with errors without blocking project load', async () => {
176180
await inTemporaryDirectory(async (dir) => {
177181
await writeAppToml(dir, 'client_id = "abc"')
178182
await writeExtensionToml(dir, 'good-ext', 'type = "function"\nname = "good"')
179183
await writeExtensionToml(dir, 'bad-ext', '{{broken toml')
180184

181185
const project = await Project.load(dir)
182186

183-
// Only the valid extension is loaded
184-
expect(project.extensionConfigFiles).toHaveLength(1)
185-
expect(project.extensionConfigFiles[0]!.content.name).toBe('good')
187+
expect(project.extensionConfigFiles).toHaveLength(2)
188+
const good = project.extensionConfigFiles.find((file) => file.content.name === 'good')
189+
const broken = project.extensionConfigFiles.find((file) => file.errors.length > 0)
190+
expect(good).toBeDefined()
191+
expect(broken).toBeDefined()
192+
expect(project.errors).toHaveLength(1)
186193
})
187194
})
188195

189-
test('skips malformed web TOML without blocking project load', async () => {
196+
test('includes malformed web TOML with errors without blocking project load', async () => {
190197
await inTemporaryDirectory(async (dir) => {
191198
await writeAppToml(dir, 'client_id = "abc"')
192199
await writeWebToml(dir, 'good-web', 'name = "good"\nroles = ["backend"]')
193200
await writeWebToml(dir, 'bad-web', '{{broken toml')
194201

195202
const project = await Project.load(dir)
196203

197-
// Only the valid web config is loaded
198-
expect(project.webConfigFiles).toHaveLength(1)
199-
expect(project.webConfigFiles[0]!.content.name).toBe('good')
204+
expect(project.webConfigFiles).toHaveLength(2)
205+
const good = project.webConfigFiles.find((file) => file.content.name === 'good')
206+
const broken = project.webConfigFiles.find((file) => file.errors.length > 0)
207+
expect(good).toBeDefined()
208+
expect(broken).toBeDefined()
209+
expect(project.errors).toHaveLength(1)
200210
})
201211
})
202212

0 commit comments

Comments
 (0)