Skip to content

Commit 849a4a7

Browse files
committed
Surface malformed discovered configs in validate json
1 parent 4534a8a commit 849a4a7

6 files changed

Lines changed: 208 additions & 31 deletions

File tree

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +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 {outputContent, outputToken} from '@shopify/cli-kit/node/output'
32+
import {outputContent, outputToken, stringifyMessage} from '@shopify/cli-kit/node/output'
3333
import {zod} from '@shopify/cli-kit/node/schema'
3434
import colors from '@shopify/cli-kit/node/colors'
3535
import {showMultipleCLIWarningIfNeeded} from '@shopify/cli-kit/node/multiple-installation-warning'
@@ -599,6 +599,39 @@ describe('load', () => {
599599
await expect(loadTestingApp()).rejects.toThrow(/Missing handle for extension "my_extension"/)
600600
})
601601

602+
test('throws when a discovered extension TOML is malformed', async () => {
603+
// Given
604+
await writeConfig(appConfiguration)
605+
const configurationPath = blockConfigurationPath({name: 'my-extension'})
606+
await writeBlockConfig({
607+
blockConfiguration: '{{broken toml',
608+
name: 'my-extension',
609+
})
610+
611+
// When / Then
612+
await expect(loadTestingApp()).rejects.toThrow(/Unknown character/)
613+
await expect(loadTestingApp()).rejects.toThrow(configurationPath)
614+
})
615+
616+
test('reports a malformed discovered extension TOML in report mode', async () => {
617+
// Given
618+
await writeConfig(appConfiguration)
619+
const configurationPath = blockConfigurationPath({name: 'my-extension'})
620+
await writeBlockConfig({
621+
blockConfiguration: '{{broken toml',
622+
name: 'my-extension',
623+
})
624+
625+
// When
626+
const app = await loadTestingApp({mode: 'report'})
627+
628+
// Then
629+
expect(getRealExtensions(app)).toHaveLength(0)
630+
expect(stringifyMessage(app.errors?.getError(configurationPath))).toContain(configurationPath)
631+
expect(stringifyMessage(app.errors?.getError(configurationPath))).toContain('Unknown character')
632+
expect(app.errors?.getIssues(configurationPath)).toEqual([])
633+
})
634+
602635
test('throws an error if the extension configuration is missing both extensions and type', async () => {
603636
// Given
604637
await writeConfig(appConfiguration, {
@@ -621,6 +654,33 @@ describe('load', () => {
621654
await expect(loadTestingApp()).rejects.toThrow(/Invalid extension type/)
622655
})
623656

657+
test('throws when a discovered web TOML is malformed', 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 / Then
664+
await expect(loadTestingApp()).rejects.toThrow(/Unknown character/)
665+
await expect(loadTestingApp()).rejects.toThrow(configurationPath)
666+
})
667+
668+
test('reports a malformed discovered web TOML in report mode', async () => {
669+
// Given
670+
const {webDirectory} = await writeConfig(appConfiguration)
671+
const configurationPath = joinPath(webDirectory, blocks.web.configurationName)
672+
await writeFile(configurationPath, '{{broken toml')
673+
674+
// When
675+
const app = await loadTestingApp({mode: 'report'})
676+
677+
// Then
678+
expect(app.webs).toHaveLength(0)
679+
expect(stringifyMessage(app.errors?.getError(configurationPath))).toContain(configurationPath)
680+
expect(stringifyMessage(app.errors?.getError(configurationPath))).toContain('Unknown character')
681+
expect(app.errors?.getIssues(configurationPath)).toEqual([])
682+
})
683+
624684
test('loads the app with web blocks', async () => {
625685
// Given
626686
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'
@@ -565,6 +567,14 @@ class AppLoader<TConfig extends CurrentAppConfiguration, TModuleSpec extends Ext
565567
async loadWebs(appDirectory: string, webDirectories?: string[]): Promise<{webs: Web[]; usedCustomLayout: boolean}> {
566568
const activeConfig = this.activeConfigFile
567569
const webFiles = activeConfig ? webFilesForConfig(this.project, activeConfig) : this.project.webConfigFiles
570+
const malformedWebFiles = activeConfig
571+
? malformedWebFilesForConfig(this.project, activeConfig)
572+
: this.project.malformedWebConfigFiles
573+
574+
for (const malformedWebFile of malformedWebFiles) {
575+
this.abortOrReport(malformedWebFile.message, undefined, malformedWebFile.path)
576+
}
577+
568578
const webTomlPaths = webFiles.map((file) => file.path)
569579
const webs = await Promise.all(
570580
webFiles.map((webFile) => loadSingleWeb(webFile.path, this.abortOrReport.bind(this), webFile.content)),
@@ -707,6 +717,13 @@ class AppLoader<TConfig extends CurrentAppConfiguration, TModuleSpec extends Ext
707717
const extensionFiles = activeConfig
708718
? extensionFilesForConfig(this.project, activeConfig)
709719
: this.project.extensionConfigFiles
720+
const malformedExtensionFiles = activeConfig
721+
? malformedExtensionFilesForConfig(this.project, activeConfig)
722+
: this.project.malformedExtensionConfigFiles
723+
724+
for (const malformedExtensionFile of malformedExtensionFiles) {
725+
this.abortOrReport(malformedExtensionFile.message, undefined, malformedExtensionFile.path)
726+
}
710727

711728
return extensionFiles.map(async (extensionFile) => {
712729
const configurationPath = extensionFile.path

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+
}

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,11 @@ describe('Project', () => {
188188

189189
const project = await Project.load(dir)
190190

191-
// Only the valid extension is loaded
191+
// Only the valid extension is loaded, but malformed files are retained as discovery metadata
192192
expect(project.extensionConfigFiles).toHaveLength(1)
193193
expect(project.extensionConfigFiles[0]!.content.name).toBe('good')
194+
expect(project.malformedExtensionConfigFiles).toHaveLength(1)
195+
expect(project.malformedExtensionConfigFiles[0]!.path).toContain('bad-ext/shopify.extension.toml')
194196
})
195197
})
196198

@@ -202,9 +204,11 @@ describe('Project', () => {
202204

203205
const project = await Project.load(dir)
204206

205-
// Only the valid web config is loaded
207+
// Only the valid web config is loaded, but malformed files are retained as discovery metadata
206208
expect(project.webConfigFiles).toHaveLength(1)
207209
expect(project.webConfigFiles[0]!.content.name).toBe('good')
210+
expect(project.malformedWebConfigFiles).toHaveLength(1)
211+
expect(project.malformedWebConfigFiles[0]!.path).toContain('bad-web/shopify.web.toml')
208212
})
209213
})
210214

0 commit comments

Comments
 (0)