Skip to content

Commit 5bbfb2b

Browse files
Abstract build steps to externalize the build configuration
1 parent b6f6989 commit 5bbfb2b

20 files changed

+780
-49
lines changed

packages/app/src/cli/models/extensions/extension-instance.test.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ vi.mock('../../services/build/extension.js', async () => {
3232
return {
3333
...actual,
3434
buildUIExtension: vi.fn(),
35-
buildThemeExtension: vi.fn(),
3635
buildFunctionExtension: vi.fn(),
3736
}
3837
})
@@ -148,8 +147,16 @@ describe('build', async () => {
148147
// Given
149148
const extensionInstance = await testTaxCalculationExtension(tmpDir)
150149
const options: ExtensionBuildOptions = {
151-
stdout: new Writable(),
152-
stderr: new Writable(),
150+
stdout: new Writable({
151+
write(chunk, enc, cb) {
152+
cb()
153+
},
154+
}),
155+
stderr: new Writable({
156+
write(chunk, enc, cb) {
157+
cb()
158+
},
159+
}),
153160
app: testApp(),
154161
environment: 'production',
155162
}

packages/app/src/cli/models/extensions/extension-instance.ts

Lines changed: 24 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,19 @@ import {PrivacyComplianceWebhooksSpecIdentifier} from './specifications/app_conf
1111
import {WebhooksSpecIdentifier} from './specifications/app_config_webhook.js'
1212
import {WebhookSubscriptionSpecIdentifier} from './specifications/app_config_webhook_subscription.js'
1313
import {EventsSpecIdentifier} from './specifications/app_config_events.js'
14-
import {
15-
ExtensionBuildOptions,
16-
buildFunctionExtension,
17-
buildThemeExtension,
18-
buildUIExtension,
19-
bundleFunctionExtension,
20-
} from '../../services/build/extension.js'
21-
import {bundleThemeExtension, copyFilesForExtension} from '../../services/extensions/bundle.js'
14+
import {ExtensionBuildOptions, bundleFunctionExtension} from '../../services/build/extension.js'
15+
import {bundleThemeExtension} from '../../services/extensions/bundle.js'
2216
import {Identifiers} from '../app/identifiers.js'
2317
import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js'
2418
import {AppConfigurationWithoutPath} from '../app/app.js'
2519
import {ApplicationURLs} from '../../services/dev/urls.js'
20+
import {executeStep, BuildContext} from '../../services/build/client-steps.js'
2621
import {ok} from '@shopify/cli-kit/node/result'
2722
import {constantize, slugify} from '@shopify/cli-kit/common/string'
2823
import {hashString, nonRandomUUID} from '@shopify/cli-kit/node/crypto'
2924
import {partnersFqdn} from '@shopify/cli-kit/node/context/fqdn'
3025
import {joinPath, basename, normalizePath, resolvePath} from '@shopify/cli-kit/node/path'
31-
import {fileExists, touchFile, moveFile, writeFile, glob, copyFile, globSync} from '@shopify/cli-kit/node/fs'
26+
import {fileExists, moveFile, glob, copyFile, globSync} from '@shopify/cli-kit/node/fs'
3227
import {getPathValue} from '@shopify/cli-kit/common/object'
3328
import {outputDebug} from '@shopify/cli-kit/node/output'
3429
import {extractJSImports, extractImportPathsRecursively} from '@shopify/cli-kit/node/import-extractor'
@@ -138,7 +133,7 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
138133

139134
get outputFileName() {
140135
const mode = this.specification.buildConfig.mode
141-
if (mode === 'copy_files' || mode === 'theme') {
136+
if (mode === 'hosted_app_home' || mode === 'theme') {
142137
return ''
143138
} else if (mode === 'function') {
144139
return 'index.wasm'
@@ -348,31 +343,26 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
348343
}
349344

350345
async build(options: ExtensionBuildOptions): Promise<void> {
351-
const mode = this.specification.buildConfig.mode
346+
const {clientSteps} = this.specification
347+
348+
const context: BuildContext = {
349+
extension: this,
350+
options,
351+
stepResults: new Map(),
352+
}
352353

353-
switch (mode) {
354-
case 'theme':
355-
await buildThemeExtension(this, options)
356-
return bundleThemeExtension(this, options)
357-
case 'function':
358-
return buildFunctionExtension(this, options)
359-
case 'ui':
360-
await buildUIExtension(this, options)
361-
// Copy static assets after build completes
362-
return this.copyStaticAssets()
363-
case 'tax_calculation':
364-
await touchFile(this.outputPath)
365-
await writeFile(this.outputPath, '(()=>{})();')
366-
break
367-
case 'copy_files':
368-
return copyFilesForExtension(
369-
this,
370-
options,
371-
this.specification.buildConfig.filePatterns ?? [],
372-
this.specification.buildConfig.ignoredFilePatterns ?? [],
373-
)
374-
case 'none':
375-
break
354+
const steps = clientSteps
355+
.filter((lifecycle) => lifecycle.lifecycle === 'deploy')
356+
.flatMap((lifecycle) => lifecycle.steps)
357+
358+
for (const step of steps) {
359+
// eslint-disable-next-line no-await-in-loop
360+
const result = await executeStep(step, context)
361+
context.stepResults.set(step.id, result)
362+
363+
if (!result.success && !step.continueOnError) {
364+
throw new Error(`Build step "${step.name}" failed: ${result.error?.message}`)
365+
}
376366
}
377367
}
378368

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import spec from './channel.js'
2+
import {ExtensionInstance} from '../extension-instance.js'
3+
import {ExtensionBuildOptions} from '../../../services/build/extension.js'
4+
import {describe, expect, test} from 'vitest'
5+
import {inTemporaryDirectory, writeFile, fileExists, mkdir} from '@shopify/cli-kit/node/fs'
6+
import {joinPath} from '@shopify/cli-kit/node/path'
7+
import {Writable} from 'stream'
8+
9+
const SUBDIRECTORY = 'specifications'
10+
11+
describe('channel_config', () => {
12+
describe('clientSteps', () => {
13+
test('uses copy_files mode', () => {
14+
expect(spec.buildConfig.mode).toBe('copy_files')
15+
})
16+
17+
test('has a single copy-files step scoped to the specifications subdirectory', () => {
18+
expect(spec.clientSteps[0]!.steps).toHaveLength(1)
19+
expect(spec.clientSteps[0]!.steps[0]).toMatchObject({
20+
id: 'copy-files',
21+
type: 'include_assets',
22+
config: {
23+
inclusions: [{type: 'pattern', baseDir: SUBDIRECTORY, destination: SUBDIRECTORY}],
24+
},
25+
})
26+
27+
const {include} = (spec.clientSteps[0]!.steps[0]!.config as {inclusions: [{include: string[]}]}).inclusions[0]
28+
29+
expect(include).toEqual(expect.arrayContaining(['**/*.json', '**/*.toml', '**/*.yaml', '**/*.yml', '**/*.svg']))
30+
})
31+
32+
test('config is serializable to JSON', () => {
33+
const serialized = JSON.stringify(spec.clientSteps)
34+
const deserialized = JSON.parse(serialized)
35+
36+
expect(deserialized[0].steps).toHaveLength(1)
37+
expect(deserialized[0].steps[0].config.inclusions[0].type).toBe('pattern')
38+
})
39+
})
40+
41+
describe('build integration', () => {
42+
test('copies specification files to output, preserving subdirectory structure', async () => {
43+
await inTemporaryDirectory(async (tmpDir) => {
44+
// Given
45+
const extensionDir = joinPath(tmpDir, 'extension')
46+
const specsDir = joinPath(extensionDir, SUBDIRECTORY)
47+
const outputDir = joinPath(tmpDir, 'output')
48+
49+
await mkdir(specsDir)
50+
await mkdir(outputDir)
51+
52+
await writeFile(joinPath(specsDir, 'product.json'), '{}')
53+
await writeFile(joinPath(specsDir, 'order.toml'), '[spec]')
54+
await writeFile(joinPath(specsDir, 'logo.svg'), '<svg/>')
55+
// Root-level files should NOT be copied
56+
await writeFile(joinPath(extensionDir, 'README.md'), '# readme')
57+
await writeFile(joinPath(extensionDir, 'index.js'), 'ignored')
58+
59+
const extension = new ExtensionInstance({
60+
configuration: {name: 'my-channel', type: 'channel'},
61+
configurationPath: '',
62+
directory: extensionDir,
63+
specification: spec,
64+
})
65+
extension.outputPath = outputDir
66+
67+
const buildOptions: ExtensionBuildOptions = {
68+
stdout: new Writable({
69+
write(chunk, enc, cb) {
70+
cb()
71+
},
72+
}),
73+
stderr: new Writable({
74+
write(chunk, enc, cb) {
75+
cb()
76+
},
77+
}),
78+
app: {} as any,
79+
environment: 'production',
80+
}
81+
82+
// When
83+
await extension.build(buildOptions)
84+
85+
// Then — specification files copied with path preserved
86+
await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'product.json'))).resolves.toBe(true)
87+
await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'order.toml'))).resolves.toBe(true)
88+
await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'logo.svg'))).resolves.toBe(true)
89+
90+
// Root-level files not in specifications/ are not copied
91+
await expect(fileExists(joinPath(outputDir, 'README.md'))).resolves.toBe(false)
92+
await expect(fileExists(joinPath(outputDir, 'index.js'))).resolves.toBe(false)
93+
})
94+
})
95+
96+
test('does not copy files with non-matching extensions inside specifications/', async () => {
97+
await inTemporaryDirectory(async (tmpDir) => {
98+
// Given
99+
const extensionDir = joinPath(tmpDir, 'extension')
100+
const specsDir = joinPath(extensionDir, SUBDIRECTORY)
101+
const outputDir = joinPath(tmpDir, 'output')
102+
103+
await mkdir(specsDir)
104+
await mkdir(outputDir)
105+
106+
await writeFile(joinPath(specsDir, 'spec.json'), '{}')
107+
await writeFile(joinPath(specsDir, 'ignored.ts'), 'const x = 1')
108+
await writeFile(joinPath(specsDir, 'ignored.js'), 'const x = 1')
109+
110+
const extension = new ExtensionInstance({
111+
configuration: {name: 'my-channel', type: 'channel'},
112+
configurationPath: '',
113+
directory: extensionDir,
114+
specification: spec,
115+
})
116+
extension.outputPath = outputDir
117+
118+
const buildOptions: ExtensionBuildOptions = {
119+
stdout: new Writable({
120+
write(chunk, enc, cb) {
121+
cb()
122+
},
123+
}),
124+
stderr: new Writable({
125+
write(chunk, enc, cb) {
126+
cb()
127+
},
128+
}),
129+
app: {} as any,
130+
environment: 'production',
131+
}
132+
133+
// When
134+
await extension.build(buildOptions)
135+
136+
// Then
137+
await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'spec.json'))).resolves.toBe(true)
138+
await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'ignored.ts'))).resolves.toBe(false)
139+
await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'ignored.js'))).resolves.toBe(false)
140+
})
141+
})
142+
})
143+
})

