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
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,8 @@ 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',
}
Expand Down
58 changes: 22 additions & 36 deletions packages/app/src/cli/models/extensions/extension-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,9 @@ 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 {HostedAppHomeSpecIdentifier} from './specifications/app_config_hosted_app_home.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 {BuildContext, executeStep} from '../../services/build/build-steps.js'
import {Identifiers} from '../app/identifiers.js'
import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js'
import {AppConfigurationWithoutPath} from '../app/app.js'
Expand All @@ -31,7 +26,7 @@ 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'
Expand Down Expand Up @@ -347,34 +342,25 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
}

async build(options: ExtensionBuildOptions): Promise<void> {
const mode = this.specification.buildConfig.mode
const {buildConfig} = this.specification

const context: BuildContext = {
extension: this,
options,
stepResults: new Map(),
signal: options.signal,
}

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 'hosted_app_home':
await this.copyStaticAssets()
break
case 'none':
break
const {steps = [], stopOnError = true} = buildConfig

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 && stopOnError && !step.continueOnError) {
throw new Error(`Build step "${step.displayName}" failed: ${result.error?.message}`)
}
}
}

Expand Down
9 changes: 6 additions & 3 deletions packages/app/src/cli/models/extensions/specification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js'
import {ExtensionInstance} from './extension-instance.js'
import {blocks} from '../../constants.js'
import {BuildStep} from '../../services/build/build-steps.js'

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

type BuildConfig =
| {mode: 'ui' | 'theme' | 'function' | 'tax_calculation' | 'none' | 'hosted_app_home'}
| {mode: 'copy_files'; filePatterns: string[]; ignoredFilePatterns?: string[]}
interface BuildConfig {
mode: 'none' | 'ui' | 'theme' | 'function' | 'tax_calculation' | 'copy_files'
steps?: ReadonlyArray<BuildStep>
stopOnError?: boolean
}
Comment on lines +58 to +62
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why keep mode and steps? the mode doesn't do anything anymore with this implementation right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mode is still used in two other places of this file, so i keep it here untill the other to usages get sorted out and replaced

