From cdfe500a3625c2f998fafb80bc82b814ba8d93ae Mon Sep 17 00:00:00 2001 From: Trish Ta Date: Wed, 7 Jan 2026 15:59:43 -0500 Subject: [PATCH] Add support for uploading instructions.md for ui extensions --- .../app/src/cli/models/extensions/schemas.ts | 1 + .../cli/models/extensions/specification.ts | 1 + .../specifications/ui_extension.test.ts | 325 +++++++++++++++++- .../extensions/specifications/ui_extension.ts | 60 +++- 4 files changed, 376 insertions(+), 11 deletions(-) diff --git a/packages/app/src/cli/models/extensions/schemas.ts b/packages/app/src/cli/models/extensions/schemas.ts index 61e0c12a56b..5fe195ff3e4 100644 --- a/packages/app/src/cli/models/extensions/schemas.ts +++ b/packages/app/src/cli/models/extensions/schemas.ts @@ -46,6 +46,7 @@ const NewExtensionPointSchema = zod.object({ module: zod.string(), should_render: ShouldRenderSchema.optional(), tools: zod.string().optional(), + instructions: 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 4e6f9d2845b..b0eab79677d 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -39,6 +39,7 @@ export enum AssetIdentifier { ShouldRender = 'should_render', Main = 'main', Tools = 'tools', + Instructions = 'instructions', } export interface Asset { 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 aac1823045e..421cfc7d4cc 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 @@ -77,6 +77,64 @@ describe('ui_extension', async () => { }) } + async function setupToolsExtension( + tmpDir: string, + options: {tools?: string; instructions?: string; createFiles?: boolean} = {}, + ) { + await mkdir(joinPath(tmpDir, 'src')) + await touchFile(joinPath(tmpDir, 'src', 'ExtensionPointA.js')) + + if (options.createFiles) { + if (options.tools) { + await writeFile(joinPath(tmpDir, options.tools), '{"tools": []}') + } + if (options.instructions) { + await writeFile(joinPath(tmpDir, options.instructions), '# Instructions') + } + } + + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! + + const targetConfig: any = { + target: 'EXTENSION::POINT::A', + module: './src/ExtensionPointA.js', + } + + if (options.tools) targetConfig.tools = options.tools + if (options.instructions) targetConfig.instructions = options.instructions + + const configuration = { + targeting: [targetConfig], + 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) + return {result, tmpDir} + } + describe('validate()', () => { test('returns ok({}) if there are no errors', async () => { await inTemporaryDirectory(async (tmpDir) => { @@ -487,6 +545,138 @@ describe('ui_extension', async () => { ]) }) + test('build_manifest includes tools asset when tools is 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', + }, + ], + 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-tools-tools.json', + module: './tools.json', + static: true, + }, + }, + }, + urls: {}, + }, + ]) + }) + + test('build_manifest includes instructions asset when instructions is 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', + instructions: './instructions.md', + }, + ], + 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', + }, + instructions: { + filepath: 'test-ui-extension-instructions-instructions.md', + module: './instructions.md', + static: true, + }, + }, + }, + urls: {}, + }, + ]) + }) + test('returns error if there is no targeting or extension_points', async () => { // Given const allSpecs = await loadLocalExtensionsSpecifications() @@ -545,7 +735,7 @@ describe('ui_extension', async () => { expect(result).toEqual( err(`Couldn't find ${notFoundPath} -Please check the module path for EXTENSION::POINT::A + Please check the module path for EXTENSION::POINT::A Please check the configuration in ${uiExtension.configurationPath}`), ) @@ -584,6 +774,139 @@ Please check the configuration in ${uiExtension.configurationPath}`), ) }) }) + + test('shows an error when the tools file is missing', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const {result} = await setupToolsExtension(tmpDir, {tools: './tools.json'}) + + const notFoundPath = joinPath(tmpDir, './tools.json') + expect(result).toEqual( + err(`Couldn't find ${notFoundPath} + Please check the tools path for EXTENSION::POINT::A + +Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}`), + ) + }) + }) + + test('shows an error when the instructions file is missing', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const {result} = await setupToolsExtension(tmpDir, {instructions: './instructions.md'}) + + const notFoundPath = joinPath(tmpDir, './instructions.md') + expect(result).toEqual( + err(`Couldn't find ${notFoundPath} + Please check the instructions path for EXTENSION::POINT::A + +Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}`), + ) + }) + }) + + test('shows multiple errors when both tools and instructions files are missing', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const {result} = await setupToolsExtension(tmpDir, { + tools: './tools.json', + instructions: './instructions.md', + }) + + const toolsNotFoundPath = joinPath(tmpDir, './tools.json') + const instructionsNotFoundPath = joinPath(tmpDir, './instructions.md') + expect(result).toEqual( + err(`Couldn't find ${toolsNotFoundPath} + Please check the tools path for EXTENSION::POINT::A + +Couldn't find ${instructionsNotFoundPath} + Please check the instructions path for EXTENSION::POINT::A + +Please check the configuration in ${joinPath(tmpDir, 'shopify.extension.toml')}`), + ) + }) + }) + + test('succeeds when both tools and instructions files exist', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const {result} = await setupToolsExtension(tmpDir, { + tools: './tools.json', + instructions: './instructions.md', + createFiles: true, + }) + + 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')! + const configuration = { + targeting: [ + { + target: 'EXTENSION::POINT::A', + module: './src/ExtensionPointA.js', + tools: './tools.json', + instructions: './instructions.md', + }, + ], + 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-tools-tools.json', + module: './tools.json', + static: true, + }, + instructions: { + filepath: 'test-ui-extension-instructions-instructions.md', + module: './instructions.md', + static: true, + }, + }, + }, + urls: {}, + }, + ]) + }) }) describe('deployConfig()', () => { 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 637c2cba1ee..3d4fc279052 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -67,6 +67,15 @@ export const UIExtensionSchema = BaseSchema.extend({ }, } : null), + ...(targeting.instructions + ? { + [AssetIdentifier.Instructions]: { + filepath: `${config.handle}-${AssetIdentifier.Instructions}-${basename(targeting.instructions)}`, + module: targeting.instructions, + static: true, + }, + } + : null), }, } @@ -309,9 +318,25 @@ function addDistPathToAssets(extP: NewExtensionPointSchemaType & {build_manifest } } +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[], + extensionPoints: (NewExtensionPointSchemaType & {build_manifest?: BuildManifest})[], configPath: string, ): Promise> { const errors: string[] = [] @@ -322,17 +347,32 @@ async function validateUIExtensionPointConfig( return err(missingExtensionPointsMessage) } - for await (const {module, target} of extensionPoints) { - const fullPath = joinPath(directory, module) - const exists = await fileExists(fullPath) + 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) + } - if (!exists) { - const notFoundPath = outputToken.path(joinPath(directory, module)) + const missingToolsError = await checkForMissingPath( + directory, + buildManifest?.assets[AssetIdentifier.Tools]?.module, + target, + AssetIdentifier.Tools, + ) + if (missingToolsError) { + errors.push(missingToolsError) + } - errors.push( - outputContent`Couldn't find ${notFoundPath} -Please check the module path for ${target}`.value, - ) + const missingInstructionsError = await checkForMissingPath( + directory, + buildManifest?.assets[AssetIdentifier.Instructions]?.module, + target, + AssetIdentifier.Instructions, + ) + if (missingInstructionsError) { + errors.push(missingInstructionsError) } if (uniqueTargets.includes(target)) {