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 5a808958df..346b572887 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 605637b16a..549afe5b8f 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -11,24 +11,19 @@ import {PrivacyComplianceWebhooksSpecIdentifier} from './specifications/app_conf 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 { - 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 {Identifiers} from '../app/identifiers.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {AppConfigurationWithoutPath} from '../app/app.js' import {ApplicationURLs} from '../../services/dev/urls.js' +import {executeStep, BuildContext} from '../../services/build/client-steps.js' import {ok} from '@shopify/cli-kit/node/result' 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' @@ -138,7 +133,7 @@ export class ExtensionInstance { - const mode = this.specification.buildConfig.mode + const {clientSteps} = this.specification + + const context: BuildContext = { + extension: this, + options, + stepResults: new Map(), + } - 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 'none': - break + const steps = clientSteps + .filter((lifecycle) => lifecycle.lifecycle === 'deploy') + .flatMap((lifecycle) => lifecycle.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.name}" failed: ${result.error?.message}`) + } } } 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 0000000000..6ac4861fd0 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/channel.test.ts @@ -0,0 +1,143 @@ +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('clientSteps', () => { + test('uses copy_files mode', () => { + expect(spec.buildConfig.mode).toBe('copy_files') + }) + + test('has a single copy-files step scoped to the specifications subdirectory', () => { + expect(spec.clientSteps[0]!.steps).toHaveLength(1) + expect(spec.clientSteps[0]!.steps[0]).toMatchObject({ + id: 'copy-files', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', baseDir: SUBDIRECTORY, destination: SUBDIRECTORY}], + }, + }) + + const {include} = (spec.clientSteps[0]!.steps[0]!.config as {inclusions: [{include: string[]}]}).inclusions[0] + + expect(include).toEqual(expect.arrayContaining(['**/*.json', '**/*.toml', '**/*.yaml', '**/*.yml', '**/*.svg'])) + }) + + test('config is serializable to JSON', () => { + const serialized = JSON.stringify(spec.clientSteps) + const deserialized = JSON.parse(serialized) + + expect(deserialized[0].steps).toHaveLength(1) + expect(deserialized[0].steps[0].config.inclusions[0].type).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 44ac2c2150..abe89c4a4e 100644 --- a/packages/app/src/cli/models/extensions/specifications/channel.ts +++ b/packages/app/src/cli/models/extensions/specifications/channel.ts @@ -10,6 +10,28 @@ const channelSpecificationSpec = createContractBasedModuleSpecification({ mode: 'copy_files', filePatterns: FILE_EXTENSIONS.map((ext) => joinPath(SUBDIRECTORY_NAME, '**', `*.${ext}`)), }, + clientSteps: [ + { + lifecycle: 'deploy', + steps: [ + { + id: 'copy-files', + name: 'Copy Files', + type: 'include_assets', + config: { + inclusions: [ + { + type: 'pattern', + baseDir: SUBDIRECTORY_NAME, + destination: SUBDIRECTORY_NAME, + include: FILE_EXTENSIONS.map((ext) => `**/*.${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 616adf80b7..319afafb00 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 @@ -15,6 +15,15 @@ const checkoutPostPurchaseSpec = createExtensionSpecification({ schema: CheckoutPostPurchaseSchema, appModuleFeatures: (_) => ['ui_preview', 'cart_url', 'esbuild', 'single_js_entry_path'], buildConfig: {mode: 'ui'}, + clientSteps: [ + { + lifecycle: 'deploy', + steps: [ + {id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', name: '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 f08dfd97c4..10653d633a 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 @@ -22,6 +22,15 @@ const checkoutSpec = createExtensionSpecification({ schema: CheckoutSchema, appModuleFeatures: (_) => ['ui_preview', 'cart_url', 'esbuild', 'single_js_entry_path', 'generates_source_maps'], buildConfig: {mode: 'ui'}, + clientSteps: [ + { + lifecycle: 'deploy', + steps: [ + {id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', name: '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 0000000000..bc9bd186b0 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/flow_template.test.ts @@ -0,0 +1,142 @@ +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('clientSteps', () => { + test('uses copy_files mode', () => { + expect(spec.buildConfig.mode).toBe('copy_files') + }) + + test('has a single copy-files step', () => { + expect(spec.clientSteps[0]!.steps).toHaveLength(1) + expect(spec.clientSteps[0]!.steps[0]).toMatchObject({ + id: 'copy-files', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern'}], + }, + }) + }) + + test('only copies flow, json, and toml files — not js or ts files', () => { + const {include} = (spec.clientSteps[0]!.steps[0]!.config as {inclusions: [{include: string[]}]}).inclusions[0] + + expect(include).toContain('**/*.flow') + expect(include).toContain('**/*.json') + expect(include).toContain('**/*.toml') + expect(include).not.toContain('**/*.js') + expect(include).not.toContain('**/*.ts') + }) + + test('config is serializable to JSON', () => { + const serialized = JSON.stringify(spec.clientSteps) + const deserialized = JSON.parse(serialized) + + expect(deserialized[0].steps).toHaveLength(1) + expect(deserialized[0].steps[0].config.inclusions[0].type).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 19841b9a4a..67f3759ad7 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,22 @@ const flowTemplateSpec = createExtensionSpecification({ identifier: 'flow_template', schema: FlowTemplateExtensionSchema, appModuleFeatures: (_) => ['ui_preview'], - buildConfig: {mode: 'copy_files', filePatterns: ['*.flow', '*.json', '*.toml']}, + buildConfig: {mode: 'copy_files', filePatterns: ['**/*.flow', '**/*.json', '**/*.toml']}, + clientSteps: [ + { + lifecycle: 'deploy', + steps: [ + { + id: 'copy-files', + name: 'Copy Files', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', include: ['**/*.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 ce6d2ec5a5..1a3ba86d35 100644 --- a/packages/app/src/cli/models/extensions/specifications/function.ts +++ b/packages/app/src/cli/models/extensions/specifications/function.ts @@ -88,6 +88,12 @@ const functionSpec = createExtensionSpecification({ schema: FunctionExtensionSchema, appModuleFeatures: (_) => ['function'], buildConfig: {mode: 'function'}, + clientSteps: [ + { + lifecycle: 'deploy', + steps: [{id: 'build-function', name: '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 0000000000..8603de9fcf --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/function_build.test.ts @@ -0,0 +1,61 @@ +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', () => { + const {steps} = functionSpec.clientSteps[0]! + + expect(steps).toHaveLength(1) + expect(steps[0]).toMatchObject({id: 'build-function', type: 'build_function'}) + }) + + test('config is serializable to JSON', () => { + const serialized = JSON.stringify(functionSpec.clientSteps) + const deserialized = JSON.parse(serialized) + + expect(deserialized[0].steps).toHaveLength(1) + expect(deserialized[0].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 33962306a2..88e21382b2 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 @@ -12,6 +12,15 @@ const posUISpec = createExtensionSpecification({ schema: BaseSchema.extend({name: zod.string()}), appModuleFeatures: (_) => ['ui_preview', 'esbuild', 'single_js_entry_path'], buildConfig: {mode: 'ui'}, + clientSteps: [ + { + lifecycle: 'deploy', + steps: [ + {id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', name: '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 ba807e409f..804b5249cd 100644 --- a/packages/app/src/cli/models/extensions/specifications/product_subscription.ts +++ b/packages/app/src/cli/models/extensions/specifications/product_subscription.ts @@ -13,6 +13,15 @@ const productSubscriptionSpec = createExtensionSpecification({ schema: BaseSchema, appModuleFeatures: (_) => ['ui_preview', 'esbuild', 'single_js_entry_path'], buildConfig: {mode: 'ui'}, + clientSteps: [ + { + lifecycle: 'deploy', + steps: [ + {id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', name: '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 1e97e577bb..5aeef75999 100644 --- a/packages/app/src/cli/models/extensions/specifications/tax_calculation.ts +++ b/packages/app/src/cli/models/extensions/specifications/tax_calculation.ts @@ -29,6 +29,12 @@ const spec = createExtensionSpecification({ schema: TaxCalculationsSchema, appModuleFeatures: (_) => [], buildConfig: {mode: 'tax_calculation'}, + clientSteps: [ + { + lifecycle: 'deploy', + steps: [{id: 'create-tax-stub', name: '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 0000000000..1ea66b727b --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/tax_calculation_build.test.ts @@ -0,0 +1,64 @@ +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', () => { + const {steps} = taxCalculationSpec.clientSteps[0]! + + expect(steps).toHaveLength(1) + expect(steps[0]).toMatchObject({id: 'create-tax-stub', type: 'create_tax_stub'}) + }) + + test('config is serializable to JSON', () => { + const serialized = JSON.stringify(taxCalculationSpec.clientSteps) + const deserialized = JSON.parse(serialized) + + expect(deserialized[0].steps).toHaveLength(1) + expect(deserialized[0].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 0000000000..f15e1c06b1 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/theme.test.ts @@ -0,0 +1,136 @@ +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', () => { + const {steps} = spec.clientSteps[0]! + + 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', () => { + const serialized = JSON.stringify(spec.clientSteps) + const deserialized = JSON.parse(serialized) + + expect(deserialized[0].steps).toHaveLength(2) + expect(deserialized[0].steps[0].id).toBe('build-theme') + expect(deserialized[0].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 b3cc7510d2..c01fa7d576 100644 --- a/packages/app/src/cli/models/extensions/specifications/theme.ts +++ b/packages/app/src/cli/models/extensions/specifications/theme.ts @@ -13,6 +13,15 @@ const themeSpec = createExtensionSpecification({ partnersWebIdentifier: 'theme_app_extension', graphQLType: 'theme_app_extension', buildConfig: {mode: 'theme'}, + clientSteps: [ + { + lifecycle: 'deploy', + steps: [ + {id: 'build-theme', name: 'Build Theme Extension', type: 'build_theme', config: {}}, + {id: 'bundle-theme', name: '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 f3e04cbeab..bfee2f7f75 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -102,6 +102,15 @@ const uiExtensionSpec = createExtensionSpecification({ dependency, schema: UIExtensionSchema, buildConfig: {mode: 'ui'}, + clientSteps: [ + { + lifecycle: 'deploy', + steps: [ + {id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', name: '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 0000000000..8e4cd9aa91 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension_build.test.ts @@ -0,0 +1,87 @@ +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', () => { + const {steps} = spec.clientSteps[0]! + + 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', () => { + const serialized = JSON.stringify(spec.clientSteps) + const deserialized = JSON.parse(serialized) + + expect(deserialized[0].steps).toHaveLength(2) + expect(deserialized[0].steps[0].type).toBe('bundle_ui') + expect(deserialized[0].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 298a18d876..ba63077c3f 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 @@ -32,6 +32,15 @@ const webPixelSpec = createExtensionSpecification({ schema: WebPixelSchema, appModuleFeatures: (_) => ['esbuild', 'single_js_entry_path'], buildConfig: {mode: 'ui'}, + clientSteps: [ + { + lifecycle: 'deploy', + steps: [ + {id: 'bundle-ui', name: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', name: '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 21072a23f6..0702eeb874 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 {buildGraphqlTypes, 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.