Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions packages/app/src/cli/models/extensions/extension-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const CONFIG_EXTENSION_IDS: string[] = [
WebhookSubscriptionSpecIdentifier,
WebhooksSpecIdentifier,
EventsSpecIdentifier,
'admin',
]

/**
Expand Down Expand Up @@ -368,17 +369,20 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
return copyFilesForExtension(
this,
options,
this.specification.buildConfig.filePatterns,
this.specification.buildConfig.ignoredFilePatterns,
this.specification.buildConfig.filePatterns ?? [],
this.specification.buildConfig.ignoredFilePatterns ?? [],
)
case 'none':
break
}
}

async buildForBundle(options: ExtensionBuildOptions, bundleDirectory: string, outputId?: string) {
this.outputPath = this.getOutputPathForDirectory(bundleDirectory, outputId)

if (this.isAppConfigExtension) {
this.outputPath = joinPath(bundleDirectory, this.uid)
} else {
this.outputPath = this.getOutputPathForDirectory(bundleDirectory, outputId)
}
await this.build(options)

const bundleInputPath = joinPath(bundleDirectory, this.getOutputFolderId(outputId))
Expand Down
24 changes: 20 additions & 4 deletions packages/app/src/cli/models/extensions/specification.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js'
import {ExtensionInstance} from './extension-instance.js'
import {blocks} from '../../constants.js'
import {ClientSteps} from '../../services/build/client-steps.js'

import {Flag} from '../../utilities/developer-platform-client.js'
import {AppConfigurationWithoutPath} from '../app/app.js'
Expand Down Expand Up @@ -53,9 +54,14 @@ export interface BuildAsset {
static?: boolean
}

type BuildConfig =
| {mode: 'ui' | 'theme' | 'function' | 'tax_calculation' | 'none'}
| {mode: 'copy_files'; filePatterns: string[]; ignoredFilePatterns?: string[]}
type BuildMode = 'copy_files' | 'theme' | 'function' | 'ui' | 'tax_calculation' | 'hosted_app_home' | 'none'

interface BuildConfig {
mode: BuildMode
filePatterns?: string[]
ignoredFilePatterns?: string[]
}

/**
* Extension specification with all the needed properties and methods to load an extension.
*/
Expand All @@ -69,6 +75,7 @@ export interface ExtensionSpecification<TConfiguration extends BaseConfigType =
surface: string
registrationLimit: number
experience: ExtensionExperience
clientSteps?: ClientSteps
buildConfig: BuildConfig
dependency?: string
graphQLType?: string
Expand Down Expand Up @@ -203,6 +210,7 @@ export function createExtensionSpecification<TConfiguration extends BaseConfigTy
experience: spec.experience ?? 'extension',
uidStrategy: spec.uidStrategy ?? (spec.experience === 'configuration' ? 'single' : 'uuid'),
getDevSessionUpdateMessages: spec.getDevSessionUpdateMessages,
clientSteps: spec.clientSteps,
buildConfig: spec.buildConfig ?? {mode: 'none'},
}
const merged = {...defaults, ...spec}
Expand Down Expand Up @@ -245,6 +253,8 @@ export function createExtensionSpecification<TConfiguration extends BaseConfigTy
export function createConfigExtensionSpecification<TConfiguration extends BaseConfigType = BaseConfigType>(spec: {
identifier: string
schema: ZodSchemaType<TConfiguration>
clientSteps?: ClientSteps
buildConfig?: BuildConfig
appModuleFeatures?: (config?: TConfiguration) => ExtensionFeature[]
transformConfig: TransformationConfig | CustomTransformationConfig
uidStrategy?: UidStrategy
Expand All @@ -262,18 +272,24 @@ export function createConfigExtensionSpecification<TConfiguration extends BaseCo
transformRemoteToLocal: resolveReverseAppConfigTransform(spec.schema, spec.transformConfig),
experience: 'configuration',
uidStrategy: spec.uidStrategy ?? 'single',
clientSteps: spec.clientSteps,
buildConfig: spec.buildConfig ?? {mode: 'none'},
getDevSessionUpdateMessages: spec.getDevSessionUpdateMessages,
patchWithAppDevURLs: spec.patchWithAppDevURLs,
})
}