packages/app/src/cli/models/extensions/specifications/channel.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,28 @@ const channelSpecificationSpec = createContractBasedModuleSpecification({
1010
mode: 'copy_files',
1111
filePatterns: FILE_EXTENSIONS.map((ext) => joinPath(SUBDIRECTORY_NAME, '**', `*.${ext}`)),
1212
},
13+
clientSteps: [
14+
{
15+
lifecycle: 'deploy',
16+
steps: [
17+
{
18+
id: 'copy-files',
19+
name: 'Copy Files',
20+
type: 'include_assets',
21+
config: {
22+
inclusions: [
23+
{
24+
type: 'pattern',
25+
baseDir: SUBDIRECTORY_NAME,
26+
destination: SUBDIRECTORY_NAME,
27+
include: FILE_EXTENSIONS.map((ext) => `**/*.${ext}`),
28+
},
29+
],
30+
},
31+
},
32+
],
33+
},
34+
],
1335
appModuleFeatures: () => [],
1436
})
1537

packages/app/src/cli/models/extensions/specifications/checkout_post_purchase.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ const checkoutPostPurchaseSpec = createExtensionSpecification({
1515
schema: CheckoutPostPurchaseSchema,
1616
appModuleFeatures: (_) => ['ui_preview', 'cart_url', 'esbuild', 'single_js_entry_path'],
1717
buildConfig: {mode: 'ui'},
18+
clientSteps: [
19+
{
20+
lifecycle: 'deploy',
21+
steps: [
22+
{id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}},
23+
{id: 'copy-static-assets', name: 'Copy Static Assets', type: 'copy_static_assets', config: {}},
24+
],
25+
},
26+
],
1827
deployConfig: async (config, _) => {
1928
return {metafields: config.metafields ?? []}
2029
},

packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ const checkoutSpec = createExtensionSpecification({
2222
schema: CheckoutSchema,
2323
appModuleFeatures: (_) => ['ui_preview', 'cart_url', 'esbuild', 'single_js_entry_path', 'generates_source_maps'],
2424
buildConfig: {mode: 'ui'},
25+
clientSteps: [
26+
{
27+
lifecycle: 'deploy',
28+
steps: [
29+
{id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}},
30+
{id: 'copy-static-assets', name: 'Copy Static Assets', type: 'copy_static_assets', config: {}},
31+
],
32+
},
33+
],
2534
deployConfig: async (config, directory) => {
2635
return {
2736
extension_points: config.extension_points,

0 commit comments

Comments
 (0)