/**
* Extension specification with all the needed properties and methods to load an extension.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import spec from './app_config_hosted_app_home.js'
import {placeholderAppConfiguration} from '../../app/app.test-data.js'
import {copyDirectoryContents} from '@shopify/cli-kit/node/fs'
import {describe, expect, test, vi} from 'vitest'

vi.mock('@shopify/cli-kit/node/fs')
import {describe, expect, test} from 'vitest'

describe('hosted_app_home', () => {
describe('transform', () => {
Expand Down Expand Up @@ -54,43 +51,43 @@ describe('hosted_app_home', () => {
})
})

describe('copyStaticAssets', () => {
test('should copy static assets from source to output directory', async () => {
vi.mocked(copyDirectoryContents).mockResolvedValue(undefined)
const config = {static_root: 'public'}
const directory = '/app/root'
const outputPath = '/output/dist/bundle.js'

await spec.copyStaticAssets!(config, directory, outputPath)

expect(copyDirectoryContents).toHaveBeenCalledWith('/app/root/public', '/output/dist')
describe('buildConfig', () => {
test('should use copy_files mode', () => {
expect(spec.buildConfig.mode).toBe('copy_files')
})

test('should not copy assets when static_root is not provided', async () => {
const config = {}
const directory = '/app/root'
const outputPath = '/output/dist/bundle.js'

await spec.copyStaticAssets!(config, directory, outputPath)
test('should have copy-static-assets step with tomlKey entry', () => {
if (spec.buildConfig.mode === 'none') {
throw new Error('Expected build_steps mode')
}

expect(copyDirectoryContents).not.toHaveBeenCalled()
expect(spec.buildConfig.steps).toHaveLength(1)
expect(spec.buildConfig.steps![0]).toMatchObject({
id: 'copy-static-assets',
displayName: 'Copy Static Assets',
type: 'copy_files',
config: {
strategy: 'files',
definition: {files: [{tomlKey: 'static_root'}]},
},
})
expect(spec.buildConfig.stopOnError).toBe(true)
})

test('should throw error when copy fails', async () => {
vi.mocked(copyDirectoryContents).mockRejectedValue(new Error('Permission denied'))
const config = {static_root: 'public'}
const directory = '/app/root'
const outputPath = '/output/dist/bundle.js'
test('config should be serializable to JSON', () => {
if (spec.buildConfig.mode === 'none') {
throw new Error('Expected build_steps mode')
}

await expect(spec.copyStaticAssets!(config, directory, outputPath)).rejects.toThrow(
'Failed to copy static assets from /app/root/public to /output/dist: Permission denied',
)
})
})
const serialized = JSON.stringify(spec.buildConfig)
expect(serialized).toBeDefined()

describe('buildConfig', () => {
test('should have hosted_app_home build mode', () => {
expect(spec.buildConfig).toEqual({mode: 'hosted_app_home'})
const deserialized = JSON.parse(serialized)
expect(deserialized.steps).toHaveLength(1)
expect(deserialized.steps[0].config).toEqual({
strategy: 'files',
definition: {files: [{tomlKey: 'static_root'}]},
})
})
})

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import {BaseSchemaWithoutHandle} from '../schemas.js'
import {TransformationConfig, createConfigExtensionSpecification} from '../specification.js'
import {copyDirectoryContents} from '@shopify/cli-kit/node/fs'
import {dirname, joinPath} from '@shopify/cli-kit/node/path'
import {zod} from '@shopify/cli-kit/node/schema'

const HostedAppHomeSchema = BaseSchemaWithoutHandle.extend({
Expand All @@ -16,18 +14,25 @@ export const HostedAppHomeSpecIdentifier = 'hosted_app_home'

const hostedAppHomeSpec = createConfigExtensionSpecification({
identifier: HostedAppHomeSpecIdentifier,
buildConfig: {mode: 'hosted_app_home'} as const,
buildConfig: {
mode: 'copy_files',
steps: [
{
id: 'copy-static-assets',
displayName: 'Copy Static Assets',
type: 'copy_files',
config: {
strategy: 'files',
definition: {
files: [{tomlKey: 'static_root'}],
},
},
},
],
stopOnError: true,
},
schema: HostedAppHomeSchema,
transformConfig: HostedAppHomeTransformConfig,
copyStaticAssets: async (config, directory, outputPath) => {
if (!config.static_root) return
const sourceDir = joinPath(directory, config.static_root)
const outputDir = dirname(outputPath)

return copyDirectoryContents(sourceDir, outputDir).catch((error) => {
throw new Error(`Failed to copy static assets from ${sourceDir} to ${outputDir}: ${error.message}`)
})
},
})

export default hostedAppHomeSpec
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
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('buildConfig', () => {
test('uses build_steps mode', () => {
expect(spec.buildConfig.mode).toBe('copy_files')
})

test('has a single copy-files step scoped to the specifications subdirectory', () => {
if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode')

expect(spec.buildConfig.steps).toHaveLength(1)
expect(spec.buildConfig.steps![0]).toMatchObject({
id: 'copy-files',
type: 'copy_files',
config: {
strategy: 'pattern',
definition: {source: '.'},
},
})

const {patterns} = (spec.buildConfig.steps![0]!.config as {definition: {patterns: string[]}}).definition

expect(patterns).toEqual(
expect.arrayContaining([
`${SUBDIRECTORY}/**/*.json`,
`${SUBDIRECTORY}/**/*.toml`,
`${SUBDIRECTORY}/**/*.yaml`,
`${SUBDIRECTORY}/**/*.yml`,
`${SUBDIRECTORY}/**/*.svg`,
]),
)
})

test('config is serializable to JSON', () => {
if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode')

const serialized = JSON.stringify(spec.buildConfig)
const deserialized = JSON.parse(serialized)

expect(deserialized.steps).toHaveLength(1)
expect(deserialized.steps[0].config.strategy).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)
})
})
})
})
Loading
Loading