From dcbe3595b6391de40baffb8e5d5b344346925923 Mon Sep 17 00:00:00 2001 From: Alfonso Noriega Date: Tue, 10 Mar 2026 09:30:35 +0100 Subject: [PATCH] Create abstract client steps infrastracture to externalize the ap modules client configurations --- .../models/extensions/extension-instance.ts | 6 +- .../cli/models/extensions/specification.ts | 16 ++- .../app_config_privacy_compliance_webhooks.ts | 6 +- .../specifications/payments_app_extension.ts | 1 + .../cli/services/build/client-steps.test.ts | 76 ++++++++++++ .../src/cli/services/build/client-steps.ts | 108 ++++++++++++++++++ .../app/src/cli/services/build/steps/index.ts | 31 +++++ .../fetch-extension-specifications.ts | 3 + 8 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 packages/app/src/cli/services/build/client-steps.test.ts create mode 100644 packages/app/src/cli/services/build/client-steps.ts create mode 100644 packages/app/src/cli/services/build/steps/index.ts diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 295fb5bdf7d..e79effd8f1d 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -351,9 +351,10 @@ export class ExtensionInstance(spec: { identifier: string schema: ZodSchemaType + clientSteps?: ClientSteps + buildConfig?: BuildConfig appModuleFeatures?: (config?: TConfiguration) => ExtensionFeature[] transformConfig: TransformationConfig | CustomTransformationConfig uidStrategy?: UidStrategy @@ -263,18 +269,24 @@ export function createConfigExtensionSpecification( - spec: Pick, 'identifier' | 'appModuleFeatures' | 'buildConfig'>, + spec: Pick< + CreateExtensionSpecType, + 'identifier' | 'appModuleFeatures' | 'clientSteps' | 'buildConfig' + >, ) { return createExtensionSpecification({ identifier: spec.identifier, schema: zod.any({}) as unknown as ZodSchemaType, appModuleFeatures: spec.appModuleFeatures, + clientSteps: spec.clientSteps, buildConfig: spec.buildConfig ?? {mode: 'none'}, deployConfig: async (config, directory) => { let parsedConfig = configWithoutFirstClassFields(config) diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.ts b/packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.ts index 0599e0ed0bd..b01adbdaf12 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.ts @@ -77,13 +77,13 @@ function relativeUri(uri?: string, appUrl?: string) { } function getCustomersDeletionUri(webhooks: WebhooksConfig) { - return getComplianceUri(webhooks, 'customers/redact') || webhooks?.privacy_compliance?.customer_deletion_url + return getComplianceUri(webhooks, 'customers/redact') ?? webhooks?.privacy_compliance?.customer_deletion_url } function getCustomersDataRequestUri(webhooks: WebhooksConfig) { - return getComplianceUri(webhooks, 'customers/data_request') || webhooks?.privacy_compliance?.customer_data_request_url + return getComplianceUri(webhooks, 'customers/data_request') ?? webhooks?.privacy_compliance?.customer_data_request_url } function getShopDeletionUri(webhooks: WebhooksConfig) { - return getComplianceUri(webhooks, 'shop/redact') || webhooks?.privacy_compliance?.shop_deletion_url + return getComplianceUri(webhooks, 'shop/redact') ?? webhooks?.privacy_compliance?.shop_deletion_url } diff --git a/packages/app/src/cli/models/extensions/specifications/payments_app_extension.ts b/packages/app/src/cli/models/extensions/specifications/payments_app_extension.ts index 33dfeda8b2e..e27aaab8655 100644 --- a/packages/app/src/cli/models/extensions/specifications/payments_app_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/payments_app_extension.ts @@ -69,6 +69,7 @@ const paymentExtensionSpec = createExtensionSpecification({ ) case CARD_PRESENT_TARGET: return cardPresentPaymentsAppExtensionDeployConfig(config as CardPresentPaymentsAppExtensionConfigType) + case undefined: default: return {} } diff --git a/packages/app/src/cli/services/build/client-steps.test.ts b/packages/app/src/cli/services/build/client-steps.test.ts new file mode 100644 index 00000000000..3c33e94891b --- /dev/null +++ b/packages/app/src/cli/services/build/client-steps.test.ts @@ -0,0 +1,76 @@ +import {executeStep, BuildContext, LifecycleStep} from './client-steps.js' +import * as stepsIndex from './steps/index.js' +import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('./steps/index.js') + +describe('executeStep', () => { + let mockContext: BuildContext + + beforeEach(() => { + mockContext = { + extension: { + directory: '/test/dir', + outputPath: '/test/output/index.js', + } as ExtensionInstance, + options: { + stdout: {write: vi.fn()} as any, + stderr: {write: vi.fn()} as any, + app: {} as any, + environment: 'production' as const, + }, + stepResults: new Map(), + } + }) + + const step: LifecycleStep = { + id: 'test-step', + name: 'Test Step', + type: 'include_assets', + config: {}, + } + + describe('success', () => { + test('returns a successful StepResult with output', async () => { + vi.mocked(stepsIndex.executeStepByType).mockResolvedValue({filesCopied: 3}) + + const result = await executeStep(step, mockContext) + + expect(result.id).toBe('test-step') + expect(result.success).toBe(true) + if (result.success) expect(result.output).toEqual({filesCopied: 3}) + expect(result.duration).toBeGreaterThanOrEqual(0) + }) + + test('logs step execution to stdout', async () => { + vi.mocked(stepsIndex.executeStepByType).mockResolvedValue({}) + + await executeStep(step, mockContext) + + expect(mockContext.options.stdout.write).toHaveBeenCalledWith('Executing step: Test Step\n') + }) + }) + + describe('failure', () => { + test('throws a wrapped error when the step fails', async () => { + vi.mocked(stepsIndex.executeStepByType).mockRejectedValue(new Error('something went wrong')) + + await expect(executeStep(step, mockContext)).rejects.toThrow( + 'Build step "Test Step" failed: something went wrong', + ) + }) + + test('returns a failure result and logs a warning when continueOnError is true', async () => { + vi.mocked(stepsIndex.executeStepByType).mockRejectedValue(new Error('something went wrong')) + + const result = await executeStep({...step, continueOnError: true}, mockContext) + + expect(result.success).toBe(false) + if (!result.success) expect(result.error?.message).toBe('something went wrong') + expect(mockContext.options.stderr.write).toHaveBeenCalledWith( + 'Warning: Step "Test Step" failed but continuing: something went wrong\n', + ) + }) + }) +}) diff --git a/packages/app/src/cli/services/build/client-steps.ts b/packages/app/src/cli/services/build/client-steps.ts new file mode 100644 index 00000000000..9a79bb3334b --- /dev/null +++ b/packages/app/src/cli/services/build/client-steps.ts @@ -0,0 +1,108 @@ +import {executeStepByType} from './steps/index.js' +import type {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import type {ExtensionBuildOptions} from './extension.js' + +/** + * LifecycleStep represents a single step in the client-side build pipeline. + * Pure configuration object — execution logic is separate (router pattern). + */ +export interface LifecycleStep { + /** Unique identifier, used as the key in the stepResults map */ + readonly id: string + + /** Human-readable name for logging */ + readonly name: string + + /** Step type (determines which executor handles it) */ + readonly type: + | 'include_assets' + | 'build_theme' + | 'bundle_theme' + | 'bundle_ui' + | 'copy_static_assets' + | 'build_function' + | 'create_tax_stub' + | 'esbuild' + | 'validate' + | 'transform' + | 'custom' + + /** Step-specific configuration */ + readonly config: {[key: string]: unknown} + + /** Whether to continue on error (default: false) */ + readonly continueOnError?: boolean +} + +/** + * A group of steps scoped to a specific lifecycle phase. + * Allows executing only the steps relevant to a given lifecycle (e.g. 'deploy'). + */ +interface ClientLifecycleGroup { + readonly lifecycle: 'deploy' + readonly steps: ReadonlyArray +} + +/** + * The full client steps configuration for an extension. + * Replaces the old buildConfig contract. + */ +export type ClientSteps = ReadonlyArray + +/** + * Context passed through the step pipeline. + * Each step can read from and write to the context. + */ +export interface BuildContext { + readonly extension: ExtensionInstance + readonly options: ExtensionBuildOptions + readonly stepResults: Map + [key: string]: unknown +} + +type StepResult = { + readonly id: string + readonly duration: number +} & ( + | { + readonly success: false + readonly error: Error + } + | { + readonly success: true + readonly output: unknown + } +) + +/** + * Executes a single client step with error handling. + */ +export async function executeStep(step: LifecycleStep, context: BuildContext): Promise { + const startTime = Date.now() + + try { + context.options.stdout.write(`Executing step: ${step.name}\n`) + const output = await executeStepByType(step, context) + + return { + id: step.id, + success: true, + duration: Date.now() - startTime, + output, + } + } catch (error) { + const stepError = error as Error + + if (step.continueOnError) { + context.options.stderr.write(`Warning: Step "${step.name}" failed but continuing: ${stepError.message}\n`) + return { + id: step.id, + success: false, + duration: Date.now() - startTime, + error: stepError, + } + } + + throw new Error(`Build step "${step.name}" failed: ${stepError.message}`) + } +} diff --git a/packages/app/src/cli/services/build/steps/index.ts b/packages/app/src/cli/services/build/steps/index.ts new file mode 100644 index 00000000000..436bc0b2278 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/index.ts @@ -0,0 +1,31 @@ +import type {LifecycleStep, BuildContext} from '../client-steps.js' + +/** + * Routes step execution to the appropriate handler based on step type. + * This implements the Command Pattern router, dispatching to type-specific executors. + * + * @param step - The build step configuration + * @param context - The build context + * @returns The output from the step execution + * @throws Error if the step type is not implemented or unknown + */ +export async function executeStepByType(step: LifecycleStep, _context: BuildContext): Promise { + switch (step.type) { + // Future step types (not implemented yet): + case 'include_assets': + case 'build_theme': + case 'bundle_theme': + case 'bundle_ui': + case 'copy_static_assets': + case 'build_function': + case 'create_tax_stub': + case 'esbuild': + case 'validate': + case 'transform': + case 'custom': + throw new Error(`Build step type "${step.type}" is not yet implemented.`) + + default: + throw new Error(`Unknown build step type: ${(step as {type: string}).type}`) + } +} diff --git a/packages/app/src/cli/services/generate/fetch-extension-specifications.ts b/packages/app/src/cli/services/generate/fetch-extension-specifications.ts index 37544a1c672..4aa743ea03a 100644 --- a/packages/app/src/cli/services/generate/fetch-extension-specifications.ts +++ b/packages/app/src/cli/services/generate/fetch-extension-specifications.ts @@ -79,6 +79,9 @@ async function mergeLocalAndRemoteSpecs( const merged = {...localSpec, ...remoteSpec, loadedRemoteSpecs: true} as RemoteAwareExtensionSpecification & FlattenedRemoteSpecification + // Not all the specs are moved to remote definition yet + merged.clientSteps ??= localSpec.clientSteps ?? [] + // If configuration is inside an app.toml -- i.e. single UID mode -- we must be able to parse a partial slice. let handleInvalidAdditionalProperties: HandleInvalidAdditionalProperties switch (merged.uidStrategy) {