-
Notifications
You must be signed in to change notification settings - Fork 228
Refactor extension build process to use modular build steps #6867
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 01-build-steps-infrastructure
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<TConfiguration extends BaseConfigType = BaseConfi | |
|
|
||
| get outputFileName() { | ||
| const mode = this.specification.buildConfig.mode | ||
| if (mode === 'copy_files' || mode === 'theme') { | ||
| if (mode === 'hosted_app_home' || mode === 'theme') { | ||
| return '' | ||
| } else if (mode === 'function') { | ||
| return 'index.wasm' | ||
|
|
@@ -348,31 +343,26 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi | |
| } | ||
|
|
||
| async build(options: ExtensionBuildOptions): Promise<void> { | ||
| 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}`) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicate/double-wrapped error handling in ExtensionInstance.build()
throw new Error(`Build step "${step.displayName}" failed: ${stepError.message}`)But if (!result.success && !step.continueOnError) {
throw new Error(`Build step "${step.displayName}" failed: ${result.error?.message}`)
}That condition is effectively unreachable because Impact:
|
||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'), '<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) | ||
| }) | ||
| }) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Crash when
specification.clientStepsis missing/undefinedExtensionInstance.build()now does:If
this.specification.clientStepsisundefinedfor any extension type (including internal/legacy specs not updated in this PR), this will throw:TypeError: Cannot read properties of undefined (reading 'filter').Impact:
clientSteps, blocking development and deployments.