Skip to content

Commit 422d760

Browse files
committed
Surface malformed discovered configs in validate json
1 parent f9b868c commit 422d760

9 files changed

Lines changed: 312 additions & 50 deletions

File tree

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {installNodeModules, PackageJson} from '@shopify/cli-kit/node/node-packag
2929
import {inTemporaryDirectory, moveFile, mkdir, mkTmpDir, rmdir, writeFile} from '@shopify/cli-kit/node/fs'
3030
import {joinPath, dirname, cwd, normalizePath} from '@shopify/cli-kit/node/path'
3131
import {platformAndArch} from '@shopify/cli-kit/node/os'
32+
import {stringifyMessage} from '@shopify/cli-kit/node/output'
3233
import {zod} from '@shopify/cli-kit/node/schema'
3334
import colors from '@shopify/cli-kit/node/colors'
3435
import {showMultipleCLIWarningIfNeeded} from '@shopify/cli-kit/node/multiple-installation-warning'
@@ -608,6 +609,27 @@ describe('load', () => {
608609
expect(app.errors.isEmpty()).toBe(false)
609610
})
610611

612+
test('collects a malformed discovered extension TOML as an app error', async () => {
613+
// Given
614+
await writeConfig(appConfiguration)
615+
const configurationPath = blockConfigurationPath({name: 'my-extension'})
616+
await writeBlockConfig({
617+
blockConfiguration: '{{broken toml',
618+
name: 'my-extension',
619+
})
620+
621+
// When
622+
const app = await loadTestingApp()
623+
624+
// Then
625+
expect(getRealExtensions(app)).toHaveLength(0)
626+
const error = app.errors.getError(configurationPath)
627+
expect(error).toBeDefined()
628+
expect(stringifyMessage(error!)).toContain(configurationPath)
629+
expect(stringifyMessage(error!)).toContain('Unknown character')
630+
expect(app.errors.getIssues(configurationPath)).toEqual([])
631+
})
632+
611633
test('collects an error if the extension configuration is missing both extensions and type', async () => {
612634
// Given
613635
await writeConfig(appConfiguration, {
@@ -632,6 +654,24 @@ describe('load', () => {
632654
expect(app.errors.isEmpty()).toBe(false)
633655
})
634656

657+
test('collects a malformed discovered web TOML as an app error', async () => {
658+
// Given
659+
const {webDirectory} = await writeConfig(appConfiguration)
660+
const configurationPath = joinPath(webDirectory, blocks.web.configurationName)
661+
await writeFile(configurationPath, '{{broken toml')
662+
663+
// When
664+
const app = await loadTestingApp()
665+
666+
// Then
667+
expect(app.webs).toHaveLength(0)
668+
const error = app.errors.getError(configurationPath)
669+
expect(error).toBeDefined()
670+
expect(stringifyMessage(error!)).toContain(configurationPath)
671+
expect(stringifyMessage(error!)).toContain('Unknown character')
672+
expect(app.errors.getIssues(configurationPath)).toEqual([])
673+
})
674+
635675
test('loads the app with web blocks', async () => {
636676
// Given
637677
const {webDirectory} = await writeConfig(appConfiguration)

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ import {
4040
resolveDotEnv,
4141
resolveHiddenConfig,
4242
extensionFilesForConfig,
43+
malformedExtensionFilesForConfig,
44+
malformedWebFilesForConfig,
4345
webFilesForConfig,
4446
} from '../project/config-selection.js'
4547
import {showMultipleCLIWarningIfNeeded} from '@shopify/cli-kit/node/multiple-installation-warning'
@@ -538,6 +540,14 @@ class AppLoader<TConfig extends CurrentAppConfiguration, TModuleSpec extends Ext
538540
async loadWebs(appDirectory: string, webDirectories?: string[]): Promise<{webs: Web[]; usedCustomLayout: boolean}> {
539541
const activeConfig = this.activeConfigFile
540542
const webFiles = activeConfig ? webFilesForConfig(this.project, activeConfig) : this.project.webConfigFiles
543+
const malformedWebFiles = activeConfig
544+
? malformedWebFilesForConfig(this.project, activeConfig)
545+
: this.project.malformedWebConfigFiles
546+
547+
for (const malformedWebFile of malformedWebFiles) {
548+
this.errors.addError(malformedWebFile.path, malformedWebFile.message)
549+
}
550+
541551
const webTomlPaths = webFiles.map((file) => file.path)
542552
const webResults = await Promise.all(
543553
webFiles.map(async (webFile) => {
@@ -692,6 +702,13 @@ class AppLoader<TConfig extends CurrentAppConfiguration, TModuleSpec extends Ext
692702
const extensionFiles = activeConfig
693703
? extensionFilesForConfig(this.project, activeConfig)
694704
: this.project.extensionConfigFiles
705+
const malformedExtensionFiles = activeConfig
706+
? malformedExtensionFilesForConfig(this.project, activeConfig)
707+
: this.project.malformedExtensionConfigFiles
708+
709+
for (const malformedExtensionFile of malformedExtensionFiles) {
710+
this.errors.addError(malformedExtensionFile.path, malformedExtensionFile.message)
711+
}
695712

696713
return extensionFiles.map(async (extensionFile) => {
697714
const configurationPath = extensionFile.path
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import {toRootValidationIssue, type AppValidationIssue} from './error-parsing.js'
12
import {AbortError} from '@shopify/cli-kit/node/error'
2-
import type {OutputMessage} from '@shopify/cli-kit/node/output'
3-
import type {AppValidationIssue} from './error-parsing.js'
3+
import {stringifyMessage, type OutputMessage} from '@shopify/cli-kit/node/output'
44

55
/**
66
* Structured local configuration failure shared by lower layers.
@@ -10,11 +10,15 @@ import type {AppValidationIssue} from './error-parsing.js'
1010
* results, or other higher-level flows.
1111
*/
1212
export class LocalConfigError extends AbortError {
13+
public readonly issues: AppValidationIssue[]
14+
1315
constructor(
1416
message: OutputMessage,
1517
public readonly configurationPath: string,
16-
public readonly issues: AppValidationIssue[] = [],
18+
issues: AppValidationIssue[] = [],
1719
) {
1820
super(message)
21+
this.issues =
22+
issues.length > 0 ? issues : [toRootValidationIssue(configurationPath, stringifyMessage(message).trim())]
1923
}
2024
}

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

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {selectActiveConfig} from './active-config.js'
22
import {Project} from './project.js'
3+
import {LocalConfigError} from '../app/local-config-error.js'
34
import {describe, expect, test, vi, beforeEach} from 'vitest'
45
import {inTemporaryDirectory, writeFile, mkdir} from '@shopify/cli-kit/node/fs'
56
import {joinPath, basename} from '@shopify/cli-kit/node/path'
@@ -155,34 +156,66 @@ describe('selectActiveConfig', () => {
155156
})
156157
})
157158

158-
test('throws when requested config does not exist', async () => {
159+
test('throws LocalConfigError when requested config does not exist', async () => {
159160
await inTemporaryDirectory(async (dir) => {
160161
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"')
161162
const project = await Project.load(dir)
162163

163-
await expect(selectActiveConfig(project, 'nonexistent')).rejects.toThrow()
164+
try {
165+
await selectActiveConfig(project, 'nonexistent')
166+
expect.fail('Expected selectActiveConfig to throw')
167+
} catch (error) {
168+
if (!(error instanceof LocalConfigError)) throw error
169+
expect(error.configurationPath).toBe(joinPath(dir, 'shopify.app.nonexistent.toml'))
170+
expect(error.message).toBe(`Couldn't find shopify.app.nonexistent.toml in ${dir}.`)
171+
expect(error.issues[0]).toMatchObject({
172+
filePath: joinPath(dir, 'shopify.app.nonexistent.toml'),
173+
path: [],
174+
pathString: 'root',
175+
})
176+
}
164177
})
165178
})
166179

167-
test('throws when the only app config is malformed (no valid configs to fall back to)', async () => {
180+
test('throws a structured parse error when the only app config is malformed', async () => {
168181
await inTemporaryDirectory(async (dir) => {
169-
// The only config is broken TOML — Project.load skips it and finds 0 valid configs
182+
// The only config is broken TOML — Project.load should surface the parse error.
170183
await writeFile(joinPath(dir, 'shopify.app.toml'), '{{invalid toml')
171184

172-
await expect(Project.load(dir)).rejects.toThrow(/Could not find/)
185+
try {
186+
await Project.load(dir)
187+
expect.fail('Expected Project.load to throw')
188+
} catch (error) {
189+
if (!(error instanceof LocalConfigError)) throw error
190+
expect(error.message).toMatch(/Unknown character/)
191+
expect(error.issues[0]).toMatchObject({
192+
filePath: joinPath(dir, 'shopify.app.toml'),
193+
path: [],
194+
pathString: 'root',
195+
})
196+
}
173197
})
174198
})
175199

176200
test('surfaces parse error when selecting a broken config while a valid one exists', async () => {
177201
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".
180202
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "good"')
181203
await writeFile(joinPath(dir, 'shopify.app.broken.toml'), '{{invalid toml')
182204

183205
const project = await Project.load(dir)
184206

185-
await expect(selectActiveConfig(project, 'shopify.app.broken.toml')).rejects.toThrow()
207+
try {
208+
await selectActiveConfig(project, 'shopify.app.broken.toml')
209+
expect.fail('Expected selectActiveConfig to throw')
210+
} catch (error) {
211+
if (!(error instanceof LocalConfigError)) throw error
212+
expect(error.message).toMatch(/Unknown character/)
213+
expect(error.issues[0]).toMatchObject({
214+
filePath: joinPath(dir, 'shopify.app.broken.toml'),
215+
path: [],
216+
pathString: 'root',
217+
})
218+
}
186219
})
187220
})
188221

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import {Project} from './project.js'
22
import {resolveDotEnv, resolveHiddenConfig} from './config-selection.js'
33
import {AppHiddenConfig} from '../app/app.js'
4+
import {LocalConfigError} from '../app/local-config-error.js'
45
import {getAppConfigurationFileName} from '../app/config-file-naming.js'
56
import {getCachedAppInfo} from '../../services/local-storage.js'
67
import use from '../../services/app/config/use.js'
78
import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file'
89
import {DotEnvFile} from '@shopify/cli-kit/node/dot-env'
910
import {fileExistsSync} from '@shopify/cli-kit/node/fs'
1011
import {joinPath} from '@shopify/cli-kit/node/path'
11-
import {AbortError} from '@shopify/cli-kit/node/error'
1212
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
1313

1414
/** @public */
@@ -87,8 +87,14 @@ export async function selectActiveConfig(project: Project, userProvidedConfigNam
8787
const configurationFileName = getAppConfigurationFileName(configName)
8888
const file = project.appConfigByName(configurationFileName)
8989
if (!file) {
90-
throw new AbortError(
90+
const malformedFile = project.malformedAppConfigByName(configurationFileName)
91+
if (malformedFile) {
92+
throw new LocalConfigError(malformedFile.message, malformedFile.path)
93+
}
94+
95+
throw new LocalConfigError(
9196
outputContent`Couldn't find ${configurationFileName} in ${outputToken.path(project.directory)}.`,
97+
joinPath(project.directory, configurationFileName),
9298
)
9399
}
94100

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

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import {resolveDotEnv, resolveHiddenConfig, extensionFilesForConfig, webFilesForConfig} from './config-selection.js'
1+
import {
2+
resolveDotEnv,
3+
resolveHiddenConfig,
4+
extensionFilesForConfig,
5+
malformedExtensionFilesForConfig,
6+
malformedWebFilesForConfig,
7+
webFilesForConfig,
8+
} from './config-selection.js'
29
import {Project} from './project.js'
310
import {describe, expect, test} from 'vitest'
411
import {inTemporaryDirectory, writeFile, mkdir} from '@shopify/cli-kit/node/fs'
@@ -230,6 +237,33 @@ describe('extensionFilesForConfig', () => {
230237
expect(stagingExts[0]!.content.name).toBe('func2')
231238
})
232239
})
240+
241+
test('filters malformed extension files to the active config extension_directories', async () => {
242+
await inTemporaryDirectory(async (dir) => {
243+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"\nextension_directories = ["ext-a/*"]')
244+
await writeFile(
245+
joinPath(dir, 'shopify.app.staging.toml'),
246+
'client_id = "staging"\nextension_directories = ["ext-b/*"]',
247+
)
248+
249+
await mkdir(joinPath(dir, 'ext-a', 'broken-default'))
250+
await writeFile(joinPath(dir, 'ext-a', 'broken-default', 'shopify.extension.toml'), '{{broken toml')
251+
await mkdir(joinPath(dir, 'ext-b', 'broken-staging'))
252+
await writeFile(joinPath(dir, 'ext-b', 'broken-staging', 'shopify.extension.toml'), '{{broken toml')
253+
254+
const project = await Project.load(dir)
255+
const defaultConfig = project.appConfigByName('shopify.app.toml')!
256+
const stagingConfig = project.appConfigByName('shopify.app.staging.toml')!
257+
258+
const defaultMalformed = malformedExtensionFilesForConfig(project, defaultConfig)
259+
expect(defaultMalformed).toHaveLength(1)
260+
expect(defaultMalformed[0]!.path).toContain('ext-a/broken-default/shopify.extension.toml')
261+
262+
const stagingMalformed = malformedExtensionFilesForConfig(project, stagingConfig)
263+
expect(stagingMalformed).toHaveLength(1)
264+
expect(stagingMalformed[0]!.path).toContain('ext-b/broken-staging/shopify.extension.toml')
265+
})
266+
})
233267
})
234268

235269
describe('webFilesForConfig', () => {
@@ -287,4 +321,28 @@ describe('webFilesForConfig', () => {
287321
expect(webFiles[0]!.content.name).toBe('web-a')
288322
})
289323
})
324+
325+
test('filters malformed web files to the active config web_directories', async () => {
326+
await inTemporaryDirectory(async (dir) => {
327+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"\nweb_directories = ["web-a"]')
328+
await writeFile(joinPath(dir, 'shopify.app.staging.toml'), 'client_id = "staging"\nweb_directories = ["web-b"]')
329+
330+
await mkdir(joinPath(dir, 'web-a'))
331+
await writeFile(joinPath(dir, 'web-a', 'shopify.web.toml'), '{{broken toml')
332+
await mkdir(joinPath(dir, 'web-b'))
333+
await writeFile(joinPath(dir, 'web-b', 'shopify.web.toml'), '{{broken toml')
334+
335+
const project = await Project.load(dir)
336+
const defaultConfig = project.appConfigByName('shopify.app.toml')!
337+
const stagingConfig = project.appConfigByName('shopify.app.staging.toml')!
338+
339+
const defaultMalformed = malformedWebFilesForConfig(project, defaultConfig)
340+
expect(defaultMalformed).toHaveLength(1)
341+
expect(defaultMalformed[0]!.path).toContain('web-a/shopify.web.toml')
342+
343+
const stagingMalformed = malformedWebFilesForConfig(project, stagingConfig)
344+
expect(stagingMalformed).toHaveLength(1)
345+
expect(stagingMalformed[0]!.path).toContain('web-b/shopify.web.toml')
346+
})
347+
})
290348
})

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

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Project} from './project.js'
1+
import {Project, type MalformedTomlFile} from './project.js'
22
import {AppHiddenConfig} from '../app/app.js'
33
import {getAppConfigurationShorthand} from '../app/loader.js'
44
import {dotEnvFileNames} from '../../constants.js'
@@ -76,19 +76,23 @@ export async function resolveHiddenConfig(project: Project, clientId: string | u
7676
* @public
7777
*/
7878
export function extensionFilesForConfig(project: Project, activeConfig: TomlFile): TomlFile[] {
79-
const configDirs = activeConfig.content.extension_directories
80-
const dirs = Array.isArray(configDirs) && configDirs.length > 0 ? (configDirs as string[]) : ['extensions/*']
81-
82-
// Replicate the same glob patterns used by discoverExtensionFiles in project.ts:
83-
// each directory pattern becomes "<dir>/*.extension.toml"
84-
const globPatterns = dirs.map((dir) => `${dir}/*.extension.toml`)
79+
const globPatterns = extensionGlobPatternsForConfig(activeConfig)
8580

8681
return project.extensionConfigFiles.filter((file) => {
8782
const relPath = relativePath(project.directory, file.path).replace(/\\/g, '/')
8883
return globPatterns.some((pattern) => matchGlob(relPath, pattern))
8984
})
9085
}
9186

87+
export function malformedExtensionFilesForConfig(project: Project, activeConfig: TomlFile): MalformedTomlFile[] {
88+
const globPatterns = extensionGlobPatternsForConfig(activeConfig)
89+
90+
return project.malformedExtensionConfigFiles.filter((file) => {
91+
const relPath = relativePath(project.directory, file.path).replace(/\\/g, '/')
92+
return globPatterns.some((pattern) => matchGlob(relPath, pattern))
93+
})
94+
}
95+
9296
/**
9397
* Filter web config files to those belonging to the active config's
9498
* web_directories. If not specified, returns all web files.
@@ -98,17 +102,35 @@ export function extensionFilesForConfig(project: Project, activeConfig: TomlFile
98102
* @public
99103
*/
100104
export function webFilesForConfig(project: Project, activeConfig: TomlFile): TomlFile[] {
101-
const configDirs = activeConfig.content.web_directories
102-
if (!Array.isArray(configDirs) || configDirs.length === 0) {
103-
return project.webConfigFiles
104-
}
105-
106-
// Replicate the same glob patterns used by discoverWebFiles in project.ts:
107-
// each directory pattern becomes "<dir>/shopify.web.toml"
108-
const globPatterns = (configDirs as string[]).map((dir) => `${dir}/shopify.web.toml`)
105+
const globPatterns = webGlobPatternsForConfig(activeConfig)
109106

110107
return project.webConfigFiles.filter((file) => {
111108
const relPath = relativePath(project.directory, file.path).replace(/\\/g, '/')
112109
return globPatterns.some((pattern) => matchGlob(relPath, pattern))
113110
})
114111
}
112+
113+
export function malformedWebFilesForConfig(project: Project, activeConfig: TomlFile): MalformedTomlFile[] {
114+
const globPatterns = webGlobPatternsForConfig(activeConfig)
115+
116+
return project.malformedWebConfigFiles.filter((file) => {
117+
const relPath = relativePath(project.directory, file.path).replace(/\\/g, '/')
118+
return globPatterns.some((pattern) => matchGlob(relPath, pattern))
119+
})
120+
}
121+
122+
function extensionGlobPatternsForConfig(activeConfig: TomlFile): string[] {
123+
const configDirs = activeConfig.content.extension_directories
124+
const dirs = Array.isArray(configDirs) && configDirs.length > 0 ? (configDirs as string[]) : ['extensions/*']
125+
126+
return dirs.map((dir) => `${dir}/*.extension.toml`)
127+
}
128+
129+
function webGlobPatternsForConfig(activeConfig: TomlFile): string[] {
130+
const configDirs = activeConfig.content.web_directories
131+
if (!Array.isArray(configDirs) || configDirs.length === 0) {
132+
return ['**/shopify.web.toml']
133+
}
134+
135+
return (configDirs as string[]).map((dir) => `${dir}/shopify.web.toml`)
136+
}

0 commit comments

Comments
 (0)