From 6ca4e2805927cd5dd41d9cb1cdc80402bcc61942 Mon Sep 17 00:00:00 2001 From: Trish Ta Date: Fri, 16 Jan 2026 13:34:15 -0500 Subject: [PATCH] Expose tools and instructions in the Dev Server payload --- .../cli/models/extensions/specification.ts | 6 + .../specifications/ui_extension.test.ts | 9 + .../extensions/specifications/ui_extension.ts | 67 +++-- .../services/dev/extension/payload.test.ts | 244 ++++++++++-------- .../src/cli/services/dev/extension/payload.ts | 89 +++++-- .../services/dev/extension/payload/models.ts | 2 +- 6 files changed, 262 insertions(+), 155 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index b0eab79677d..d40a17dd115 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -48,6 +48,12 @@ export interface Asset { content: string } +export interface BuildAsset { + filepath: string + module: string + static?: boolean +} + type BuildConfig = | {mode: 'ui' | 'theme' | 'function' | 'tax_calculation' | 'none'} | {mode: 'copy_files'; filePatterns: string[]; ignoredFilePatterns?: string[]} diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts index 4494db13efd..f74aa72e8c7 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts @@ -207,6 +207,7 @@ describe('ui_extension', async () => { { target: 'EXTENSION::POINT::A', tools: undefined, + instructions: undefined, module: './src/ExtensionPointA.js', metafields: [{namespace: 'test', key: 'test'}], default_placement_reference: undefined, @@ -273,6 +274,7 @@ describe('ui_extension', async () => { { target: 'EXTENSION::POINT::A', tools: undefined, + instructions: undefined, module: './src/ExtensionPointA.js', metafields: [], default_placement_reference: 'PLACEMENT_REFERENCE1', @@ -335,6 +337,7 @@ describe('ui_extension', async () => { { target: 'EXTENSION::POINT::A', tools: undefined, + instructions: undefined, module: './src/ExtensionPointA.js', metafields: [], urls: {}, @@ -397,6 +400,7 @@ describe('ui_extension', async () => { { target: 'EXTENSION::POINT::A', tools: undefined, + instructions: undefined, module: './src/ExtensionPointA.js', metafields: [], default_placement_reference: undefined, @@ -462,6 +466,7 @@ describe('ui_extension', async () => { { target: 'EXTENSION::POINT::A', tools: undefined, + instructions: undefined, module: './src/ExtensionPointA.js', metafields: [], default_placement_reference: undefined, @@ -529,6 +534,7 @@ describe('ui_extension', async () => { { target: 'EXTENSION::POINT::A', tools: undefined, + instructions: undefined, module: './src/ExtensionPointA.js', metafields: [], default_placement_reference: undefined, @@ -596,6 +602,7 @@ describe('ui_extension', async () => { target: 'EXTENSION::POINT::A', module: './src/ExtensionPointA.js', tools: './tools.json', + instructions: undefined, metafields: [], default_placement_reference: undefined, capabilities: undefined, @@ -663,6 +670,7 @@ describe('ui_extension', async () => { target: 'EXTENSION::POINT::A', module: './src/ExtensionPointA.js', tools: undefined, + instructions: './instructions.md', metafields: [], default_placement_reference: undefined, capabilities: undefined, @@ -890,6 +898,7 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` target: 'EXTENSION::POINT::A', module: './src/ExtensionPointA.js', tools: './tools.json', + instructions: './instructions.md', metafields: [], default_placement_reference: undefined, capabilities: undefined, 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 0aea517ccf7..f1acb096c8f 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -6,7 +6,7 @@ import { createToolsTypeDefinition, ToolsFileSchema, } from './type-generation.js' -import {Asset, AssetIdentifier, ExtensionFeature, createExtensionSpecification} from '../specification.js' +import {Asset, AssetIdentifier, BuildAsset, ExtensionFeature, createExtensionSpecification} from '../specification.js' import {NewExtensionPointSchemaType, NewExtensionPointsSchema, BaseSchema, MetafieldSchema} from '../schemas.js' import {loadLocalesConfig} from '../../../utilities/extensions/locales-configuration.js' import {getExtensionPointTargetSurface} from '../../../services/dev/extension/utilities.js' @@ -27,16 +27,10 @@ const validatePoints = (config: {extension_points?: unknown[]; targeting?: unkno export interface BuildManifest { assets: { // Main asset is always required - [AssetIdentifier.Main]: { - filepath: string - module?: string - } - } & { - [key in AssetIdentifier]?: { - filepath: string - module?: string - static?: boolean - } + [AssetIdentifier.Main]: BuildAsset + [AssetIdentifier.ShouldRender]?: BuildAsset + [AssetIdentifier.Tools]?: BuildAsset + [AssetIdentifier.Instructions]?: BuildAsset } } @@ -88,7 +82,6 @@ export const UIExtensionSchema = BaseSchema.extend({ } return { - tools: targeting.tools, target: targeting.target, module: targeting.module, metafields: targeting.metafields ?? config.metafields ?? [], @@ -97,6 +90,8 @@ export const UIExtensionSchema = BaseSchema.extend({ capabilities: targeting.capabilities, preloads: targeting.preloads ?? {}, build_manifest: buildManifest, + tools: targeting.tools, + instructions: targeting.instructions, } }) return {...config, extension_points: extensionPoints} @@ -147,27 +142,10 @@ const uiExtensionSpec = createExtensionSpecification({ const assets: {[key: string]: Asset} = {} extensionPoints.forEach((extensionPoint) => { - // Start of Selection - Object.entries(extensionPoint.build_manifest.assets).forEach(([identifier, asset]) => { - if (identifier === AssetIdentifier.Main) { - 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, - content: shouldIncludeShopifyExtend - ? `import shouldRender from '${asset.module}';shopify.extend('${getShouldRenderTarget( - extensionPoint.target, - )}', (...args) => shouldRender(...args));` - : `import '${asset.module}'`, - } - }) + const shouldRenderAsset = buildShouldRenderAsset(extensionPoint, shouldIncludeShopifyExtend) + if (shouldRenderAsset) { + assets[AssetIdentifier.ShouldRender] = shouldRenderAsset + } }) const assetsArray = Object.values(assets) @@ -180,8 +158,8 @@ const uiExtensionSpec = createExtensionSpecification({ if (!isRemoteDomExtension(config)) return await Promise.all( - config.extension_points.map((extensionPoint) => { - if (!('build_manifest' in extensionPoint)) return Promise.resolve() + config.extension_points.flatMap((extensionPoint) => { + if (!('build_manifest' in extensionPoint)) return [] return Object.entries(extensionPoint.build_manifest.assets).map(([_, asset]) => { if (asset.static && asset.module) { @@ -458,4 +436,23 @@ export function getShouldRenderTarget(target: string) { return target.replace(/\.render$/, '.should-render') } +function buildShouldRenderAsset( + extensionPoint: NewExtensionPointSchemaType & {build_manifest: BuildManifest}, + shouldIncludeShopifyExtend: boolean, +) { + const shouldRenderAsset = extensionPoint.build_manifest.assets[AssetIdentifier.ShouldRender] + if (!shouldRenderAsset) { + return + } + return { + identifier: AssetIdentifier.ShouldRender, + outputFileName: shouldRenderAsset.filepath, + content: shouldIncludeShopifyExtend + ? `import shouldRender from '${shouldRenderAsset.module}';shopify.extend('${getShouldRenderTarget( + extensionPoint.target, + )}', (...args) => shouldRender(...args));` + : `import '${shouldRenderAsset.module}'`, + } +} + export default uiExtensionSpec diff --git a/packages/app/src/cli/services/dev/extension/payload.test.ts b/packages/app/src/cli/services/dev/extension/payload.test.ts index 44c649fc593..a01fce3af9f 100644 --- a/packages/app/src/cli/services/dev/extension/payload.test.ts +++ b/packages/app/src/cli/services/dev/extension/payload.test.ts @@ -3,23 +3,45 @@ import {getUIExtensionPayload} from './payload.js' import {ExtensionsPayloadStoreOptions} from './payload/store.js' import {testUIExtension} from '../../../models/app/app.test-data.js' import * as appModel from '../../../models/app/app.js' -import {describe, expect, test, vi} from 'vitest' -import {inTemporaryDirectory, touchFile} from '@shopify/cli-kit/node/fs' +import {describe, expect, test, vi, beforeEach} from 'vitest' +import {inTemporaryDirectory, touchFile, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' describe('getUIExtensionPayload', () => { + beforeEach(() => { + vi.spyOn(appModel, 'getUIExtensionRendererVersion').mockResolvedValue({ + name: 'extension-renderer', + version: '1.2.3', + }) + }) + + function createMockOptions(tmpDir: string, extensions: any[]): Omit { + return { + signal: vi.fn() as any, + stdout: vi.fn() as any, + stderr: vi.fn() as any, + apiKey: 'api-key', + appName: 'foobar', + appDirectory: '/tmp', + extensions, + grantedScopes: ['scope-a'], + port: 123, + url: 'http://tunnel-url.com', + storeFqdn: 'my-domain.com', + storeId: '123456789', + buildDirectory: tmpDir, + checkoutCartUrl: 'https://my-domain.com/cart', + subscriptionProductUrl: 'https://my-domain.com/subscription', + manifestVersion: '3', + websocketURL: 'wss://mock.url/extensions', + } + } + test('returns the right payload', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given const outputPath = joinPath(tmpDir, 'test-ui-extension.js') await touchFile(outputPath) - const signal: any = vi.fn() - const stdout: any = vi.fn() - const stderr: any = vi.fn() - vi.spyOn(appModel, 'getUIExtensionRendererVersion').mockResolvedValue({ - name: 'extension-renderer', - version: '1.2.3', - }) const uiExtension = await testUIExtension({ outputPath, @@ -45,34 +67,10 @@ describe('getUIExtensionPayload', () => { devUUID: 'devUUID', }) - const options: Omit = { - signal, - stdout, - stderr, - apiKey: 'api-key', - appName: 'foobar', - appDirectory: '/tmp', - extensions: [uiExtension], - grantedScopes: ['scope-a'], - port: 123, - url: 'http://tunnel-url.com', - storeFqdn: 'my-domain.com', - storeId: '123456789', - buildDirectory: tmpDir, - checkoutCartUrl: 'https://my-domain.com/cart', - subscriptionProductUrl: 'https://my-domain.com/subscription', - manifestVersion: '3', - websocketURL: 'wss://mock.url/extensions', - } - const development: Partial = { - hidden: true, - status: 'success', - } - // When const got = await getUIExtensionPayload(uiExtension, 'mock-bundle-path', { - ...options, - currentDevelopmentPayload: development, + ...createMockOptions(tmpDir, [uiExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, }) // Then @@ -126,13 +124,6 @@ describe('getUIExtensionPayload', () => { // Given const outputPath = joinPath(tmpDir, 'test-ui-extension.js') await touchFile(outputPath) - const signal: any = vi.fn() - const stdout: any = vi.fn() - const stderr: any = vi.fn() - vi.spyOn(appModel, 'getUIExtensionRendererVersion').mockResolvedValue({ - name: 'extension-renderer', - version: '1.2.3', - }) const buildManifest = { assets: { @@ -174,34 +165,10 @@ describe('getUIExtensionPayload', () => { devUUID: 'devUUID', }) - const options: Omit = { - signal, - stdout, - stderr, - apiKey: 'api-key', - appName: 'foobar', - appDirectory: '/tmp', - extensions: [uiExtension], - grantedScopes: ['scope-a'], - port: 123, - url: 'http://tunnel-url.com', - storeFqdn: 'my-domain.com', - storeId: '123456789', - buildDirectory: tmpDir, - checkoutCartUrl: 'https://my-domain.com/cart', - subscriptionProductUrl: 'https://my-domain.com/subscription', - manifestVersion: '3', - websocketURL: 'wss://mock.url/extensions', - } - const development: Partial = { - hidden: true, - status: 'success', - } - // When const got = await getUIExtensionPayload(uiExtension, 'mock-bundle-path', { - ...options, - currentDevelopmentPayload: development, + ...createMockOptions(tmpDir, [uiExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, }) // Then @@ -267,18 +234,115 @@ describe('getUIExtensionPayload', () => { }) }) + test('returns the right payload for UI Extensions with tools in build_manifest', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const outputPath = joinPath(tmpDir, 'test-ui-extension.js') + await touchFile(outputPath) + await writeFile(joinPath(tmpDir, 'tools.json'), '{"tools": []}') + + const buildManifest = { + assets: { + main: {module: './src/ExtensionPointA.js', filepath: '/test-ui-extension.js'}, + tools: {module: './tools.json', filepath: '/test-ui-extension-tools.json', static: true}, + }, + } + + const uiExtension = await testUIExtension({ + outputPath, + directory: tmpDir, + configuration: { + name: 'test-ui-extension', + type: 'ui_extension', + extension_points: [{target: 'CUSTOM_EXTENSION_POINT', build_manifest: buildManifest}], + }, + devUUID: 'devUUID', + }) + + // When + const got = await getUIExtensionPayload(uiExtension, 'mock-bundle-path', { + ...createMockOptions(tmpDir, [uiExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, + }) + + // Then + expect(got.extensionPoints).toMatchObject([ + { + target: 'CUSTOM_EXTENSION_POINT', + assets: { + main: { + name: 'main', + url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension.js', + lastUpdated: expect.any(Number), + }, + tools: { + name: 'tools', + url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension-tools.json', + lastUpdated: expect.any(Number), + }, + }, + }, + ]) + }) + }) + + test('returns the right payload for UI Extensions with instructions in build_manifest', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const outputPath = joinPath(tmpDir, 'test-ui-extension.js') + await touchFile(outputPath) + await writeFile(joinPath(tmpDir, 'instructions.md'), '# Instructions') + + const buildManifest = { + assets: { + main: {module: './src/ExtensionPointA.js', filepath: '/test-ui-extension.js'}, + instructions: {module: './instructions.md', filepath: '/test-ui-extension-instructions.md', static: true}, + }, + } + + const uiExtension = await testUIExtension({ + outputPath, + directory: tmpDir, + configuration: { + name: 'test-ui-extension', + type: 'ui_extension', + extension_points: [{target: 'CUSTOM_EXTENSION_POINT', build_manifest: buildManifest}], + }, + devUUID: 'devUUID', + }) + + // When + const got = await getUIExtensionPayload(uiExtension, 'mock-bundle-path', { + ...createMockOptions(tmpDir, [uiExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, + }) + + // Then + expect(got.extensionPoints).toMatchObject([ + { + target: 'CUSTOM_EXTENSION_POINT', + assets: { + main: { + name: 'main', + url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension.js', + lastUpdated: expect.any(Number), + }, + instructions: { + name: 'instructions', + url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension-instructions.md', + lastUpdated: expect.any(Number), + }, + }, + }, + ]) + }) + }) + test('returns the right payload for post-purchase extensions', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given const outputPath = joinPath(tmpDir, 'test-post-purchase-extension.js') await touchFile(outputPath) - const signal: any = vi.fn() - const stdout: any = vi.fn() - const stderr: any = vi.fn() - vi.spyOn(appModel, 'getUIExtensionRendererVersion').mockResolvedValue({ - name: 'extension-renderer', - version: '1.2.3', - }) const postPurchaseExtension = await testUIExtension({ outputPath, @@ -308,34 +372,10 @@ describe('getUIExtensionPayload', () => { devUUID: 'devUUID', }) - const options: Omit = { - signal, - stdout, - stderr, - apiKey: 'api-key', - appName: 'foobar', - appDirectory: '/tmp', - extensions: [postPurchaseExtension], - grantedScopes: ['scope-a'], - port: 123, - url: 'http://tunnel-url.com', - storeFqdn: 'my-domain.com', - storeId: '123456789', - buildDirectory: tmpDir, - checkoutCartUrl: 'https://my-domain.com/cart', - subscriptionProductUrl: 'https://my-domain.com/subscription', - manifestVersion: '3', - websocketURL: 'wss://mock.url/extensions', - } - const development: Partial = { - hidden: true, - status: 'success', - } - // When const got = await getUIExtensionPayload(postPurchaseExtension, 'mock-bundle-path', { - ...options, - currentDevelopmentPayload: development, + ...createMockOptions(tmpDir, [postPurchaseExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, }) // Then @@ -422,7 +462,7 @@ describe('getUIExtensionPayload', () => { }) test('adds root.url, resource.url and surface to extensionPoints[n] when extensionPoints[n] is an object', async () => { - await inTemporaryDirectory(async (tmpDir) => { + await inTemporaryDirectory(async (_tmpDir) => { // Given const uiExtension = await testUIExtension({ devUUID: 'devUUID', diff --git a/packages/app/src/cli/services/dev/extension/payload.ts b/packages/app/src/cli/services/dev/extension/payload.ts index 57f9ae3433f..b33279a7cbd 100644 --- a/packages/app/src/cli/services/dev/extension/payload.ts +++ b/packages/app/src/cli/services/dev/extension/payload.ts @@ -1,11 +1,13 @@ import {getLocalization} from './localization.js' -import {Asset, DevNewExtensionPointSchema, UIExtensionPayload} from './payload/models.js' +import {DevNewExtensionPointSchema, UIExtensionPayload} from './payload/models.js' import {getExtensionPointTargetSurface} from './utilities.js' import {ExtensionsPayloadStoreOptions} from './payload/store.js' import {getUIExtensionResourceURL} from '../../../utilities/extensions/configuration.js' import {getUIExtensionRendererVersion} from '../../../models/app/app.js' import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' import {BuildManifest} from '../../../models/extensions/specifications/ui_extension.js' +import {BuildAsset} from '../../../models/extensions/specification.js' +import {NewExtensionPointSchemaType} from '../../../models/extensions/schemas.js' import {fileLastUpdatedTimestamp} from '@shopify/cli-kit/node/fs' import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components' import {dirname, joinPath} from '@shopify/cli-kit/node/path' @@ -15,6 +17,13 @@ export type GetUIExtensionPayloadOptions = Omit { const {target, resource} = extensionPoint - return { + const payload = { ...extensionPoint, - ...(extensionPoint.build_manifest - ? {assets: await extractAssetsFromBuildManifest(extensionPoint.build_manifest, url, extension)} - : {}), surface: getExtensionPointTargetSurface(target), root: { url: `${url}/${target}`, }, resource: resource || {url: ''}, } + + if (!('build_manifest' in extensionPoint)) { + return payload + } + + return { + ...payload, + ...(await mapBuildManifestToPayload( + extensionPoint.build_manifest, + extensionPoint as NewExtensionPointSchemaType & {build_manifest: BuildManifest}, + url, + extension, + )), + } }), ) } @@ -123,20 +143,47 @@ async function getExtensionPoints(extension: ExtensionInstance, url: string) { return extensionPoints } -async function extractAssetsFromBuildManifest(buildManifest: BuildManifest, url: string, extension: ExtensionInstance) { - if (!buildManifest?.assets) return {} - const assets: {[key: string]: Asset} = {} - - for (const [name, asset] of Object.entries(buildManifest.assets)) { - assets[name] = { - name, - url: `${url}${joinPath('/assets/', asset.filepath)}`, - // eslint-disable-next-line no-await-in-loop - lastUpdated: (await fileLastUpdatedTimestamp(joinPath(dirname(extension.outputPath), asset.filepath))) ?? 0, - } +/** + * Default asset mapper - adds asset to the assets object + */ +async function defaultAssetMapper({ + identifier, + asset, + url, + extension, +}: AssetMapperContext): Promise> { + const payload = await getAssetPayload(identifier, asset, url, extension) + return { + assets: {[payload.name]: payload}, } +} + +/** + * Maps build manifest assets to payload format + * Each mapper returns a partial that gets merged into the extension point + */ +async function mapBuildManifestToPayload( + buildManifest: BuildManifest, + _extensionPoint: NewExtensionPointSchemaType & {build_manifest: BuildManifest}, + url: string, + extension: ExtensionInstance, +): Promise> { + if (!buildManifest?.assets) return {} + + const mappingResults = await Promise.all( + Object.entries(buildManifest.assets).map(async ([identifier, asset]) => { + return defaultAssetMapper({identifier, asset, url, extension}) + }), + ) - return assets + return mappingResults.reduce>( + (acc, result) => ({ + ...acc, + ...result, + assets: {...acc.assets, ...result.assets}, + }), + {}, + ) } export function isNewExtensionPointsSchema(extensionPoints: unknown): extensionPoints is DevNewExtensionPointSchema[] { @@ -145,3 +192,11 @@ export function isNewExtensionPointsSchema(extensionPoints: unknown): extensionP extensionPoints.every((extensionPoint: unknown) => typeof extensionPoint === 'object') ) } + +async function getAssetPayload(name: string, asset: BuildAsset, url: string, extension: ExtensionInstance) { + return { + name, + url: `${url}${joinPath('/assets/', asset.filepath)}`, + lastUpdated: (await fileLastUpdatedTimestamp(joinPath(dirname(extension.outputPath), asset.filepath))) ?? 0, + } +} diff --git a/packages/app/src/cli/services/dev/extension/payload/models.ts b/packages/app/src/cli/services/dev/extension/payload/models.ts index dbf0ba53169..e132629e6fc 100644 --- a/packages/app/src/cli/services/dev/extension/payload/models.ts +++ b/packages/app/src/cli/services/dev/extension/payload/models.ts @@ -26,7 +26,7 @@ export interface ExtensionsEndpointPayload extends ExtensionsPayloadInterface { url: string } } -export interface Asset { +interface Asset { name: string url: string lastUpdated: number