Skip to content
Draft
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
165 changes: 165 additions & 0 deletions packages/app/src/cli/services/build/build-steps.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import {ExtensionBuildOptions} from './extension.js'
import {executeStep, BuildContext} from './build-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('build_steps integration', () => {
test('executes copy_files 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',
displayName: 'Copy Assets',
type: 'copy_files',
config: {
strategy: 'pattern',
definition: {source: 'assets', patterns: ['**/*']},
},
},
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',
displayName: 'Copy Images',
type: 'copy_files',
config: {
strategy: 'pattern',
definition: {source: 'images', patterns: ['**/*'], destination: 'assets/images'},
},
},
context,
)
await executeStep(
{
id: 'copy-styles',
displayName: 'Copy Styles',
type: 'copy_files',
config: {
strategy: 'pattern',
definition: {source: 'styles', patterns: ['**/*'], 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 tomlKeys step when TOML 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 tomlKeys are silently skipped
await expect(
executeStep(
{
id: 'copy-static',
displayName: 'Copy Static Assets',
type: 'copy_files',
config: {strategy: 'files', definition: {files: [{tomlKey: 'static_root'}]}},
},
context,
),
).resolves.not.toThrow()
})
})
})
77 changes: 77 additions & 0 deletions packages/app/src/cli/services/build/build-steps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {executeStep, BuildStep, BuildContext} from './build-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: BuildStep = {
id: 'test-step',
displayName: 'Test Step',
type: 'copy_files',
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.stepId).toBe('test-step')
expect(result.displayName).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',
)
})
})
})
114 changes: 114 additions & 0 deletions packages/app/src/cli/services/build/build-steps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {executeStepByType} from './steps/index.js'
import type {ExtensionInstance} from '../../models/extensions/extension-instance.js'
import type {ExtensionBuildOptions} from './extension.js'

/**
* BuildStep represents a single build command configuration.
* Inspired by the existing Task<TContext> pattern in:
* /packages/cli-kit/src/private/node/ui/components/Tasks.tsx
*
* Key differences from Task<TContext>:
* - Not coupled to UI rendering
* - Pure configuration object (execution logic is separate)
* - Router pattern dispatches to type-specific executors
*/
export interface BuildStep {
/** Unique identifier for this step (e.g., 'copy_files', 'build') */
readonly id: string

/** Display name for logging */
readonly displayName: string

/** Optional description */
readonly description?: string

/** Step type (determines which executor handles it) */
readonly type:
| 'copy_files'
| '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
}

/**
* BuildContext is passed through the pipeline (similar to Task<TContext>).
* Each step can read from and write to the context.
*
* Key design: Immutable configuration, mutable context
*/
export interface BuildContext {
/** The extension being built */
readonly extension: ExtensionInstance

/** Build options (stdout, stderr, etc.) */
readonly options: ExtensionBuildOptions

/** Results from previous steps (for step dependencies) */
readonly stepResults: Map<string, StepResult>

/** Custom data that steps can write to (extensible) */
[key: string]: unknown
}

/**
* Result of a step execution
*/
interface StepResult {
readonly stepId: string
readonly displayName: string
readonly success: boolean
readonly duration: number
readonly output?: unknown
readonly error?: Error
}

/**
* Executes a single build step with error handling and skip logic.
*/
export async function executeStep(step: BuildStep, context: BuildContext): Promise<StepResult> {
const startTime = Date.now()

try {
// Execute the step using type-specific executor
context.options.stdout.write(`Executing step: ${step.displayName}\n`)
const output = await executeStepByType(step, context)

return {
stepId: step.id,
displayName: step.displayName,
success: true,
duration: Date.now() - startTime,
output,
}
} catch (error) {
const stepError = error as Error

if (step.continueOnError) {
context.options.stderr.write(`Warning: Step "${step.displayName}" failed but continuing: ${stepError.message}\n`)
return {
stepId: step.id,
displayName: step.displayName,
success: false,
duration: Date.now() - startTime,
error: stepError,
}
}

throw new Error(`Build step "${step.displayName}" failed: ${stepError.message}`)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {buildFunctionExtension} from '../extension.js'
import type {BuildStep, BuildContext} from '../build-steps.js'

/**
* Executes a build_function build step.
*
* Compiles the function extension (JavaScript or other language) to WASM,
* applying wasm-opt and trampoline as configured.
*/
export async function executeBuildFunctionStep(_step: BuildStep, context: BuildContext): Promise<void> {
return buildFunctionExtension(context.extension, context.options)
}
14 changes: 14 additions & 0 deletions packages/app/src/cli/services/build/steps/build-theme-step.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {runThemeCheck} from '../theme-check.js'
import type {BuildStep, BuildContext} from '../build-steps.js'

/**
* Executes a build_theme build step.
*
* Runs theme check on the extension directory and writes any offenses to stdout.
*/
export async function executeBuildThemeStep(_step: BuildStep, context: BuildContext): Promise<void> {
const {extension, options} = context
options.stdout.write(`Running theme check on your Theme app extension...`)
const offenses = await runThemeCheck(extension.directory)
if (offenses) options.stdout.write(offenses)
}
Loading