From cfe3768ab7203e839a8e6e887795f36edb34c985 Mon Sep 17 00:00:00 2001 From: Alfonso Noriega Date: Thu, 19 Feb 2026 13:41:06 +0100 Subject: [PATCH] Abstract build steps to externalize the build configuration --- .../extensions/extension-instance.test.ts | 13 +- .../models/extensions/extension-instance.ts | 58 +++---- .../cli/models/extensions/specification.ts | 5 +- .../app_config_hosted_app_home.test.ts | 2 +- .../app_config_hosted_app_home.ts | 2 +- .../extensions/specifications/channel.test.ts | 156 ++++++++++++++++++ .../extensions/specifications/channel.ts | 15 +- .../specifications/checkout_post_purchase.ts | 8 +- .../specifications/checkout_ui_extension.ts | 8 +- .../specifications/flow_template.test.ts | 154 +++++++++++++++++ .../specifications/flow_template.ts | 18 +- .../extensions/specifications/function.ts | 5 +- .../specifications/function_build.test.ts | 65 ++++++++ .../specifications/pos_ui_extension.ts | 8 +- .../specifications/product_subscription.ts | 8 +- .../specifications/tax_calculation.ts | 5 +- .../tax_calculation_build.test.ts | 68 ++++++++ .../extensions/specifications/theme.test.ts | 140 ++++++++++++++++ .../models/extensions/specifications/theme.ts | 18 +- .../extensions/specifications/ui_extension.ts | 8 +- .../specifications/ui_extension_build.test.ts | 91 ++++++++++ .../specifications/web_pixel_extension.ts | 8 +- .../app/src/cli/services/build/extension.ts | 11 -- 23 files changed, 809 insertions(+), 65 deletions(-) create mode 100644 packages/app/src/cli/models/extensions/specifications/channel.test.ts create mode 100644 packages/app/src/cli/models/extensions/specifications/flow_template.test.ts create mode 100644 packages/app/src/cli/models/extensions/specifications/function_build.test.ts create mode 100644 packages/app/src/cli/models/extensions/specifications/tax_calculation_build.test.ts create mode 100644 packages/app/src/cli/models/extensions/specifications/theme.test.ts create mode 100644 packages/app/src/cli/models/extensions/specifications/ui_extension_build.test.ts diff --git a/packages/app/src/cli/models/extensions/extension-instance.test.ts b/packages/app/src/cli/models/extensions/extension-instance.test.ts index 2adc27a7d1b..c52992affbe 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.test.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.test.ts @@ -32,7 +32,6 @@ vi.mock('../../services/build/extension.js', async () => { return { ...actual, buildUIExtension: vi.fn(), - buildThemeExtension: vi.fn(), buildFunctionExtension: vi.fn(), } }) @@ -148,8 +147,16 @@ describe('build', async () => { // Given const extensionInstance = await testTaxCalculationExtension(tmpDir) const options: ExtensionBuildOptions = { - stdout: new Writable(), - stderr: new Writable(), + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), app: testApp(), environment: 'production', } diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 932033f3154..a83aec2e01d 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -14,14 +14,9 @@ import {WebhooksSpecIdentifier} from './specifications/app_config_webhook.js' import {WebhookSubscriptionSpecIdentifier} from './specifications/app_config_webhook_subscription.js' import {EventsSpecIdentifier} from './specifications/app_config_events.js' import {HostedAppHomeSpecIdentifier} from './specifications/app_config_hosted_app_home.js' -import { - ExtensionBuildOptions, - buildFunctionExtension, - buildThemeExtension, - buildUIExtension, - bundleFunctionExtension, -} from '../../services/build/extension.js' -import {bundleThemeExtension, copyFilesForExtension} from '../../services/extensions/bundle.js' +import {ExtensionBuildOptions, bundleFunctionExtension} from '../../services/build/extension.js' +import {bundleThemeExtension} from '../../services/extensions/bundle.js' +import {BuildContext, executeStep} from '../../services/build/build-steps.js' import {Identifiers} from '../app/identifiers.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {AppConfigurationWithoutPath} from '../app/app.js' @@ -31,7 +26,7 @@ import {constantize, slugify} from '@shopify/cli-kit/common/string' import {hashString, nonRandomUUID} from '@shopify/cli-kit/node/crypto' import {partnersFqdn} from '@shopify/cli-kit/node/context/fqdn' import {joinPath, basename, normalizePath, resolvePath} from '@shopify/cli-kit/node/path' -import {fileExists, touchFile, moveFile, writeFile, glob, copyFile, globSync} from '@shopify/cli-kit/node/fs' +import {fileExists, moveFile, glob, copyFile, globSync} from '@shopify/cli-kit/node/fs' import {getPathValue} from '@shopify/cli-kit/common/object' import {outputDebug} from '@shopify/cli-kit/node/output' import {extractJSImports, extractImportPathsRecursively} from '@shopify/cli-kit/node/import-extractor' @@ -347,34 +342,25 @@ export class ExtensionInstance { - const mode = this.specification.buildConfig.mode + const {buildConfig} = this.specification + + const context: BuildContext = { + extension: this, + options, + stepResults: new Map(), + signal: options.signal, + } - switch (mode) { - case 'theme': - await buildThemeExtension(this, options) - return bundleThemeExtension(this, options) - case 'function': - return buildFunctionExtension(this, options) - case 'ui': - await buildUIExtension(this, options) - // Copy static assets after build completes - return this.copyStaticAssets() - case 'tax_calculation': - await touchFile(this.outputPath) - await writeFile(this.outputPath, '(()=>{})();') - break - case 'copy_files': - return copyFilesForExtension( - this, - options, - this.specification.buildConfig.filePatterns, - this.specification.buildConfig.ignoredFilePatterns, - ) - case 'hosted_app_home': - await this.copyStaticAssets() - break - case 'none': - break + const steps = buildConfig.mode === 'none' ? [] : buildConfig.steps + + for (const step of steps) { + // eslint-disable-next-line no-await-in-loop + const result = await executeStep(step, context) + context.stepResults.set(step.id, result) + + if (!result.success && !step.continueOnError) { + throw new Error(`Build step "${step.displayName}" failed: ${result.error?.message}`) + } } } diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 9c3b5625b90..ab5ad22008f 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -2,6 +2,7 @@ import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js' import {ExtensionInstance} from './extension-instance.js' import {blocks} from '../../constants.js' +import {BuildStep} from '../../services/build/build-steps.js' import {Flag} from '../../utilities/developer-platform-client.js' import {AppConfigurationWithoutPath} from '../app/app.js' @@ -55,8 +56,8 @@ export interface BuildAsset { } type BuildConfig = - | {mode: 'ui' | 'theme' | 'function' | 'tax_calculation' | 'none' | 'hosted_app_home'} - | {mode: 'copy_files'; filePatterns: string[]; ignoredFilePatterns?: string[]} + | {mode: 'none'} + | {mode: 'ui' | 'theme' | 'function' | 'tax_calculation' | 'copy_files'; steps: ReadonlyArray} /** * Extension specification with all the needed properties and methods to load an extension. */ diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.test.ts b/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.test.ts index c45f98bdf6b..98247189610 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.test.ts @@ -90,7 +90,7 @@ describe('hosted_app_home', () => { describe('buildConfig', () => { test('should have hosted_app_home build mode', () => { - expect(spec.buildConfig).toEqual({mode: 'hosted_app_home'}) + expect(spec.buildConfig).toEqual({mode: 'none'}) }) }) diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.ts b/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.ts index 6b71b710496..b11578d83e5 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.ts @@ -16,7 +16,7 @@ export const HostedAppHomeSpecIdentifier = 'hosted_app_home' const hostedAppHomeSpec = createConfigExtensionSpecification({ identifier: HostedAppHomeSpecIdentifier, - buildConfig: {mode: 'hosted_app_home'} as const, + buildConfig: {mode: 'none'} as const, schema: HostedAppHomeSchema, transformConfig: HostedAppHomeTransformConfig, copyStaticAssets: async (config, directory, outputPath) => { diff --git a/packages/app/src/cli/models/extensions/specifications/channel.test.ts b/packages/app/src/cli/models/extensions/specifications/channel.test.ts new file mode 100644 index 00000000000..b1ac36a7d10 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/channel.test.ts @@ -0,0 +1,156 @@ +import spec from './channel.js' +import {ExtensionInstance} from '../extension-instance.js' +import {ExtensionBuildOptions} from '../../../services/build/extension.js' +import {describe, expect, test} from 'vitest' +import {inTemporaryDirectory, writeFile, fileExists, mkdir} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {Writable} from 'stream' + +const SUBDIRECTORY = 'specifications' + +describe('channel_config', () => { + describe('buildConfig', () => { + test('uses build_steps mode', () => { + expect(spec.buildConfig.mode).toBe('copy_files') + }) + + test('has a single copy-files step scoped to the specifications subdirectory', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + expect(spec.buildConfig.steps).toHaveLength(1) + expect(spec.buildConfig.steps[0]).toMatchObject({ + id: 'copy-files', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: {source: '.'}, + }, + }) + + const {patterns} = (spec.buildConfig.steps[0]!.config as {definition: {patterns: string[]}}).definition + + expect(patterns).toEqual( + expect.arrayContaining([ + `${SUBDIRECTORY}/**/*.json`, + `${SUBDIRECTORY}/**/*.toml`, + `${SUBDIRECTORY}/**/*.yaml`, + `${SUBDIRECTORY}/**/*.yml`, + `${SUBDIRECTORY}/**/*.svg`, + ]), + ) + }) + + test('config is serializable to JSON', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const serialized = JSON.stringify(spec.buildConfig) + const deserialized = JSON.parse(serialized) + + expect(deserialized.steps).toHaveLength(1) + expect(deserialized.steps[0].config.strategy).toBe('pattern') + }) + }) + + describe('build integration', () => { + test('copies specification files to output, preserving subdirectory structure', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const specsDir = joinPath(extensionDir, SUBDIRECTORY) + const outputDir = joinPath(tmpDir, 'output') + + await mkdir(specsDir) + await mkdir(outputDir) + + await writeFile(joinPath(specsDir, 'product.json'), '{}') + await writeFile(joinPath(specsDir, 'order.toml'), '[spec]') + await writeFile(joinPath(specsDir, 'logo.svg'), '') + // Root-level files should NOT be copied + await writeFile(joinPath(extensionDir, 'README.md'), '# readme') + await writeFile(joinPath(extensionDir, 'index.js'), 'ignored') + + const extension = new ExtensionInstance({ + configuration: {name: 'my-channel', type: 'channel'}, + configurationPath: '', + directory: extensionDir, + specification: spec, + }) + extension.outputPath = outputDir + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production', + } + + // When + await extension.build(buildOptions) + + // Then — specification files copied with path preserved + await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'product.json'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'order.toml'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'logo.svg'))).resolves.toBe(true) + + // Root-level files not in specifications/ are not copied + await expect(fileExists(joinPath(outputDir, 'README.md'))).resolves.toBe(false) + await expect(fileExists(joinPath(outputDir, 'index.js'))).resolves.toBe(false) + }) + }) + + test('does not copy files with non-matching extensions inside specifications/', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const specsDir = joinPath(extensionDir, SUBDIRECTORY) + const outputDir = joinPath(tmpDir, 'output') + + await mkdir(specsDir) + await mkdir(outputDir) + + await writeFile(joinPath(specsDir, 'spec.json'), '{}') + await writeFile(joinPath(specsDir, 'ignored.ts'), 'const x = 1') + await writeFile(joinPath(specsDir, 'ignored.js'), 'const x = 1') + + const extension = new ExtensionInstance({ + configuration: {name: 'my-channel', type: 'channel'}, + configurationPath: '', + directory: extensionDir, + specification: spec, + }) + extension.outputPath = outputDir + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production', + } + + // When + await extension.build(buildOptions) + + // Then + await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'spec.json'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'ignored.ts'))).resolves.toBe(false) + await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'ignored.js'))).resolves.toBe(false) + }) + }) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/channel.ts b/packages/app/src/cli/models/extensions/specifications/channel.ts index 44ac2c2150d..a89492dca08 100644 --- a/packages/app/src/cli/models/extensions/specifications/channel.ts +++ b/packages/app/src/cli/models/extensions/specifications/channel.ts @@ -8,7 +8,20 @@ const channelSpecificationSpec = createContractBasedModuleSpecification({ identifier: 'channel_config', buildConfig: { mode: 'copy_files', - filePatterns: FILE_EXTENSIONS.map((ext) => joinPath(SUBDIRECTORY_NAME, '**', `*.${ext}`)), + steps: [ + { + id: 'copy-files', + displayName: 'Copy Files', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: { + source: '.', + patterns: FILE_EXTENSIONS.map((ext) => joinPath(SUBDIRECTORY_NAME, '**', `*.${ext}`)), + }, + }, + }, + ], }, appModuleFeatures: () => [], }) diff --git a/packages/app/src/cli/models/extensions/specifications/checkout_post_purchase.ts b/packages/app/src/cli/models/extensions/specifications/checkout_post_purchase.ts index 616adf80b73..71ea4d8c022 100644 --- a/packages/app/src/cli/models/extensions/specifications/checkout_post_purchase.ts +++ b/packages/app/src/cli/models/extensions/specifications/checkout_post_purchase.ts @@ -14,7 +14,13 @@ const checkoutPostPurchaseSpec = createExtensionSpecification({ partnersWebIdentifier: 'post_purchase', schema: CheckoutPostPurchaseSchema, appModuleFeatures: (_) => ['ui_preview', 'cart_url', 'esbuild', 'single_js_entry_path'], - buildConfig: {mode: 'ui'}, + buildConfig: { + mode: 'ui', + steps: [ + {id: 'bundle-ui', displayName: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', displayName: 'Copy Static Assets', type: 'copy_static_assets', config: {}}, + ], + }, deployConfig: async (config, _) => { return {metafields: config.metafields ?? []} }, diff --git a/packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts index f08dfd97c40..dc62f60cbeb 100644 --- a/packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts @@ -21,7 +21,13 @@ const checkoutSpec = createExtensionSpecification({ dependency, schema: CheckoutSchema, appModuleFeatures: (_) => ['ui_preview', 'cart_url', 'esbuild', 'single_js_entry_path', 'generates_source_maps'], - buildConfig: {mode: 'ui'}, + buildConfig: { + mode: 'ui', + steps: [ + {id: 'bundle-ui', displayName: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', displayName: 'Copy Static Assets', type: 'copy_static_assets', config: {}}, + ], + }, deployConfig: async (config, directory) => { return { extension_points: config.extension_points, diff --git a/packages/app/src/cli/models/extensions/specifications/flow_template.test.ts b/packages/app/src/cli/models/extensions/specifications/flow_template.test.ts new file mode 100644 index 00000000000..a6adba2bb67 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/flow_template.test.ts @@ -0,0 +1,154 @@ +import spec from './flow_template.js' +import {ExtensionInstance} from '../extension-instance.js' +import {ExtensionBuildOptions} from '../../../services/build/extension.js' +import {describe, expect, test} from 'vitest' +import {inTemporaryDirectory, writeFile, fileExists, mkdir} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {Writable} from 'stream' + +describe('flow_template', () => { + describe('buildConfig', () => { + test('uses build_steps mode', () => { + expect(spec.buildConfig.mode).toBe('copy_files') + }) + + test('has a single copy-files step', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + expect(spec.buildConfig.steps).toHaveLength(1) + expect(spec.buildConfig.steps[0]).toMatchObject({ + id: 'copy-files', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: { + source: '.', + patterns: expect.arrayContaining(['**/*.flow', '**/*.json', '**/*.toml']), + }, + }, + }) + }) + + test('only copies flow, json, and toml files — not js or ts files', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const {definition} = spec.buildConfig.steps[0]!.config as { + definition: {patterns: string[]} + } + + expect(definition.patterns).toContain('**/*.flow') + expect(definition.patterns).toContain('**/*.json') + expect(definition.patterns).toContain('**/*.toml') + expect(definition.patterns).not.toContain('**/*.js') + expect(definition.patterns).not.toContain('**/*.ts') + }) + + test('config is serializable to JSON', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const serialized = JSON.stringify(spec.buildConfig) + const deserialized = JSON.parse(serialized) + + expect(deserialized.steps).toHaveLength(1) + expect(deserialized.steps[0].config.strategy).toBe('pattern') + }) + }) + + describe('build integration', () => { + test('copies flow, json, and toml files to output directory', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + + await mkdir(extensionDir) + await mkdir(outputDir) + + await writeFile(joinPath(extensionDir, 'template.flow'), 'flow-content') + await writeFile(joinPath(extensionDir, 'config.json'), '{}') + await writeFile(joinPath(extensionDir, 'shopify.app.toml'), '[extension]') + await writeFile(joinPath(extensionDir, 'index.js'), 'console.log("ignored")') + await writeFile(joinPath(extensionDir, 'index.ts'), 'const x = 1') + + const extension = new ExtensionInstance({ + configuration: {name: 'my-flow-template', type: 'flow_template'}, + configurationPath: '', + directory: extensionDir, + + specification: spec as any, + }) + extension.outputPath = outputDir + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production', + } + + // When + await extension.build(buildOptions) + + // Then — only matching extensions are copied + await expect(fileExists(joinPath(outputDir, 'template.flow'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'config.json'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'shopify.app.toml'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'index.js'))).resolves.toBe(false) + await expect(fileExists(joinPath(outputDir, 'index.ts'))).resolves.toBe(false) + }) + }) + + test('preserves subdirectory structure when copying', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + const subDir = joinPath(extensionDir, 'sub') + + await mkdir(extensionDir) + await mkdir(subDir) + await mkdir(outputDir) + + await writeFile(joinPath(subDir, 'nested.flow'), 'nested-flow-content') + + const extension = new ExtensionInstance({ + configuration: {name: 'my-flow-template', type: 'flow_template'}, + configurationPath: '', + directory: extensionDir, + + specification: spec as any, + }) + extension.outputPath = outputDir + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production', + } + + // When + await extension.build(buildOptions) + + // Then — subdirectory structure is preserved + await expect(fileExists(joinPath(outputDir, 'sub', 'nested.flow'))).resolves.toBe(true) + }) + }) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/flow_template.ts b/packages/app/src/cli/models/extensions/specifications/flow_template.ts index 19841b9a4ad..d90bc644d6a 100644 --- a/packages/app/src/cli/models/extensions/specifications/flow_template.ts +++ b/packages/app/src/cli/models/extensions/specifications/flow_template.ts @@ -49,7 +49,23 @@ const flowTemplateSpec = createExtensionSpecification({ identifier: 'flow_template', schema: FlowTemplateExtensionSchema, appModuleFeatures: (_) => ['ui_preview'], - buildConfig: {mode: 'copy_files', filePatterns: ['*.flow', '*.json', '*.toml']}, + buildConfig: { + mode: 'copy_files', + steps: [ + { + id: 'copy-files', + displayName: 'Copy Files', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: { + source: '.', + patterns: ['**/*.flow', '**/*.json', '**/*.toml'], + }, + }, + }, + ], + }, deployConfig: async (config, extensionPath) => { return { template_handle: config.handle, diff --git a/packages/app/src/cli/models/extensions/specifications/function.ts b/packages/app/src/cli/models/extensions/specifications/function.ts index 4687dc44558..1255aec7a48 100644 --- a/packages/app/src/cli/models/extensions/specifications/function.ts +++ b/packages/app/src/cli/models/extensions/specifications/function.ts @@ -83,7 +83,10 @@ const functionSpec = createExtensionSpecification({ ], schema: FunctionExtensionSchema, appModuleFeatures: (_) => ['function'], - buildConfig: {mode: 'function'}, + buildConfig: { + mode: 'function', + steps: [{id: 'build-function', displayName: 'Build Function', type: 'build_function', config: {}}], + }, deployConfig: async (config, directory, apiKey) => { let inputQuery: string | undefined const moduleId = randomUUID() diff --git a/packages/app/src/cli/models/extensions/specifications/function_build.test.ts b/packages/app/src/cli/models/extensions/specifications/function_build.test.ts new file mode 100644 index 00000000000..e5cb20fd80d --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/function_build.test.ts @@ -0,0 +1,65 @@ +import functionSpec from './function.js' +import {ExtensionInstance} from '../extension-instance.js' +import {describe, expect, test, vi} from 'vitest' +import {Writable} from 'stream' + +vi.mock('../../../services/build/extension.js', async (importOriginal) => { + const original = await importOriginal() + return {...original, buildFunctionExtension: vi.fn().mockResolvedValue(undefined)} +}) + +describe('function buildConfig', () => { + test('uses build_steps mode', () => { + expect(functionSpec.buildConfig.mode).toBe('function') + }) + + test('has a single build-function step', () => { + if (functionSpec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const {steps} = functionSpec.buildConfig + + expect(steps).toHaveLength(1) + expect(steps[0]).toMatchObject({id: 'build-function', type: 'build_function'}) + }) + + test('config is serializable to JSON', () => { + if (functionSpec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const serialized = JSON.stringify(functionSpec.buildConfig) + const deserialized = JSON.parse(serialized) + + expect(deserialized.steps).toHaveLength(1) + expect(deserialized.steps[0].type).toBe('build_function') + }) + + test('build_function step invokes buildFunctionExtension', async () => { + const {buildFunctionExtension} = await import('../../../services/build/extension.js') + + const extension = new ExtensionInstance({ + configuration: {name: 'my-function', type: 'product_discounts', api_version: '2022-07'}, + configurationPath: '', + directory: '/tmp/func', + + specification: functionSpec as any, + }) + + const buildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production' as const, + } + + await extension.build(buildOptions) + + expect(buildFunctionExtension).toHaveBeenCalledWith(extension, buildOptions) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts index 33962306a24..49cdaa64f96 100644 --- a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts @@ -11,7 +11,13 @@ const posUISpec = createExtensionSpecification({ dependency, schema: BaseSchema.extend({name: zod.string()}), appModuleFeatures: (_) => ['ui_preview', 'esbuild', 'single_js_entry_path'], - buildConfig: {mode: 'ui'}, + buildConfig: { + mode: 'ui', + steps: [ + {id: 'bundle-ui', displayName: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', displayName: 'Copy Static Assets', type: 'copy_static_assets', config: {}}, + ], + }, deployConfig: async (config, directory) => { const result = await getDependencyVersion(dependency, directory) if (result === 'not_found') throw new BugError(`Dependency ${dependency} not found`) diff --git a/packages/app/src/cli/models/extensions/specifications/product_subscription.ts b/packages/app/src/cli/models/extensions/specifications/product_subscription.ts index ba807e409f6..a1d2752cf5a 100644 --- a/packages/app/src/cli/models/extensions/specifications/product_subscription.ts +++ b/packages/app/src/cli/models/extensions/specifications/product_subscription.ts @@ -12,7 +12,13 @@ const productSubscriptionSpec = createExtensionSpecification({ graphQLType: 'subscription_management', schema: BaseSchema, appModuleFeatures: (_) => ['ui_preview', 'esbuild', 'single_js_entry_path'], - buildConfig: {mode: 'ui'}, + buildConfig: { + mode: 'ui', + steps: [ + {id: 'bundle-ui', displayName: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', displayName: 'Copy Static Assets', type: 'copy_static_assets', config: {}}, + ], + }, deployConfig: async (_, directory) => { const result = await getDependencyVersion(dependency, directory) if (result === 'not_found') throw new BugError(`Dependency ${dependency} not found`) diff --git a/packages/app/src/cli/models/extensions/specifications/tax_calculation.ts b/packages/app/src/cli/models/extensions/specifications/tax_calculation.ts index 1e97e577bb6..0150c630e07 100644 --- a/packages/app/src/cli/models/extensions/specifications/tax_calculation.ts +++ b/packages/app/src/cli/models/extensions/specifications/tax_calculation.ts @@ -28,7 +28,10 @@ const spec = createExtensionSpecification({ identifier: 'tax_calculation', schema: TaxCalculationsSchema, appModuleFeatures: (_) => [], - buildConfig: {mode: 'tax_calculation'}, + buildConfig: { + mode: 'tax_calculation', + steps: [{id: 'create-tax-stub', displayName: 'Create Tax Stub', type: 'create_tax_stub', config: {}}], + }, deployConfig: async (config, _) => { return { production_api_base_url: config.production_api_base_url, diff --git a/packages/app/src/cli/models/extensions/specifications/tax_calculation_build.test.ts b/packages/app/src/cli/models/extensions/specifications/tax_calculation_build.test.ts new file mode 100644 index 00000000000..9d610c054ec --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/tax_calculation_build.test.ts @@ -0,0 +1,68 @@ +import taxCalculationSpec from './tax_calculation.js' +import {ExtensionInstance} from '../extension-instance.js' +import {ExtensionBuildOptions} from '../../../services/build/extension.js' +import {describe, expect, test} from 'vitest' +import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' +import {Writable} from 'stream' + +describe('tax_calculation buildConfig', () => { + test('uses build_steps mode', () => { + expect(taxCalculationSpec.buildConfig.mode).toBe('tax_calculation') + }) + + test('has a single create-tax-stub step', () => { + if (taxCalculationSpec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const {steps} = taxCalculationSpec.buildConfig + + expect(steps).toHaveLength(1) + expect(steps[0]).toMatchObject({id: 'create-tax-stub', type: 'create_tax_stub'}) + }) + + test('config is serializable to JSON', () => { + if (taxCalculationSpec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const serialized = JSON.stringify(taxCalculationSpec.buildConfig) + const deserialized = JSON.parse(serialized) + + expect(deserialized.steps).toHaveLength(1) + expect(deserialized.steps[0].type).toBe('create_tax_stub') + }) + + describe('build integration', () => { + test('creates the stub JS file at outputPath', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extension = new ExtensionInstance({ + configuration: {name: 'tax-calc', type: 'tax_calculation'}, + configurationPath: '', + directory: tmpDir, + + specification: taxCalculationSpec as any, + }) + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production', + } + + // When + await extension.build(buildOptions) + + // Then + const content = await readFile(extension.outputPath) + expect(content).toBe('(()=>{})();') + }) + }) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/theme.test.ts b/packages/app/src/cli/models/extensions/specifications/theme.test.ts new file mode 100644 index 00000000000..526d124cea9 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/theme.test.ts @@ -0,0 +1,140 @@ +import spec from './theme.js' +import {ExtensionInstance} from '../extension-instance.js' +import {ExtensionBuildOptions} from '../../../services/build/extension.js' +import {describe, expect, test, vi} from 'vitest' +import {inTemporaryDirectory, writeFile, fileExists, mkdir} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {Writable} from 'stream' + +vi.mock('../../../services/build/theme-check.js', () => ({ + runThemeCheck: vi.fn().mockResolvedValue(''), +})) + +describe('theme', () => { + describe('buildConfig', () => { + test('uses build_steps mode', () => { + expect(spec.buildConfig.mode).toBe('theme') + }) + + test('has two steps: build-theme and bundle-theme', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const {steps} = spec.buildConfig + + expect(steps).toHaveLength(2) + expect(steps[0]).toMatchObject({id: 'build-theme', type: 'build_theme'}) + expect(steps[1]).toMatchObject({id: 'bundle-theme', type: 'bundle_theme'}) + }) + + test('config is serializable to JSON', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const serialized = JSON.stringify(spec.buildConfig) + const deserialized = JSON.parse(serialized) + + expect(deserialized.steps).toHaveLength(2) + expect(deserialized.steps[0].id).toBe('build-theme') + expect(deserialized.steps[1].id).toBe('bundle-theme') + }) + }) + + describe('build integration', () => { + test('bundles theme files to output directory preserving subdirectory structure', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + const blocksDir = joinPath(extensionDir, 'blocks') + const assetsDir = joinPath(extensionDir, 'assets') + + await mkdir(extensionDir) + await mkdir(outputDir) + await mkdir(blocksDir) + await mkdir(assetsDir) + + await writeFile(joinPath(blocksDir, 'main.liquid'), '{% block %}{% endblock %}') + await writeFile(joinPath(assetsDir, 'style.css'), 'body {}') + + const extension = new ExtensionInstance({ + configuration: {name: 'theme-extension', type: 'theme'}, + configurationPath: '', + directory: extensionDir, + + specification: spec as any, + }) + extension.outputPath = outputDir + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production', + } + + // When + await extension.build(buildOptions) + + // Then — theme files are copied with directory structure preserved + await expect(fileExists(joinPath(outputDir, 'blocks', 'main.liquid'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'assets', 'style.css'))).resolves.toBe(true) + }) + }) + + test('does not copy ignored files (e.g. .DS_Store, .gitkeep)', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + const blocksDir = joinPath(extensionDir, 'blocks') + + await mkdir(extensionDir) + await mkdir(outputDir) + await mkdir(blocksDir) + + await writeFile(joinPath(blocksDir, 'main.liquid'), '{% block %}{% endblock %}') + await writeFile(joinPath(blocksDir, '.DS_Store'), 'ignored') + await writeFile(joinPath(blocksDir, '.gitkeep'), '') + + const extension = new ExtensionInstance({ + configuration: {name: 'theme-extension', type: 'theme'}, + configurationPath: '', + directory: extensionDir, + + specification: spec as any, + }) + extension.outputPath = outputDir + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production', + } + + // When + await extension.build(buildOptions) + + // Then — liquid files are copied, ignored files are not + await expect(fileExists(joinPath(outputDir, 'blocks', 'main.liquid'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'blocks', '.DS_Store'))).resolves.toBe(false) + await expect(fileExists(joinPath(outputDir, 'blocks', '.gitkeep'))).resolves.toBe(false) + }) + }) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/theme.ts b/packages/app/src/cli/models/extensions/specifications/theme.ts index 6debcc8135e..f9f05866323 100644 --- a/packages/app/src/cli/models/extensions/specifications/theme.ts +++ b/packages/app/src/cli/models/extensions/specifications/theme.ts @@ -12,7 +12,23 @@ const themeSpec = createExtensionSpecification({ schema: BaseSchema, partnersWebIdentifier: 'theme_app_extension', graphQLType: 'theme_app_extension', - buildConfig: {mode: 'theme'}, + buildConfig: { + mode: 'theme', + steps: [ + { + id: 'build-theme', + displayName: 'Build Theme Extension', + type: 'build_theme', + config: {}, + }, + { + id: 'bundle-theme', + displayName: 'Bundle Theme Extension', + type: 'bundle_theme', + config: {}, + }, + ], + }, appModuleFeatures: (_) => { return ['theme'] }, diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts index f3e04cbeab9..26a789a0c38 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -101,7 +101,13 @@ const uiExtensionSpec = createExtensionSpecification({ identifier: 'ui_extension', dependency, schema: UIExtensionSchema, - buildConfig: {mode: 'ui'}, + buildConfig: { + mode: 'ui', + steps: [ + {id: 'bundle-ui', displayName: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', displayName: 'Copy Static Assets', type: 'copy_static_assets', config: {}}, + ], + }, appModuleFeatures: (config) => { const basic: ExtensionFeature[] = ['ui_preview', 'esbuild', 'generates_source_maps'] const needsCart = diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension_build.test.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension_build.test.ts new file mode 100644 index 00000000000..36994ad7237 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension_build.test.ts @@ -0,0 +1,91 @@ +import uiExtensionSpec from './ui_extension.js' +import checkoutPostPurchaseSpec from './checkout_post_purchase.js' +import checkoutUiExtensionSpec from './checkout_ui_extension.js' +import posUiExtensionSpec from './pos_ui_extension.js' +import productSubscriptionSpec from './product_subscription.js' +import webPixelExtensionSpec from './web_pixel_extension.js' +import {ExtensionInstance} from '../extension-instance.js' +import {ExtensionBuildOptions} from '../../../services/build/extension.js' +import {describe, expect, test, vi} from 'vitest' +import {Writable} from 'stream' + +vi.mock('../../../services/build/extension.js', async (importOriginal) => { + const original = await importOriginal() + return {...original, buildUIExtension: vi.fn().mockResolvedValue(undefined)} +}) + +const UI_SPECS = [ + {name: 'ui_extension', spec: uiExtensionSpec}, + {name: 'checkout_post_purchase', spec: checkoutPostPurchaseSpec}, + {name: 'checkout_ui_extension', spec: checkoutUiExtensionSpec}, + {name: 'pos_ui_extension', spec: posUiExtensionSpec}, + {name: 'product_subscription', spec: productSubscriptionSpec}, + {name: 'web_pixel_extension', spec: webPixelExtensionSpec}, +] + +describe('UI extension build configs', () => { + for (const {name, spec} of UI_SPECS) { + describe(name, () => { + test('uses build_steps mode', () => { + expect(spec.buildConfig.mode).toBe('ui') + }) + + test('has bundle-ui and copy-static-assets steps', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const {steps} = spec.buildConfig + + expect(steps).toHaveLength(2) + expect(steps[0]).toMatchObject({id: 'bundle-ui', type: 'bundle_ui'}) + expect(steps[1]).toMatchObject({id: 'copy-static-assets', type: 'copy_static_assets'}) + }) + + test('config is serializable to JSON', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const serialized = JSON.stringify(spec.buildConfig) + const deserialized = JSON.parse(serialized) + + expect(deserialized.steps).toHaveLength(2) + expect(deserialized.steps[0].type).toBe('bundle_ui') + expect(deserialized.steps[1].type).toBe('copy_static_assets') + }) + }) + } + + describe('bundle-ui step invokes buildUIExtension', () => { + test('calls buildUIExtension with extension and options', async () => { + const {buildUIExtension} = await import('../../../services/build/extension.js') + + const extension = new ExtensionInstance({ + configuration: {name: 'ui-ext', type: 'product_subscription', metafields: []}, + configurationPath: '', + directory: '/tmp/ext', + + specification: uiExtensionSpec as any, + }) + + const copyStaticAssetsSpy = vi.spyOn(extension, 'copyStaticAssets').mockResolvedValue(undefined) + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production', + } + + await extension.build(buildOptions) + + expect(buildUIExtension).toHaveBeenCalledWith(extension, buildOptions) + expect(copyStaticAssetsSpy).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/web_pixel_extension.ts b/packages/app/src/cli/models/extensions/specifications/web_pixel_extension.ts index 298a18d876b..d63387a07c5 100644 --- a/packages/app/src/cli/models/extensions/specifications/web_pixel_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/web_pixel_extension.ts @@ -31,7 +31,13 @@ const webPixelSpec = createExtensionSpecification({ partnersWebIdentifier: 'web_pixel', schema: WebPixelSchema, appModuleFeatures: (_) => ['esbuild', 'single_js_entry_path'], - buildConfig: {mode: 'ui'}, + buildConfig: { + mode: 'ui', + steps: [ + {id: 'bundle-ui', displayName: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', displayName: 'Copy Static Assets', type: 'copy_static_assets', config: {}}, + ], + }, deployConfig: async (config, _) => { return { runtime_context: config.runtime_context, diff --git a/packages/app/src/cli/services/build/extension.ts b/packages/app/src/cli/services/build/extension.ts index 0e992326a25..eba50af4611 100644 --- a/packages/app/src/cli/services/build/extension.ts +++ b/packages/app/src/cli/services/build/extension.ts @@ -1,4 +1,3 @@ -import {runThemeCheck} from './theme-check.js' import {AppInterface} from '../../models/app/app.js' import {bundleExtension} from '../extensions/bundle.js' import {buildJSFunction, runTrampoline, runWasmOpt} from '../function/build.js' @@ -55,16 +54,6 @@ export interface ExtensionBuildOptions { appURL?: string } -/** - * It builds the theme extensions. - * @param options - Build options. - */ -export async function buildThemeExtension(extension: ExtensionInstance, options: ExtensionBuildOptions): Promise { - options.stdout.write(`Running theme check on your Theme app extension...`) - const offenses = await runThemeCheck(extension.directory) - if (offenses) options.stdout.write(offenses) -} - /** * It builds the UI extensions. * @param options - Build options.