From 359e45f5c06974b63cdc4d2032e301b12ebf7ce8 Mon Sep 17 00:00:00 2001 From: Trish Ta Date: Thu, 20 Nov 2025 18:01:41 -0500 Subject: [PATCH] Enable uploading tools schema --- .../extensions/extension-instance.test.ts | 38 +++++++++++++++++- .../models/extensions/extension-instance.ts | 14 ++++++- .../app/src/cli/models/extensions/schemas.ts | 1 + .../cli/models/extensions/specification.ts | 6 +++ .../extensions/specifications/ui_extension.ts | 39 ++++++++++++++++++- .../app-events/app-watcher-esbuild.test.ts | 2 + .../dev/app-events/app-watcher-esbuild.ts | 7 +++- 7 files changed, 102 insertions(+), 5 deletions(-) diff --git a/packages/app/src/cli/models/extensions/extension-instance.test.ts b/packages/app/src/cli/models/extensions/extension-instance.test.ts index 80f20ca5578..2adc27a7d1b 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.test.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.test.ts @@ -14,7 +14,7 @@ import { placeholderAppConfiguration, } from '../app/app.test-data.js' import {FunctionConfigType} from '../extensions/specifications/function.js' -import {ExtensionBuildOptions} from '../../services/build/extension.js' +import {ExtensionBuildOptions, buildUIExtension} from '../../services/build/extension.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {joinPath} from '@shopify/cli-kit/node/path' import {describe, expect, test, vi} from 'vitest' @@ -27,6 +27,15 @@ import {Writable} from 'stream' const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient() vi.mock('@shopify/cli-kit/node/import-extractor') +vi.mock('../../services/build/extension.js', async () => { + const actual = await vi.importActual('../../services/build/extension.js') + return { + ...actual, + buildUIExtension: vi.fn(), + buildThemeExtension: vi.fn(), + buildFunctionExtension: vi.fn(), + } +}) function functionConfiguration(): FunctionConfigType { return { @@ -156,6 +165,33 @@ describe('build', async () => { }) }) + test('calls copyStaticAssets after buildUIExtension when building UI extensions', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionInstance = await testUIExtension({ + type: 'ui_extension', + directory: tmpDir, + }) + + const copyStaticAssetsSpy = vi.spyOn(extensionInstance, 'copyStaticAssets').mockResolvedValue() + vi.mocked(buildUIExtension).mockResolvedValue() + + const options: ExtensionBuildOptions = { + stdout: new Writable({write: vi.fn()}), + stderr: new Writable({write: vi.fn()}), + app: testApp(), + environment: 'production', + } + + // When + await extensionInstance.build(options) + + // Then + expect(buildUIExtension).toHaveBeenCalledWith(extensionInstance, options) + expect(copyStaticAssetsSpy).toHaveBeenCalledOnce() + }) + }) + test('does not copy shopify.extension.toml file when bundling theme extensions', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index bbfbc44cbce..3a37dd9aaf8 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -352,7 +352,9 @@ export class ExtensionInstance{})();') @@ -492,6 +494,16 @@ export class ExtensionInstance normalizePath(file)))] } + /** + * Copy static assets from the extension directory to the output path + * Used by both dev and deploy builds + */ + async copyStaticAssets(outputPath?: string) { + if (this.specification.copyStaticAssets) { + return this.specification.copyStaticAssets(this.configuration, this.directory, outputPath ?? this.outputPath) + } + } + /** * Rescans imports for this extension and updates the cached import paths * Returns true if the imports changed diff --git a/packages/app/src/cli/models/extensions/schemas.ts b/packages/app/src/cli/models/extensions/schemas.ts index 19d20dfb3f0..61e0c12a56b 100644 --- a/packages/app/src/cli/models/extensions/schemas.ts +++ b/packages/app/src/cli/models/extensions/schemas.ts @@ -45,6 +45,7 @@ const NewExtensionPointSchema = zod.object({ target: zod.string(), module: zod.string(), should_render: ShouldRenderSchema.optional(), + tools: zod.string().optional(), metafields: zod.array(MetafieldSchema).optional(), default_placement: zod.string().optional(), urls: zod diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 72d8259ed2a..4e6f9d2845b 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -38,6 +38,7 @@ type UidStrategy = 'single' | 'dynamic' | 'uuid' export enum AssetIdentifier { ShouldRender = 'should_render', Main = 'main', + Tools = 'tools', } export interface Asset { @@ -118,6 +119,11 @@ export interface ExtensionSpecification, typeDefinitionsByFile: Map>, ) => Promise + + /** + * Copy static assets from the extension directory to the output path + */ + copyStaticAssets?: (configuration: TConfiguration, directory: string, outputPath: string) => Promise } /** diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts index 03cddaeca07..637c2cba1ee 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -5,8 +5,8 @@ import {loadLocalesConfig} from '../../../utilities/extensions/locales-configura import {getExtensionPointTargetSurface} from '../../../services/dev/extension/utilities.js' import {ExtensionInstance} from '../extension-instance.js' import {err, ok, Result} from '@shopify/cli-kit/node/result' -import {fileExists} from '@shopify/cli-kit/node/fs' -import {joinPath} from '@shopify/cli-kit/node/path' +import {copyFile, fileExists} from '@shopify/cli-kit/node/fs' +import {joinPath, basename, dirname} from '@shopify/cli-kit/node/path' import {outputContent, outputToken} from '@shopify/cli-kit/node/output' import {zod} from '@shopify/cli-kit/node/schema' @@ -27,6 +27,7 @@ export interface BuildManifest { [key in AssetIdentifier]?: { filepath: string module?: string + static?: boolean } } } @@ -57,6 +58,15 @@ export const UIExtensionSchema = BaseSchema.extend({ }, } : null), + ...(targeting.tools + ? { + [AssetIdentifier.Tools]: { + filepath: `${config.handle}-${AssetIdentifier.Tools}-${basename(targeting.tools)}`, + module: targeting.tools, + static: true, + }, + } + : null), }, } @@ -125,6 +135,11 @@ const uiExtensionSpec = createExtensionSpecification({ return } + // Skip static assets - they are copied after esbuild completes in rebuildContext + if (asset.static && asset.module) { + return + } + assets[identifier] = { identifier: identifier as AssetIdentifier, outputFileName: asset.filepath, @@ -143,6 +158,26 @@ const uiExtensionSpec = createExtensionSpecification({ ...(assetsArray.length ? {assets: assetsArray} : {}), } }, + copyStaticAssets: async (config, directory, outputPath) => { + if (!isRemoteDomExtension(config)) return + + await Promise.all( + config.extension_points.map((extensionPoint) => { + if (!('build_manifest' in extensionPoint)) return Promise.resolve() + + return Object.entries(extensionPoint.build_manifest.assets).map(([_, asset]) => { + if (asset.static && asset.module) { + const sourceFile = joinPath(directory, asset.module) + const outputFilePath = joinPath(dirname(outputPath), asset.filepath) + return copyFile(sourceFile, outputFilePath).catch((error) => { + throw new Error(`Failed to copy static asset ${asset.module} to ${outputFilePath}: ${error.message}`) + }) + } + return Promise.resolve() + }) + }), + ) + }, hasExtensionPointTarget: (config, requestedTarget) => { return ( config.extension_points?.find((extensionPoint) => { diff --git a/packages/app/src/cli/services/dev/app-events/app-watcher-esbuild.test.ts b/packages/app/src/cli/services/dev/app-events/app-watcher-esbuild.test.ts index f07bcf3b4b2..0a1a7256f02 100644 --- a/packages/app/src/cli/services/dev/app-events/app-watcher-esbuild.test.ts +++ b/packages/app/src/cli/services/dev/app-events/app-watcher-esbuild.test.ts @@ -154,6 +154,7 @@ describe('app-watcher-esbuild', () => { const manager = new ESBuildContextManager(options) await manager.createContexts([extension1]) const spyContext = vi.spyOn(manager.contexts.uid1![0]!, 'rebuild').mockResolvedValue({} as any) + const spyCopyStaticAssets = vi.spyOn(extension1, 'copyStaticAssets').mockResolvedValue() const spyCopy = vi.spyOn(fs, 'copyFile').mockResolvedValue() // When @@ -161,6 +162,7 @@ describe('app-watcher-esbuild', () => { // Then expect(spyContext).toHaveBeenCalled() + expect(spyCopyStaticAssets).toHaveBeenCalledWith('/path/to/output/uid1/dist/test-ui-extension.js') expect(spyCopy).toHaveBeenCalledWith('/path/to/output/uid1/dist', '/extensions/ui_extension_1/dist') }) diff --git a/packages/app/src/cli/services/dev/app-events/app-watcher-esbuild.ts b/packages/app/src/cli/services/dev/app-events/app-watcher-esbuild.ts index 28696ae6ede..edf39052082 100644 --- a/packages/app/src/cli/services/dev/app-events/app-watcher-esbuild.ts +++ b/packages/app/src/cli/services/dev/app-events/app-watcher-esbuild.ts @@ -79,6 +79,11 @@ export class ESBuildContextManager { const context = this.contexts[extension.uid] if (!context) return await Promise.all(context.map((ctxt) => ctxt.rebuild())) + const devBundleOutputPath = extension.getOutputPathForDirectory(this.outputPath) + + // Copy static assets after build completes + // Pass in an explicit output path because the extension.outputPath is not the same as the dev bundle output path. + await extension.copyStaticAssets(devBundleOutputPath) // The default output path for a extension is now inside `.shopify/bundle//dist`, // all extensions output need to be under the same directory so that it can all be zipped together. @@ -86,7 +91,7 @@ export class ESBuildContextManager { // But historically the output was inside each extension's directory. // To avoid breaking flows that depend on this, we copy the output to the old location. // This also makes it easier to access sourcemaps or other built artifacts. - const outputPath = dirname(extension.getOutputPathForDirectory(this.outputPath)) + const outputPath = dirname(devBundleOutputPath) const copyPath = dirname(extension.outputPath) await copyFile(outputPath, copyPath) }