diff --git a/packages/app/src/cli/models/app/app.test-data.ts b/packages/app/src/cli/models/app/app.test-data.ts index d44964c7a40..5a712c047eb 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -1048,6 +1048,23 @@ const testRemoteSpecifications: RemoteSpecification[] = [ '{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","additionalProperties":false,"properties":{"pattern":{"type":"string"},"name":{"type":"string"}},"required":["pattern"]}', }, }, + { + name: 'Admin Link', + externalName: 'Admin Link', + identifier: 'admin_link', + externalIdentifier: 'admin_link_external', + gated: false, + experience: 'extension', + options: { + managementExperience: 'cli', + registrationLimit: 10, + uidIsClientProvided: true, + }, + validationSchema: { + jsonSchema: + '{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","additionalProperties":true,"properties":{"name":{"type":"string"},"type":{"type":"string"},"handle":{"type":"string"},"targeting":{"type":"array","items":{"type":"object","properties":{"target":{"type":"string"},"tools":{"type":"string"},"instructions":{"type":"string"},"intents":{"type":"array"},"build_manifest":{"type":"object"}},"additionalProperties":true}}},"required":["name","targeting"]}', + }, + }, ] const productSubscriptionUIExtensionTemplate: ExtensionTemplate = { 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 2adc27a7d1b..d8d1ddce373 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.test.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.test.ts @@ -192,6 +192,77 @@ describe('build', async () => { }) }) + test('copies intent schema files when building UI extensions with intents', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const intentsDir = joinPath(tmpDir, 'intents') + await mkdir(intentsDir) + await writeFile(joinPath(intentsDir, 'create-schema.json'), '{"type": "create"}') + await writeFile(joinPath(intentsDir, 'update-schema.json'), '{"type": "update"}') + + const distDir = joinPath(tmpDir, 'dist') + await mkdir(distDir) + + const extensionInstance = await testUIExtension({ + type: 'ui_extension', + directory: tmpDir, + configuration: { + name: 'test-ui-extension', + type: 'ui_extension', + api_version: '2025-10', + metafields: [], + capabilities: { + network_access: false, + api_access: false, + block_progress: false, + }, + extension_points: [ + { + target: 'EXTENSION::POINT::A', + module: './src/ExtensionPointA.js', + build_manifest: { + assets: { + main: { + filepath: 'test-ui-extension.js', + module: './src/ExtensionPointA.js', + }, + intents: [ + { + filepath: 'test-ui-extension-intent-create-app_intent-create-schema.json', + module: './intents/create-schema.json', + static: true, + }, + { + filepath: 'test-ui-extension-intent-update-app_intent-update-schema.json', + module: './intents/update-schema.json', + static: true, + }, + ], + }, + }, + }, + ], + }, + outputPath: joinPath(distDir, 'test-ui-extension.js'), + }) + + // When + await extensionInstance.copyStaticAssets() + + // Then + const createSchemaOutput = joinPath(distDir, 'test-ui-extension-intent-create-app_intent-create-schema.json') + const updateSchemaOutput = joinPath(distDir, 'test-ui-extension-intent-update-app_intent-update-schema.json') + + expect(fileExistsSync(createSchemaOutput)).toBe(true) + expect(fileExistsSync(updateSchemaOutput)).toBe(true) + + const createContent = await readFile(createSchemaOutput) + const updateContent = await readFile(updateSchemaOutput) + expect(createContent).toBe('{"type": "create"}') + expect(updateContent).toBe('{"type": "update"}') + }) + }) + 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 3a37dd9aaf8..58be0a77c7b 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -367,7 +367,8 @@ export class ExtensionInstance + +const NewExtensionPointSchema = StaticExtensionPointSchema.extend({ + module: zod.string(), + should_render: ShouldRenderSchema.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 d40a17dd115..3808f368586 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js' import {ExtensionInstance} from './extension-instance.js' +import {adminLinkOverride} from './specifications/remote-overrides/admin_link.js' import {blocks} from '../../constants.js' import {Flag} from '../../utilities/developer-platform-client.js' @@ -40,6 +41,7 @@ export enum AssetIdentifier { Main = 'main', Tools = 'tools', Instructions = 'instructions', + Intents = 'intents', } export interface Asset { @@ -268,21 +270,23 @@ export function createConfigExtensionSpecification( - spec: Pick, 'identifier' | 'appModuleFeatures' | 'buildConfig'>, + spec: Pick, 'identifier' | 'appModuleFeatures'> & + Partial, 'identifier' | 'appModuleFeatures' | 'schema'>>, ) { + const defaultDeployConfig = async (config: TConfiguration, directory: string) => { + let parsedConfig = configWithoutFirstClassFields(config) + if (spec.appModuleFeatures().includes('localization')) { + const localization = await loadLocalesConfig(directory, spec.identifier) + parsedConfig = {...parsedConfig, localization} + } + return parsedConfig + } + return createExtensionSpecification({ - identifier: spec.identifier, + ...spec, schema: zod.any({}) as unknown as ZodSchemaType, - appModuleFeatures: spec.appModuleFeatures, buildConfig: spec.buildConfig ?? {mode: 'none'}, - deployConfig: async (config, directory) => { - let parsedConfig = configWithoutFirstClassFields(config) - if (spec.appModuleFeatures().includes('localization')) { - const localization = await loadLocalesConfig(directory, spec.identifier) - parsedConfig = {...parsedConfig, localization} - } - return parsedConfig - }, + deployConfig: spec.deployConfig ?? defaultDeployConfig, }) } @@ -400,3 +404,37 @@ export function configWithoutFirstClassFields(config: JsonMapType): JsonMapType const {type, handle, uid, path, extensions, ...configWithoutFirstClassFields} = config return configWithoutFirstClassFields } + +/** + * Specification overrides for remote specifications that need custom behavior. + * These overrides are applied when creating contract-based module specifications + * for extensions that are defined remotely but need local customization. + * + * Can include any method from ExtensionSpecification plus a schema. + * All properties are optional - only provide what you want to override. + */ +export type SpecificationOverride = Partial< + ExtensionSpecification +> & { + schema?: ZodSchemaType +} + +/** + * Registry of specification overrides by identifier. + * Add custom behavior for remote specifications here. + */ +export const SPECIFICATION_OVERRIDES: {[key: string]: SpecificationOverride} = { + admin_link: adminLinkOverride as unknown as SpecificationOverride, +} + +/** + * Get the override configuration for a specific specification identifier. + * + * @param identifier - The specification identifier + * @returns The override configuration if it exists, undefined otherwise + */ +export function getSpecificationOverride( + identifier: string, +): SpecificationOverride | undefined { + return SPECIFICATION_OVERRIDES[identifier] as SpecificationOverride | undefined +} diff --git a/packages/app/src/cli/models/extensions/specifications/build-manifest-schema.ts b/packages/app/src/cli/models/extensions/specifications/build-manifest-schema.ts new file mode 100644 index 00000000000..dc2bc4e3b06 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/build-manifest-schema.ts @@ -0,0 +1,290 @@ +import {AssetIdentifier, BuildAsset} from '../specification.js' +import {fileExists, copyFile, mkdir} from '@shopify/cli-kit/node/fs' +import {joinPath, dirname, basename} from '@shopify/cli-kit/node/path' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {err, ok, Result} from '@shopify/cli-kit/node/result' + +// Re-export ok and err for use in specification overrides +export {ok, err} + +/** + * Minimal interface for targeting configurations with build manifests. + * This allows both UI extensions (with modules) and static-only extensions (without modules) + * to use the same build manifest utilities. + */ +export interface TargetingWithBuildManifest { + target: string + build_manifest?: BuildManifest +} + +/** + * Generic build manifest structure that can contain any combination of assets. + * Different specifications can define their own subset of supported assets. + */ +export interface BuildManifest { + assets: { + [AssetIdentifier.Main]?: BuildAsset + [AssetIdentifier.ShouldRender]?: BuildAsset + [AssetIdentifier.Tools]?: BuildAsset + [AssetIdentifier.Instructions]?: BuildAsset + [AssetIdentifier.Intents]?: BuildAsset[] + [key: string]: BuildAsset | BuildAsset[] | undefined + } +} + +/** + * Configuration for static assets (tools, instructions, intents). + */ +export interface StaticAssetsConfig { + target: string + tools?: string + instructions?: string + intents?: { + type: string + action: string + schema: string + name?: string + description?: string + }[] +} + +/** + * Transform static asset configuration into build manifest format (tools, instructions, intents). + * Returns a partial object with build_manifest and top-level fields that can be merged + * into the extension point configuration. + * + * @param config - Static assets configuration from targeting + * @param handle - Extension handle for generating filenames + * @returns Partial extension point config with build_manifest and static asset fields + */ +export function transformStaticAssets(config: StaticAssetsConfig, handle: string) { + const assets: Partial = {} + + if (config.tools) { + assets[AssetIdentifier.Tools] = { + filepath: `${handle}-${config.target}-${AssetIdentifier.Tools}-${basename(config.tools)}`, + module: config.tools, + static: true, + } + } + + if (config.instructions) { + assets[AssetIdentifier.Instructions] = { + filepath: `${handle}-${config.target}-${AssetIdentifier.Instructions}-${basename(config.instructions)}`, + module: config.instructions, + static: true, + } + } + + if (config.intents) { + assets[AssetIdentifier.Intents] = config.intents.map((intent, index) => ({ + filepath: `${handle}-${config.target}-${AssetIdentifier.Intents}-${index + 1}-${basename(intent.schema)}`, + module: intent.schema, + static: true, + })) + } + + return { + build_manifest: { + assets, + }, + tools: config.tools, + instructions: config.instructions, + ...(config.intents ? {intents: config.intents} : {}), + } +} + +/** + * Copy static assets from the extension directory to the output path. + * Processes all assets in the build manifest that are marked as static. + * + * @param targeting - Array of targeting configurations with build manifests + * @param directory - Source directory containing the assets + * @param outputPath - Destination path for the copied assets + */ +export async function copyStaticBuildManifestAssets( + targeting: TargetingWithBuildManifest[], + directory: string, + outputPath: string, +): Promise { + console.log('copyStaticBuildManifestAssets -- targeting --->', JSON.stringify(targeting, null, 2)) + await Promise.all( + targeting.flatMap((target) => { + if (!('build_manifest' in target) || !target.build_manifest) return [] + + return Object.entries(target.build_manifest.assets) + .filter((entry): entry is [string, BuildAsset | BuildAsset[]] => { + const [_, asset] = entry + return asset !== undefined && isStaticAsset(asset) + }) + .map(([_, asset]) => { + if (Array.isArray(asset)) { + return Promise.all(asset.map((childAsset) => copyAsset(childAsset, directory, outputPath))) + } + + return copyAsset(asset, directory, outputPath) + }) + }), + ) +} + +/** + * Copy a single asset file if it's marked as static. + * + * @param asset - The asset to copy + * @param directory - Source directory + * @param outputPath - Destination path + */ +async function copyAsset( + {module, filepath, static: isStatic}: BuildAsset, + directory: string, + outputPath: string, +): Promise { + if (isStatic) { + const sourceFile = joinPath(directory, module) + const outputFilePath = joinPath(dirname(outputPath), filepath) + + // Ensure the directory exists before copying + await mkdir(dirname(outputFilePath)) + + await copyFile(sourceFile, outputFilePath).catch((error) => { + throw new Error(`Failed to copy static asset ${module} to ${outputFilePath}: ${error.message}`) + }) + } +} + +/** + * Check if a file path exists and return an error message if it doesn't. + * + * @param directory - Base directory + * @param assetModule - Relative path to the asset + * @param target - Extension point target (for error messages) + * @param assetType - Type of asset (for error messages) + * @returns Error message if file doesn't exist, undefined otherwise + */ +async function checkForMissingPath( + directory: string, + assetModule: string | undefined, + target: string, + assetType: string, +): Promise { + if (!assetModule) return undefined + + const assetPath = joinPath(directory, assetModule) + const exists = await fileExists(assetPath) + return exists + ? undefined + : outputContent`Couldn't find ${outputToken.path(assetPath)} + Please check the ${assetType} path for ${target}`.value +} + +/** + * Validate that all asset files referenced in the build manifest exist. + * Validates all assets present in the build manifest. + * + * @param directory - Extension directory + * @param targeting - Array of targeting configurations to validate + * @returns Result indicating success or failure with error messages + */ +export async function validateBuildManifestAssets( + directory: string, + targeting: TargetingWithBuildManifest[], +): Promise> { + const validationPromises = targeting.flatMap((targetConfig) => { + const {target, build_manifest: buildManifest} = targetConfig + + if (!buildManifest) return [] + + // Validate each asset type + return Object.entries(buildManifest.assets).flatMap(([identifier, asset]) => { + if (!asset) return [] + + const mappedAssets = Array.isArray(asset) ? asset : [asset] + return mappedAssets.map((assetItem) => + checkForMissingPath(directory, assetItem.module, target, getAssetDisplayName(identifier as AssetIdentifier)), + ) + }) + }) + + const validationResults = await Promise.all(validationPromises) + const errors = validationResults.filter((error): error is string => error !== undefined) + + if (errors.length) { + return err(errors.join('\n\n')) + } + return ok({}) +} + +/** + * Transform extension points by adding dist path to all assets. + * This is used during deployment to update asset paths. + * + * @param extensionPoint - Extension point with build manifest + * @returns Extension point with updated asset paths + */ +export function addDistPathToAssets( + extP: T, +): T { + return { + ...extP, + build_manifest: { + ...extP.build_manifest, + assets: Object.fromEntries( + Object.entries(extP.build_manifest.assets) + .filter(([_, value]) => value !== undefined) + .map(([key, value]) => { + if (!value) return [key, value] + + return [ + key as AssetIdentifier, + Array.isArray(value) + ? value.map((asset) => ({ + ...asset, + filepath: joinPath('dist', asset.filepath), + })) + : { + ...value, + filepath: joinPath('dist', value.filepath), + }, + ] + }), + ), + }, + } +} + +/** + * Check if an asset is static and should be copied. + * + * @param asset - Asset or array of assets to check + * @returns True if the asset(s) are static + */ +function isStaticAsset(asset: BuildAsset | BuildAsset[]): boolean { + if (Array.isArray(asset)) { + return asset.every((assetItem) => assetItem.static) + } + return asset.static === true +} + +/** + * Get a human-readable display name for an asset identifier. + * + * @param identifier - Asset identifier + * @returns Display name + */ +function getAssetDisplayName(identifier: AssetIdentifier): string { + switch (identifier) { + case AssetIdentifier.Main: + return 'main module' + case AssetIdentifier.ShouldRender: + return 'should render module' + case AssetIdentifier.Tools: + return 'tools' + case AssetIdentifier.Instructions: + return 'instructions' + case AssetIdentifier.Intents: + return 'intent schema' + default: + return identifier + } +} diff --git a/packages/app/src/cli/models/extensions/specifications/remote-overrides/admin_link.ts b/packages/app/src/cli/models/extensions/specifications/remote-overrides/admin_link.ts new file mode 100644 index 00000000000..d1c61acd926 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/remote-overrides/admin_link.ts @@ -0,0 +1,50 @@ +import { + transformStaticAssets, + copyStaticBuildManifestAssets, + validateBuildManifestAssets, + addDistPathToAssets, +} from '../build-manifest-schema.js' +import {BaseSchema, StaticExtensionPointSchemaType, StaticExtensionPointsSchema} from '../../schemas.js' +import {configWithoutFirstClassFields} from '../../specification.js' +import {loadLocalesConfig} from '../../../../utilities/extensions/locales-configuration.js' +import {zod} from '@shopify/cli-kit/node/schema' +import {JsonMapType} from '@shopify/cli-kit/node/toml' + +export const AdminLinkSchema = BaseSchema.extend({ + name: zod.string(), + type: zod.literal('admin_link'), + targeting: StaticExtensionPointsSchema, +}).transform((config) => transformTargeting(config as {handle: string; targeting: StaticExtensionPointSchemaType[]})) + +function transformTargeting(config: {handle: string; targeting: StaticExtensionPointSchemaType[]}) { + const handle = config.handle ?? 'admin-link' + return { + ...config, + targeting: config.targeting.map((targeting) => { + return { + ...targeting, + ...transformStaticAssets(targeting, handle), + } + }), + } +} + +export type AdminLinkConfigType = zod.infer + +export const adminLinkOverride = { + validate: async (config: AdminLinkConfigType, _path: string, directory: string) => + validateBuildManifestAssets(directory, transformTargeting(config).targeting), + copyStaticAssets: async (config: AdminLinkConfigType, directory: string, outputPath: string) => + copyStaticBuildManifestAssets(transformTargeting(config).targeting, directory, outputPath), + deployConfig: async (config: JsonMapType, directory: string) => { + const transformedTargeting = transformTargeting(config as AdminLinkConfigType).targeting.map(addDistPathToAssets) + const parsedConfig = configWithoutFirstClassFields(config) as AdminLinkConfigType + const localization = await loadLocalesConfig(directory, 'admin_link') + + return { + ...parsedConfig, + localization, + targeting: transformedTargeting, + } + }, +} 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 29a8fab8431..c7aa724a75c 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 @@ -614,7 +614,7 @@ describe('ui_extension', async () => { module: './src/ExtensionPointA.js', }, tools: { - filepath: 'test-ui-extension-tools-tools.json', + filepath: 'test-ui-extension-EXTENSION::POINT::A-tools-tools.json', module: './tools.json', static: true, }, @@ -682,7 +682,7 @@ describe('ui_extension', async () => { module: './src/ExtensionPointA.js', }, instructions: { - filepath: 'test-ui-extension-instructions-instructions.md', + filepath: 'test-ui-extension-EXTENSION::POINT::A-instructions-instructions.md', module: './instructions.md', static: true, }, @@ -693,6 +693,264 @@ describe('ui_extension', async () => { ]) }) + test('build_manifest includes intents assets when intents are present', async () => { + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! + const configuration = { + targeting: [ + { + target: 'EXTENSION::POINT::A', + module: './src/ExtensionPointA.js', + intents: [ + { + type: 'app_intent', + action: 'create', + schema: './intents/create-schema.json', + name: 'Create Intent', + description: 'Creates a new item', + }, + ], + }, + ], + api_version: '2023-01' as const, + name: 'UI Extension', + description: 'This is an ordinary test extension', + type: 'ui_extension', + handle: 'test-ui-extension', + capabilities: { + block_progress: false, + network_access: false, + api_access: false, + collect_buyer_consent: { + customer_privacy: true, + sms_marketing: false, + }, + iframe: { + sources: [], + }, + }, + settings: {}, + } + + // When + const parsed = specification.parseConfigurationObject(configuration) + if (parsed.state !== 'ok') { + throw new Error("Couldn't parse configuration") + } + + const got = parsed.data + + // Then + expect(got.extension_points).toStrictEqual([ + { + target: 'EXTENSION::POINT::A', + module: './src/ExtensionPointA.js', + metafields: [], + default_placement_reference: undefined, + capabilities: undefined, + preloads: {}, + build_manifest: { + assets: { + main: { + filepath: 'test-ui-extension.js', + module: './src/ExtensionPointA.js', + }, + intents: [ + { + filepath: 'test-ui-extension-EXTENSION::POINT::A-intents-1-create-schema.json', + module: './intents/create-schema.json', + static: true, + }, + ], + }, + }, + tools: undefined, + instructions: undefined, + intents: [ + { + type: 'app_intent', + action: 'create', + schema: './intents/create-schema.json', + name: 'Create Intent', + description: 'Creates a new item', + }, + ], + urls: {}, + }, + ]) + }) + + test('build_manifest includes multiple intents assets when multiple intents are present', async () => { + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! + const configuration = { + targeting: [ + { + target: 'EXTENSION::POINT::A', + module: './src/ExtensionPointA.js', + intents: [ + { + type: 'app_intent', + action: 'create', + schema: './intents/create-schema.json', + }, + { + type: 'app_intent', + action: 'update', + schema: './intents/update-schema.json', + }, + { + type: 'app_intent', + action: 'delete', + schema: './intents/delete-schema.json', + }, + ], + }, + ], + api_version: '2023-01' as const, + name: 'UI Extension', + description: 'This is an ordinary test extension', + type: 'ui_extension', + handle: 'test-ui-extension', + capabilities: { + block_progress: false, + network_access: false, + api_access: false, + collect_buyer_consent: { + customer_privacy: true, + sms_marketing: false, + }, + iframe: { + sources: [], + }, + }, + settings: {}, + } + + // When + const parsed = specification.parseConfigurationObject(configuration) + if (parsed.state !== 'ok') { + throw new Error("Couldn't parse configuration") + } + + const got = parsed.data + + // Then + expect(got.extension_points[0]?.build_manifest?.assets?.intents).toHaveLength(3) + expect(got.extension_points[0]?.build_manifest?.assets?.intents).toEqual([ + { + filepath: 'test-ui-extension-EXTENSION::POINT::A-intents-1-create-schema.json', + module: './intents/create-schema.json', + static: true, + }, + { + filepath: 'test-ui-extension-EXTENSION::POINT::A-intents-2-update-schema.json', + module: './intents/update-schema.json', + static: true, + }, + { + filepath: 'test-ui-extension-EXTENSION::POINT::A-intents-3-delete-schema.json', + module: './intents/delete-schema.json', + static: true, + }, + ]) + }) + + test('build_manifest includes both intents and other assets when both are present', async () => { + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! + const configuration = { + targeting: [ + { + target: 'EXTENSION::POINT::A', + module: './src/ExtensionPointA.js', + tools: './tools.json', + instructions: './instructions.md', + intents: [ + { + type: 'app_intent', + action: 'create', + schema: './intents/create-schema.json', + }, + ], + }, + ], + api_version: '2023-01' as const, + name: 'UI Extension', + description: 'This is an ordinary test extension', + type: 'ui_extension', + handle: 'test-ui-extension', + capabilities: { + block_progress: false, + network_access: false, + api_access: false, + collect_buyer_consent: { + customer_privacy: true, + sms_marketing: false, + }, + iframe: { + sources: [], + }, + }, + settings: {}, + } + + // When + const parsed = specification.parseConfigurationObject(configuration) + if (parsed.state !== 'ok') { + throw new Error("Couldn't parse configuration") + } + + const got = parsed.data + + // Then + expect(got.extension_points).toStrictEqual([ + { + target: 'EXTENSION::POINT::A', + module: './src/ExtensionPointA.js', + metafields: [], + default_placement_reference: undefined, + capabilities: undefined, + preloads: {}, + build_manifest: { + assets: { + main: { + filepath: 'test-ui-extension.js', + module: './src/ExtensionPointA.js', + }, + tools: { + filepath: 'test-ui-extension-EXTENSION::POINT::A-tools-tools.json', + module: './tools.json', + static: true, + }, + instructions: { + filepath: 'test-ui-extension-EXTENSION::POINT::A-instructions-instructions.md', + module: './instructions.md', + static: true, + }, + intents: [ + { + filepath: 'test-ui-extension-EXTENSION::POINT::A-intents-1-create-schema.json', + module: './intents/create-schema.json', + static: true, + }, + ], + }, + }, + tools: './tools.json', + instructions: './instructions.md', + intents: [ + { + type: 'app_intent', + action: 'create', + schema: './intents/create-schema.json', + }, + ], + urls: {}, + }, + ]) + }) + test('returns error if there is no targeting or extension_points', async () => { // Given const allSpecs = await loadLocalExtensionsSpecifications() @@ -852,6 +1110,189 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` }) }) + test('shows an error when an intent schema file is missing', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await mkdir(joinPath(tmpDir, 'src')) + await touchFile(joinPath(tmpDir, 'src', 'ExtensionPointA.js')) + + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! + + const configuration = { + targeting: [ + { + target: 'EXTENSION::POINT::A', + module: './src/ExtensionPointA.js', + intents: [ + { + type: 'app_intent', + action: 'create', + schema: './intents/create-schema.json', + }, + ], + }, + ], + api_version: '2023-01' as const, + name: 'UI Extension', + description: 'This is an ordinary test extension', + type: 'ui_extension', + handle: 'test-ui-extension', + capabilities: { + block_progress: false, + network_access: false, + api_access: false, + collect_buyer_consent: { + customer_privacy: true, + sms_marketing: false, + }, + iframe: { + sources: [], + }, + }, + settings: {}, + } + + const parsed = specification.parseConfigurationObject(configuration) + if (parsed.state !== 'ok') { + throw new Error("Couldn't parse configuration") + } + + const result = await specification.validate?.(parsed.data, joinPath(tmpDir, 'shopify.extension.toml'), tmpDir) + + const notFoundPath = joinPath(tmpDir, './intents/create-schema.json') + expect(result).toEqual( + err(`Couldn't find ${notFoundPath} + Please check the intent schema path for EXTENSION::POINT::A + +Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}`), + ) + }) + }) + + test('shows multiple errors when multiple intent schema files are missing', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await mkdir(joinPath(tmpDir, 'src')) + await touchFile(joinPath(tmpDir, 'src', 'ExtensionPointA.js')) + + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! + + const configuration = { + targeting: [ + { + target: 'EXTENSION::POINT::A', + module: './src/ExtensionPointA.js', + intents: [ + { + type: 'app_intent', + action: 'create', + schema: './intents/create-schema.json', + }, + { + type: 'app_intent', + action: 'update', + schema: './intents/update-schema.json', + }, + ], + }, + ], + api_version: '2023-01' as const, + name: 'UI Extension', + description: 'This is an ordinary test extension', + type: 'ui_extension', + handle: 'test-ui-extension', + capabilities: { + block_progress: false, + network_access: false, + api_access: false, + collect_buyer_consent: { + customer_privacy: true, + sms_marketing: false, + }, + iframe: { + sources: [], + }, + }, + settings: {}, + } + + const parsed = specification.parseConfigurationObject(configuration) + if (parsed.state !== 'ok') { + throw new Error("Couldn't parse configuration") + } + + const result = await specification.validate?.(parsed.data, joinPath(tmpDir, 'shopify.extension.toml'), tmpDir) + + const notFoundPath1 = joinPath(tmpDir, './intents/create-schema.json') + const notFoundPath2 = joinPath(tmpDir, './intents/update-schema.json') + expect(result).toEqual( + err(`Couldn't find ${notFoundPath1} + Please check the intent schema path for EXTENSION::POINT::A + +Couldn't find ${notFoundPath2} + Please check the intent schema path for EXTENSION::POINT::A + +Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}`), + ) + }) + }) + + test('succeeds when intent schema files exist', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await mkdir(joinPath(tmpDir, 'src')) + await touchFile(joinPath(tmpDir, 'src', 'ExtensionPointA.js')) + + await mkdir(joinPath(tmpDir, 'intents')) + await writeFile(joinPath(tmpDir, 'intents', 'create-schema.json'), '{"schema": "content"}') + + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! + + const configuration = { + targeting: [ + { + target: 'EXTENSION::POINT::A', + module: './src/ExtensionPointA.js', + intents: [ + { + type: 'app_intent', + action: 'create', + schema: './intents/create-schema.json', + }, + ], + }, + ], + api_version: '2023-01' as const, + name: 'UI Extension', + description: 'This is an ordinary test extension', + type: 'ui_extension', + handle: 'test-ui-extension', + capabilities: { + block_progress: false, + network_access: false, + api_access: false, + collect_buyer_consent: { + customer_privacy: true, + sms_marketing: false, + }, + iframe: { + sources: [], + }, + }, + settings: {}, + } + + const parsed = specification.parseConfigurationObject(configuration) + if (parsed.state !== 'ok') { + throw new Error("Couldn't parse configuration") + } + + const result = await specification.validate?.(parsed.data, joinPath(tmpDir, 'shopify.extension.toml'), tmpDir) + + expect(result).toStrictEqual(ok({})) + }) + }) + test('build_manifest includes both tools and instructions when both are present', async () => { const allSpecs = await loadLocalExtensionsSpecifications() const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! @@ -910,12 +1351,12 @@ Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}` module: './src/ExtensionPointA.js', }, tools: { - filepath: 'test-ui-extension-tools-tools.json', + filepath: 'test-ui-extension-EXTENSION::POINT::A-tools-tools.json', module: './tools.json', static: true, }, instructions: { - filepath: 'test-ui-extension-instructions-instructions.md', + filepath: 'test-ui-extension-EXTENSION::POINT::A-instructions-instructions.md', module: './instructions.md', static: true, }, 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 f3e04cbeab9..f416ee69b58 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -6,15 +6,22 @@ import { createToolsTypeDefinition, ToolsFileSchema, } from './type-generation.js' -import {Asset, AssetIdentifier, BuildAsset, ExtensionFeature, createExtensionSpecification} from '../specification.js' +import { + copyStaticBuildManifestAssets, + validateBuildManifestAssets, + addDistPathToAssets, + transformStaticAssets, + BuildManifest, +} from './build-manifest-schema.js' +import {Asset, AssetIdentifier, 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' import {ExtensionInstance} from '../extension-instance.js' import {formatContent} from '../../../utilities/file-formatter.js' import {err, ok, Result} from '@shopify/cli-kit/node/result' -import {copyFile, fileExists, readFile} from '@shopify/cli-kit/node/fs' -import {joinPath, basename, dirname} from '@shopify/cli-kit/node/path' +import {fileExists, readFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' import {outputContent, outputToken, outputWarn} from '@shopify/cli-kit/node/output' import {zod} from '@shopify/cli-kit/node/schema' @@ -24,18 +31,11 @@ const validatePoints = (config: {extension_points?: unknown[]; targeting?: unkno return config.extension_points !== undefined || config.targeting !== undefined } -export interface BuildManifest { - assets: { - // Main asset is always required - [AssetIdentifier.Main]: BuildAsset - [AssetIdentifier.ShouldRender]?: BuildAsset - [AssetIdentifier.Tools]?: BuildAsset - [AssetIdentifier.Instructions]?: BuildAsset - } -} - const missingExtensionPointsMessage = 'No extension targets defined, add a `targeting` field to your configuration' +// Re-export BuildManifest for backward compatibility with files that import it from ui_extension +export type {BuildManifest} + export const UIExtensionSchema = BaseSchema.extend({ name: zod.string(), type: zod.literal('ui_extension'), @@ -45,39 +45,27 @@ export const UIExtensionSchema = BaseSchema.extend({ }) .refine((config) => validatePoints(config), missingExtensionPointsMessage) .transform((config) => { + const handle = config.handle ?? 'extension' const extensionPoints = (config.targeting ?? config.extension_points ?? []).map((targeting) => { + // Create static assets configuration (tools, instructions, intents) + const {build_manifest: staticBuildManifest, ...staticAssetFields} = transformStaticAssets(targeting, handle) + const buildManifest: BuildManifest = { assets: { [AssetIdentifier.Main]: { - filepath: `${config.handle}.js`, + filepath: `${handle}.js`, module: targeting.module, }, ...(targeting.should_render?.module ? { [AssetIdentifier.ShouldRender]: { - filepath: `${config.handle}-conditions.js`, + filepath: `${handle}-conditions.js`, module: targeting.should_render.module, }, } : null), - ...(targeting.tools - ? { - [AssetIdentifier.Tools]: { - filepath: `${config.handle}-${AssetIdentifier.Tools}-${basename(targeting.tools)}`, - module: targeting.tools, - static: true, - }, - } - : null), - ...(targeting.instructions - ? { - [AssetIdentifier.Instructions]: { - filepath: `${config.handle}-${AssetIdentifier.Instructions}-${basename(targeting.instructions)}`, - module: targeting.instructions, - static: true, - }, - } - : null), + // Merge in static assets (tools, instructions, intents) + ...staticBuildManifest.assets, }, } @@ -90,8 +78,8 @@ export const UIExtensionSchema = BaseSchema.extend({ capabilities: targeting.capabilities, preloads: targeting.preloads ?? {}, build_manifest: buildManifest, - tools: targeting.tools, - instructions: targeting.instructions, + // tools, instructions, intents + ...staticAssetFields, } }) return {...config, extension_points: extensionPoints} @@ -158,22 +146,7 @@ const uiExtensionSpec = createExtensionSpecification({ copyStaticAssets: async (config, directory, outputPath) => { if (!isRemoteDomExtension(config)) return - await Promise.all( - 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) { - 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() - }) - }), - ) + await copyStaticBuildManifestAssets(config.extension_points, directory, outputPath) }, hasExtensionPointTarget: (config, requestedTarget) => { return ( @@ -329,40 +302,6 @@ const uiExtensionSpec = createExtensionSpecification({ }, }) -function addDistPathToAssets(extP: NewExtensionPointSchemaType & {build_manifest: BuildManifest}) { - return { - ...extP, - build_manifest: { - ...extP.build_manifest, - assets: Object.fromEntries( - Object.entries(extP.build_manifest.assets).map(([key, value]) => [ - key as AssetIdentifier, - { - ...value, - filepath: joinPath('dist', value.filepath), - }, - ]), - ), - }, - } -} - -async function checkForMissingPath( - directory: string, - assetModule: string | undefined, - target: string, - assetType: string, -): Promise { - if (!assetModule) return undefined - - const assetPath = joinPath(directory, assetModule) - const exists = await fileExists(assetPath) - return exists - ? undefined - : outputContent`Couldn't find ${outputToken.path(assetPath)} - Please check the ${assetType} path for ${target}`.value -} - async function validateUIExtensionPointConfig( directory: string, extensionPoints: (NewExtensionPointSchemaType & {build_manifest?: BuildManifest})[], @@ -377,31 +316,16 @@ async function validateUIExtensionPointConfig( } for await (const extensionPoint of extensionPoints) { - const {module, target, build_manifest: buildManifest} = extensionPoint - - const missingModuleError = await checkForMissingPath(directory, module, target, 'module') - if (missingModuleError) { - errors.push(missingModuleError) - } - - const missingToolsError = await checkForMissingPath( - directory, - buildManifest?.assets[AssetIdentifier.Tools]?.module, - target, - AssetIdentifier.Tools, - ) - if (missingToolsError) { - errors.push(missingToolsError) - } - - const missingInstructionsError = await checkForMissingPath( - directory, - buildManifest?.assets[AssetIdentifier.Instructions]?.module, - target, - AssetIdentifier.Instructions, - ) - if (missingInstructionsError) { - errors.push(missingInstructionsError) + const {module, target} = extensionPoint + + // Check if module file exists + const modulePath = joinPath(directory, module) + const moduleExists = await fileExists(modulePath) + if (!moduleExists) { + errors.push( + outputContent`Couldn't find ${outputToken.path(modulePath)} + Please check the module path for ${target}`.value, + ) } if (uniqueTargets.includes(target)) { @@ -411,6 +335,12 @@ async function validateUIExtensionPointConfig( } } + // Validate static assets in build manifest + const buildManifestValidation = await validateBuildManifestAssets(directory, extensionPoints) + if (buildManifestValidation.isErr()) { + errors.push(buildManifestValidation.error) + } + if (duplicateTargets.length) { errors.push(`Duplicate targets found: ${duplicateTargets.join(', ')}\nExtension point targets must be unique`) } 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 edf39052082..8465bfd092f 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 @@ -77,11 +77,13 @@ export class ESBuildContextManager { async rebuildContext(extension: ExtensionInstance) { 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 + if (context) { + await Promise.all(context.map((ctxt) => ctxt.rebuild())) + } + + // Copy static assets after build completes (or even if there's no build context) // Pass in an explicit output path because the extension.outputPath is not the same as the dev bundle output path. await extension.copyStaticAssets(devBundleOutputPath) 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 ae905a037a2..5f7a2cec569 100644 --- a/packages/app/src/cli/services/dev/extension/payload.test.ts +++ b/packages/app/src/cli/services/dev/extension/payload.test.ts @@ -4,7 +4,7 @@ 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, beforeEach} from 'vitest' -import {inTemporaryDirectory, touchFile, writeFile} from '@shopify/cli-kit/node/fs' +import {inTemporaryDirectory, mkdir, touchFile, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' describe('getUIExtensionPayload', () => { @@ -338,6 +338,97 @@ describe('getUIExtensionPayload', () => { }) }) + test('returns the right payload for UI Extensions with intents in build_manifest', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const outputPath = joinPath(tmpDir, 'test-ui-extension.js') + await touchFile(outputPath) + + const intentsDir = joinPath(tmpDir, 'intents') + await mkdir(intentsDir) + await writeFile(joinPath(intentsDir, 'create-schema.json'), '{"type": "object"}') + await writeFile(joinPath(intentsDir, 'update-schema.json'), '{"type": "object"}') + + const buildManifest = { + assets: { + main: {module: './src/ExtensionPointA.js', filepath: '/test-ui-extension.js'}, + intents: [ + { + module: './intents/create-schema.json', + filepath: '/test-ui-extension-intent-create-app_intent-create-schema.json', + static: true, + }, + { + module: './intents/update-schema.json', + filepath: '/test-ui-extension-intent-update-app_intent-update-schema.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, + intents: [ + {type: 'application/email', action: 'create', schema: './intents/create-schema.json'}, + {type: 'application/email', action: 'update', schema: './intents/update-schema.json'}, + ], + }, + ], + }, + 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), + }, + }, + intents: [ + { + type: 'application/email', + action: 'create', + schema: { + name: 'schema', + url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension-intent-create-app_intent-create-schema.json', + lastUpdated: expect.any(Number), + }, + }, + { + type: 'application/email', + action: 'update', + schema: { + name: 'schema', + url: 'http://tunnel-url.com/extensions/devUUID/assets/test-ui-extension-intent-update-app_intent-update-schema.json', + lastUpdated: expect.any(Number), + }, + }, + ], + }, + ]) + }) + }) + test('returns the right payload for post-purchase extensions', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given diff --git a/packages/app/src/cli/services/dev/extension/payload.ts b/packages/app/src/cli/services/dev/extension/payload.ts index ed21fe7eef7..7bccbe34329 100644 --- a/packages/app/src/cli/services/dev/extension/payload.ts +++ b/packages/app/src/cli/services/dev/extension/payload.ts @@ -6,7 +6,7 @@ import {getUIExtensionResourceURL} from '../../../utilities/extensions/configura 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 {AssetIdentifier, 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' @@ -19,7 +19,8 @@ export type GetUIExtensionPayloadOptions = Omit> { - const payload = await getAssetPayload(identifier, asset, url, extension) + const payload = Array.isArray(asset) + ? await Promise.all(asset.map((child) => getAssetPayload(child.module, child, url, extension))) + : [await getAssetPayload(identifier, asset, url, extension)] + return { - assets: {[payload.name]: payload}, + assets: Object.fromEntries(payload.map((payload) => [payload.name, payload])), } } +/** + * Intents asset mapper - transforms intents and places them at extension point level + */ +async function intentsAssetMapper({ + asset, + extensionPoint, + url, + extension, +}: AssetMapperContext): Promise> { + if (!extensionPoint.intents || !Array.isArray(asset)) return {} + + const intents = await Promise.all( + extensionPoint.intents.map(async (intent, index) => { + const intentAsset = asset[index] + if (!intentAsset) throw new Error(`Missing intent asset for ${intent.action}`) + return { + ...intent, + schema: await getAssetPayload('schema', intentAsset, url, extension), + } + }), + ) + + return {intents} +} + +/** + * Asset mappers registry - defines how each asset type should be handled + */ +const ASSET_MAPPERS: { + [key in AssetIdentifier]?: (context: AssetMapperContext) => Promise> +} = { + [AssetIdentifier.Intents]: intentsAssetMapper, +} + /** * 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}, + 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}) - }), + Object.entries(buildManifest.assets) + .filter((entry): entry is [string, BuildAsset | BuildAsset[]] => { + const [_, asset] = entry + return asset !== undefined + }) + .map(async ([identifier, asset]) => { + return ( + ASSET_MAPPERS[identifier as AssetIdentifier]?.({identifier, asset, extensionPoint, url, extension}) ?? + defaultAssetMapper({identifier, asset, extensionPoint, url, extension}) + ) + }), ) return mappingResults.reduce>( 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 7e617ca63ab..f67eed08e82 100644 --- a/packages/app/src/cli/services/dev/extension/payload/models.ts +++ b/packages/app/src/cli/services/dev/extension/payload/models.ts @@ -32,7 +32,7 @@ interface Asset { lastUpdated: number } -export interface DevNewExtensionPointSchema extends NewExtensionPointSchemaType { +export interface DevNewExtensionPointSchema extends Omit { build_manifest: BuildManifest assets: { [name: string]: Asset @@ -43,6 +43,13 @@ export interface DevNewExtensionPointSchema extends NewExtensionPointSchemaType resource: { url: string } + intents?: { + type: string + action: string + name?: string + description?: string + schema: Asset + }[] } interface SupportedFeatures { diff --git a/packages/app/src/cli/services/generate/fetch-extension-specifications.test.ts b/packages/app/src/cli/services/generate/fetch-extension-specifications.test.ts index 3b81982d2d2..64f855f203d 100644 --- a/packages/app/src/cli/services/generate/fetch-extension-specifications.test.ts +++ b/packages/app/src/cli/services/generate/fetch-extension-specifications.test.ts @@ -1,5 +1,7 @@ import {fetchSpecifications} from './fetch-extension-specifications.js' import {testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' +import {inTemporaryDirectory, writeFile, mkdir, fileExistsSync} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' import {describe, expect, test} from 'vitest' describe('fetchExtensionSpecifications', () => { @@ -99,4 +101,206 @@ describe('fetchExtensionSpecifications', () => { expect(withoutLocalization?.appModuleFeatures()).toEqual([]) expect(withLocalization?.appModuleFeatures()).toEqual(['localization']) }) + + describe('admin_link', () => { + test('applies override with validate and copyStaticAssets methods', async () => { + const got = await fetchSpecifications({ + developerPlatformClient: testDeveloperPlatformClient(), + app: testOrganizationApp(), + }) + + const adminLink = got.find((spec) => spec.identifier === 'admin_link') + + expect(adminLink).toBeDefined() + expect(adminLink?.identifier).toBe('admin_link') + expect(adminLink?.validate).toBeDefined() + expect(adminLink?.copyStaticAssets).toBeDefined() + expect(adminLink?.uidStrategy).toBe('uuid') + expect(adminLink?.registrationLimit).toBe(10) + }) + + test('remote schema validates configuration', async () => { + const got = await fetchSpecifications({ + developerPlatformClient: testDeveloperPlatformClient(), + app: testOrganizationApp(), + }) + + const adminLink = got.find((spec) => spec.identifier === 'admin_link') + expect(adminLink).toBeDefined() + + const validConfig = { + targeting: [ + { + target: 'admin.product.details.action', + tools: './tools.json', + }, + ], + name: 'Test Admin Link', + type: 'admin_link' as const, + handle: 'test-admin-link', + } + + const parsed = adminLink?.parseConfigurationObject(validConfig) + + expect(parsed?.state).toBe('ok') + if (parsed?.state === 'ok') { + const data = parsed.data as {name?: string; targeting?: {target: string}[]} + expect(data.name).toBe('Test Admin Link') + expect(data.targeting).toBeDefined() + expect(data.targeting?.[0]?.target).toBe('admin.product.details.action') + } + + const invalidConfig = { + targeting: [ + { + target: 'admin.product.details.action', + }, + ], + type: 'admin_link' as const, + } + + const parsedInvalid = adminLink?.parseConfigurationObject(invalidConfig) + + expect(parsedInvalid?.state).toBe('error') + if (parsedInvalid?.state === 'error') { + expect(parsedInvalid.errors.length).toBeGreaterThan(0) + const nameError = parsedInvalid.errors.find((err) => err.path?.includes('name')) + expect(nameError).toBeDefined() + } + }) + + test('validate works with fetched admin_link spec', async () => { + const got = await fetchSpecifications({ + developerPlatformClient: testDeveloperPlatformClient(), + app: testOrganizationApp(), + }) + + const adminLink = got.find((spec) => spec.identifier === 'admin_link') + expect(adminLink).toBeDefined() + expect(adminLink?.validate).toBeDefined() + + await inTemporaryDirectory(async (tmpDir) => { + await writeFile(joinPath(tmpDir, 'tools.json'), '{"tools": []}') + await writeFile(joinPath(tmpDir, 'instructions.md'), '# Instructions') + + const config = { + targeting: [ + { + target: 'admin.product.details.action', + tools: './tools.json', + instructions: './instructions.md', + build_manifest: { + assets: { + tools: { + filepath: 'test-tools.json', + module: './tools.json', + static: true, + }, + instructions: { + filepath: 'test-instructions.md', + module: './instructions.md', + static: true, + }, + }, + }, + }, + ], + name: 'Test', + type: 'admin_link' as const, + } + + const result = await adminLink!.validate!(config, '', tmpDir) + expect(result).toMatchObject({value: {}}) + }) + }) + + test('validate returns error when files are missing', async () => { + const got = await fetchSpecifications({ + developerPlatformClient: testDeveloperPlatformClient(), + app: testOrganizationApp(), + }) + + const adminLink = got.find((spec) => spec.identifier === 'admin_link') + expect(adminLink).toBeDefined() + + await inTemporaryDirectory(async (tmpDir) => { + const config = { + targeting: [ + { + target: 'admin.product.details.action', + tools: './missing-tools.json', + instructions: undefined, + build_manifest: { + assets: { + tools: { + filepath: 'test-tools.json', + module: './missing-tools.json', + static: true, + }, + }, + }, + }, + ], + name: 'Test', + type: 'admin_link' as const, + } + + const result = await adminLink!.validate!(config, '', tmpDir) + expect(result.isErr()).toBe(true) + if (result.isErr()) { + expect(result.error).toContain("Couldn't find") + expect(result.error).toContain('missing-tools.json') + } + }) + }) + + test('copyStaticAssets works with fetched admin_link spec', async () => { + const got = await fetchSpecifications({ + developerPlatformClient: testDeveloperPlatformClient(), + app: testOrganizationApp(), + }) + + const adminLink = got.find((spec) => spec.identifier === 'admin_link') + expect(adminLink).toBeDefined() + expect(adminLink?.copyStaticAssets).toBeDefined() + + await inTemporaryDirectory(async (tmpDir) => { + const distDir = joinPath(tmpDir, 'dist') + await mkdir(distDir) + await writeFile(joinPath(tmpDir, 'tools.json'), '{"tools": []}') + await writeFile(joinPath(tmpDir, 'instructions.md'), '# Instructions') + + const config = { + targeting: [ + { + target: 'admin.product.details.action', + tools: './tools.json', + instructions: './instructions.md', + build_manifest: { + assets: { + tools: { + filepath: 'test-tools.json', + module: './tools.json', + static: true, + }, + instructions: { + filepath: 'test-instructions.md', + module: './instructions.md', + static: true, + }, + }, + }, + }, + ], + name: 'Test', + type: 'admin_link' as const, + } + + await adminLink!.copyStaticAssets!(config, tmpDir, joinPath(distDir, 'extension.js')) + + expect(fileExistsSync(joinPath(distDir, 'test-tools.json'))).toBe(true) + expect(fileExistsSync(joinPath(distDir, 'test-instructions.md'))).toBe(true) + }) + }) + }) }) diff --git a/packages/app/src/cli/services/generate/fetch-extension-specifications.ts b/packages/app/src/cli/services/generate/fetch-extension-specifications.ts index 37544a1c672..fa392490e29 100644 --- a/packages/app/src/cli/services/generate/fetch-extension-specifications.ts +++ b/packages/app/src/cli/services/generate/fetch-extension-specifications.ts @@ -4,6 +4,7 @@ import { createContractBasedModuleSpecification, ExtensionSpecification, RemoteAwareExtensionSpecification, + SPECIFICATION_OVERRIDES, } from '../../models/extensions/specification.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {MinimalAppIdentifiers} from '../../models/organization.js' @@ -68,9 +69,12 @@ async function mergeLocalAndRemoteSpecs( if (!localSpec && remoteSpec.validationSchema?.jsonSchema) { const normalisedSchema = await normaliseJsonSchema(remoteSpec.validationSchema.jsonSchema) const hasLocalization = normalisedSchema.properties?.localization !== undefined + const override = SPECIFICATION_OVERRIDES[remoteSpec.identifier] ?? {} + localSpec = createContractBasedModuleSpecification({ identifier: remoteSpec.identifier, appModuleFeatures: () => (hasLocalization ? ['localization'] : []), + ...override, }) localSpec.uidStrategy = remoteSpec.options.uidIsClientProvided ? 'uuid' : 'single' }