export function createContractBasedModuleSpecification<TConfiguration extends BaseConfigType = BaseConfigType>(
spec: Pick<CreateExtensionSpecType<TConfiguration>, 'identifier' | 'appModuleFeatures' | 'buildConfig'>,
spec: Pick<
CreateExtensionSpecType<TConfiguration>,
'identifier' | 'appModuleFeatures' | 'clientSteps' | 'buildConfig'
>,
) {
return createExtensionSpecification({
identifier: spec.identifier,
schema: zod.any({}) as unknown as ZodSchemaType<TConfiguration>,
appModuleFeatures: spec.appModuleFeatures,
clientSteps: spec.clientSteps,
buildConfig: spec.buildConfig ?? {mode: 'none'},
deployConfig: async (config, directory) => {
let parsedConfig = configWithoutFirstClassFields(config)
Expand Down
162 changes: 162 additions & 0 deletions packages/app/src/cli/services/build/client-steps.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import {ExtensionBuildOptions} from './extension.js'
import {executeStep, BuildContext} from './client-steps.js'
import {ExtensionInstance} from '../../models/extensions/extension-instance.js'
import {describe, expect, test} from 'vitest'
import {inTemporaryDirectory, writeFile, readFile, mkdir, fileExists} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'
import {Writable} from 'stream'

function buildOptions(): ExtensionBuildOptions {
return {
stdout: new Writable({
write(chunk, encoding, callback) {
callback()
},
}),
stderr: new Writable({
write(chunk, encoding, callback) {
callback()
},
}),
app: {} as any,
environment: 'production',
}
}

describe('client_steps integration', () => {
test('executes include_assets step and copies files to output', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Setup: Create extension directory with assets
const extensionDir = joinPath(tmpDir, 'extension')
const assetsDir = joinPath(extensionDir, 'assets')
const outputDir = joinPath(tmpDir, 'output')

await mkdir(extensionDir)
await mkdir(assetsDir)
await mkdir(outputDir)

// Create test files
await writeFile(joinPath(assetsDir, 'logo.png'), 'fake-png-data')
await writeFile(joinPath(assetsDir, 'style.css'), 'body { color: red; }')

const mockExtension = {
directory: extensionDir,
outputPath: joinPath(outputDir, 'extension.js'),
} as ExtensionInstance

const context: BuildContext = {extension: mockExtension, options: buildOptions(), stepResults: new Map()}

await executeStep(
{
id: 'copy-assets',
name: 'Copy Assets',
type: 'include_assets',
config: {
inclusions: [{type: 'pattern', baseDir: 'assets', include: ['**/*']}],
},
},
context,
)

// Verify: Files were copied to output directory
const logoExists = await fileExists(joinPath(outputDir, 'logo.png'))
const styleExists = await fileExists(joinPath(outputDir, 'style.css'))

expect(logoExists).toBe(true)
expect(styleExists).toBe(true)

const logoContent = await readFile(joinPath(outputDir, 'logo.png'))
const styleContent = await readFile(joinPath(outputDir, 'style.css'))

expect(logoContent).toBe('fake-png-data')
expect(styleContent).toBe('body { color: red; }')
})
})

test('executes multiple steps in sequence', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Setup: Create extension with two asset directories
const extensionDir = joinPath(tmpDir, 'extension')
const imagesDir = joinPath(extensionDir, 'images')
const stylesDir = joinPath(extensionDir, 'styles')
const outputDir = joinPath(tmpDir, 'output')

await mkdir(extensionDir)
await mkdir(imagesDir)
await mkdir(stylesDir)
await mkdir(outputDir)

await writeFile(joinPath(imagesDir, 'logo.png'), 'logo-data')
await writeFile(joinPath(stylesDir, 'main.css'), 'css-data')

const mockExtension = {
directory: extensionDir,
outputPath: joinPath(outputDir, 'extension.js'),
} as ExtensionInstance

const context: BuildContext = {extension: mockExtension, options: buildOptions(), stepResults: new Map()}

await executeStep(
{
id: 'copy-images',
name: 'Copy Images',
type: 'include_assets',
config: {
inclusions: [{type: 'pattern', baseDir: 'images', include: ['**/*'], destination: 'assets/images'}],
},
},
context,
)
await executeStep(
{
id: 'copy-styles',
name: 'Copy Styles',
type: 'include_assets',
config: {
inclusions: [{type: 'pattern', baseDir: 'styles', include: ['**/*'], destination: 'assets/styles'}],
},
},
context,
)

// Verify: Files from both steps were copied to correct destinations
const logoExists = await fileExists(joinPath(outputDir, 'assets/images/logo.png'))
const styleExists = await fileExists(joinPath(outputDir, 'assets/styles/main.css'))

expect(logoExists).toBe(true)
expect(styleExists).toBe(true)
})
})

test('silently skips configKey step when config key is absent from extension config', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const extensionDir = joinPath(tmpDir, 'extension')
const outputDir = joinPath(tmpDir, 'output')

await mkdir(extensionDir)
await mkdir(outputDir)

// Extension has no configuration — static_root key is absent
const mockExtension = {
directory: extensionDir,
outputPath: joinPath(outputDir, 'extension.js'),
configuration: {},
} as unknown as ExtensionInstance

const context: BuildContext = {extension: mockExtension, options: buildOptions(), stepResults: new Map()}

// Should not throw — absent configKey values are silently skipped
await expect(
executeStep(
{
id: 'copy-static-assets',
name: 'Copy Static Assets',
type: 'include_assets',
config: {inclusions: [{type: 'configKey', configKey: 'static_root'}]},
},
context,
),
).resolves.not.toThrow()
})
})
})
76 changes: 76 additions & 0 deletions packages/app/src/cli/services/build/client-steps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {executeStep, ClientStep, BuildContext} 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: ClientStep = {
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)
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)
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',
)
})
})
})
Loading
Loading