diff --git a/packages/app/src/cli/models/extensions/specifications/type-generation.test.ts b/packages/app/src/cli/models/extensions/specifications/type-generation.test.ts index b26704b3d7c..b3029f3599e 100644 --- a/packages/app/src/cli/models/extensions/specifications/type-generation.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/type-generation.test.ts @@ -1,7 +1,164 @@ -import {createToolsTypeDefinition} from './type-generation.js' +import { + createIntentsTypeDefinition, + createToolsTypeDefinition, + getGeneratedTypesHelperImportPath, +} from './type-generation.js' import {AbortError} from '@shopify/cli-kit/node/error' import {describe, expect, test} from 'vitest' +const adminGeneratedTypesHelperImportPath = '@shopify/ui-extensions/admin' + +describe('getGeneratedTypesHelperImportPath', () => { + test('returns the surface package for generated helper types', () => { + expect(getGeneratedTypesHelperImportPath(['admin.app.intent.render'])).toBe('@shopify/ui-extensions/admin') + expect(getGeneratedTypesHelperImportPath(['purchase.checkout.block.render'])).toBe( + '@shopify/ui-extensions/checkout', + ) + expect(getGeneratedTypesHelperImportPath(['pos.home.tile.render'])).toBe('@shopify/ui-extensions/point-of-sale') + expect(getGeneratedTypesHelperImportPath(['customer-account.order-status.block.render'])).toBe( + '@shopify/ui-extensions/customer-account', + ) + }) +}) + +describe('createIntentsTypeDefinition', () => { + test('returns empty string when intents array is empty', async () => { + // When + const result = await createIntentsTypeDefinition([], { + generatedTypesHelperImportPath: adminGeneratedTypesHelperImportPath, + }) + + // Then + expect(result).toBe('') + }) + + test('generates request and response types for a single intent schema', async () => { + // Given + const intents = [ + { + action: 'create', + type: 'application/email', + inputSchema: { + type: 'object', + properties: { + recipient: {type: 'string'}, + }, + required: ['recipient'], + }, + outputSchema: { + type: 'object', + properties: { + success: {type: 'boolean'}, + }, + }, + }, + ] + + // When + const result = await createIntentsTypeDefinition(intents, { + generatedTypesHelperImportPath: adminGeneratedTypesHelperImportPath, + }) + + // Then + expect(result).toBe(`interface CreateApplicationEmailIntentInput { + recipient: string; + [k: string]: unknown; +} + +type CreateApplicationEmailIntentValue = unknown +interface CreateApplicationEmailIntentOutput { + success?: boolean; + [k: string]: unknown; +} + +interface CreateApplicationEmailIntentRequest { + action: "create"; + type: "application/email"; + data: CreateApplicationEmailIntentInput; + value?: CreateApplicationEmailIntentValue; +} + +type ShopifyGeneratedIntentVariants = + | import('@shopify/ui-extensions/admin').ShopifyGeneratedIntentVariant +`) + }) + + test('supports multiple intents with value schemas', async () => { + // Given + const intents = [ + { + action: 'create', + type: 'application/email', + inputSchema: { + type: 'object', + properties: { + recipient: {type: 'string'}, + }, + }, + }, + { + action: 'edit', + type: 'shopify/Product', + valueSchema: { + type: 'string', + }, + inputSchema: { + type: 'object', + properties: { + title: {type: 'string'}, + }, + }, + outputSchema: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + }, + ] + + // When + const result = await createIntentsTypeDefinition(intents, { + generatedTypesHelperImportPath: adminGeneratedTypesHelperImportPath, + }) + + // Then + expect(result).toContain('interface CreateApplicationEmailIntentRequest') + expect(result).toContain('type CreateApplicationEmailIntentOutput = unknown') + expect(result).toContain('interface EditShopifyProductIntentRequest') + expect(result).toContain('type EditShopifyProductIntentValue = string') + expect(result).not.toContain('ShopifyGeneratedIntentsApi') + expect(result).toContain( + "import('@shopify/ui-extensions/admin').ShopifyGeneratedIntentVariant", + ) + }) + + test('throws AbortError when intent action/type pairs are duplicated', async () => { + // Given + const intents = [ + { + action: 'create', + type: 'application/email', + inputSchema: {type: 'object'}, + }, + { + action: 'create', + type: 'application/email', + inputSchema: {type: 'object'}, + }, + ] + + // When & Then + await expect( + createIntentsTypeDefinition(intents, {generatedTypesHelperImportPath: adminGeneratedTypesHelperImportPath}), + ).rejects.toThrow( + new AbortError( + 'Intent "create:application/email" is defined multiple times. Intents must be unique within a target.', + ), + ) + }) +}) + describe('createToolsTypeDefinition', () => { test('returns empty string when tools array is empty', async () => { // When @@ -53,7 +210,7 @@ interface ShopifyTools { /** * Gets a product by ID */ - register(name: 'get_product', handler: (input: GetProductInput) => GetProductOutput | Promise); + register(name: 'get_product', handler: (input: GetProductInput) => GetProductOutput | Promise): () => void; } `) }) @@ -87,7 +244,7 @@ interface ShopifyTools { /** * A simple action */ - register(name: 'simple_action', handler: (input: SimpleActionInput) => SimpleActionOutput | Promise); + register(name: 'simple_action', handler: (input: SimpleActionInput) => SimpleActionOutput | Promise): () => void; } `) }) @@ -157,11 +314,11 @@ interface ShopifyTools { /** * First tool */ - register(name: 'tool_one', handler: (input: ToolOneInput) => ToolOneOutput | Promise); + register(name: 'tool_one', handler: (input: ToolOneInput) => ToolOneOutput | Promise): () => void; /** * Second tool */ - register(name: 'tool_two', handler: (input: ToolTwoInput) => ToolTwoOutput | Promise); + register(name: 'tool_two', handler: (input: ToolTwoInput) => ToolTwoOutput | Promise): () => void; } `) }) @@ -212,7 +369,7 @@ interface ShopifyTools { /** * This description contains *\\/ which could break comments */ - register(name: 'tool_with_special_desc', handler: (input: ToolWithSpecialDescInput) => ToolWithSpecialDescOutput | Promise); + register(name: 'tool_with_special_desc', handler: (input: ToolWithSpecialDescInput) => ToolWithSpecialDescOutput | Promise): () => void; } `) }) @@ -242,7 +399,7 @@ interface ShopifyTools { * Line two * Line three */ - register(name: 'documented_tool', handler: (input: DocumentedToolInput) => DocumentedToolOutput | Promise); + register(name: 'documented_tool', handler: (input: DocumentedToolInput) => DocumentedToolOutput | Promise): () => void; } `) }) @@ -274,7 +431,7 @@ interface ShopifyTools { /** * A tool with snake case name */ - register(name: 'my_snake_case_tool', handler: (input: MySnakeCaseToolInput) => MySnakeCaseToolOutput | Promise); + register(name: 'my_snake_case_tool', handler: (input: MySnakeCaseToolInput) => MySnakeCaseToolOutput | Promise): () => void; } `) }) @@ -345,7 +502,7 @@ interface ShopifyTools { /** * A tool with nested schema */ - register(name: 'complex_tool', handler: (input: ComplexToolInput) => ComplexToolOutput | Promise); + register(name: 'complex_tool', handler: (input: ComplexToolInput) => ComplexToolOutput | Promise): () => void; } `) }) @@ -373,11 +530,44 @@ interface ShopifyTools { /** * Gets product info */ - register(name: 'get-product-info', handler: (input: GetProductInfoInput) => GetProductInfoOutput | Promise); + register(name: 'get-product-info', handler: (input: GetProductInfoInput) => GetProductInfoOutput | Promise): () => void; } `) }) + test('renames types generated from schemas with titles to the requested generated name', async () => { + // Given + const tools = [ + { + name: 'expected_tool', + description: 'A tool with schema titles', + inputSchema: { + title: 'SchemaTitleInput', + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + outputSchema: { + title: 'SchemaTitleOutput', + type: 'object', + properties: { + success: {type: 'boolean'}, + }, + }, + }, + ] + + // When + const result = await createToolsTypeDefinition(tools) + + // Then + expect(result).toContain('interface ExpectedToolInput') + expect(result).toContain('interface ExpectedToolOutput') + expect(result).not.toContain('interface SchemaTitleInput') + expect(result).not.toContain('interface SchemaTitleOutput') + }) + test('does not include export declarations in the output', async () => { // Given // We need to ensure export declarations are stripped from the output since these types diff --git a/packages/app/src/cli/models/extensions/specifications/type-generation.ts b/packages/app/src/cli/models/extensions/specifications/type-generation.ts index e519bb0a737..79c82a83e94 100644 --- a/packages/app/src/cli/models/extensions/specifications/type-generation.ts +++ b/packages/app/src/cli/models/extensions/specifications/type-generation.ts @@ -15,6 +15,27 @@ async function loadTypeScript(): Promise { } const require = createRequire(import.meta.url) +const uiExtensionsPackage = '@shopify/ui-extensions' + +function getGeneratedTypesHelperSurface(target: string): string { + const domain = target.toLowerCase().replace(/(::|\.).+$/, '') + + switch (domain) { + case 'purchase': + return 'checkout' + case 'pos': + return 'point-of-sale' + default: + return domain + } +} + +export function getGeneratedTypesHelperImportPath(targets: string[]): string { + const target = targets[0] + if (!target) return uiExtensionsPackage + + return `${uiExtensionsPackage}/${getGeneratedTypesHelperSurface(target)}` +} export function parseApiVersion(apiVersion: string): {year: number; month: number} | null { const [year, month] = apiVersion.split('-') @@ -174,6 +195,12 @@ interface CreateTypeDefinitionOptions { targets: string[] apiVersion: string toolsTypeDefinition?: string + intentsTypeDefinition?: string +} + +interface ShopifyTypeOptions { + includesTools: boolean + includesIntents: boolean } /** @@ -218,23 +245,19 @@ async function targetExportsShopifyGlobal(targetDtsPath: string): Promise').Api & import('').ShopifyGlobal` so consumers * retain access to both the target's data surface and host-level APIs * (e.g. `shopify.addEventListener`). Otherwise emits just `.Api`. * * Returns null if no targets are provided. */ -async function buildShopifyType( +async function buildBaseShopifyType( targets: string[], resolvedTargetPaths: Map, - toolsTypeDefinition?: string, ): Promise { - const toolsSuffix = toolsTypeDefinition ? ' & { tools: ShopifyTools }' : '' - const typeForTarget = async (target: string): Promise => { const base = `import('@shopify/ui-extensions/${target}').Api` const dtsPath = resolvedTargetPaths.get(target) @@ -244,10 +267,50 @@ async function buildShopifyType( return base } - if (targets.length === 0) return null - if (targets.length === 1) return `${await typeForTarget(targets[0] ?? '')}${toolsSuffix}` - const typesForTargets = await Promise.all(targets.map(typeForTarget)) - return `(${typesForTargets.join(' | ')})${toolsSuffix}` + if (targets.length === 1) { + return typeForTarget(targets[0] ?? '') + } + + if (targets.length > 1) { + const typesForTargets = await Promise.all(targets.map(typeForTarget)) + return `(${typesForTargets.join(' | ')})` + } + + return null +} + +/** + * Builds the shopify API type based on targets and optional generated tool / intent types. + * + * Generated tools and intents are layered on top of the base target type via + * wrapper utility types. + * + * Returns null if no targets are provided. + */ +async function buildShopifyType( + targets: string[], + resolvedTargetPaths: Map, + {includesTools, includesIntents}: ShopifyTypeOptions, + generatedTypesHelperImportPath: string, +): Promise { + const baseShopifyType = await buildBaseShopifyType(targets, resolvedTargetPaths) + if (!baseShopifyType) return null + + if (!includesTools && !includesIntents) { + return baseShopifyType + } + + let shopifyType = baseShopifyType + + if (includesIntents) { + shopifyType = `import('${generatedTypesHelperImportPath}').WithGeneratedIntents<${shopifyType}, ShopifyGeneratedIntentVariants>` + } + + if (includesTools) { + shopifyType = `import('${generatedTypesHelperImportPath}').WithGeneratedTools<${shopifyType}, ShopifyTools>` + } + + return shopifyType } export async function createTypeDefinition({ @@ -256,9 +319,12 @@ export async function createTypeDefinition({ targets, apiVersion, toolsTypeDefinition, + intentsTypeDefinition, }: CreateTypeDefinitionOptions): Promise { try { const resolvedTargetPaths = new Map() + const includesTools = Boolean(toolsTypeDefinition) + const includesIntents = Boolean(intentsTypeDefinition) // Validate that all targets can be resolved, and capture the resolved .d.ts // path so buildShopifyType can inspect it for ShopifyGlobal exports. @@ -277,15 +343,34 @@ export async function createTypeDefinition({ } } - const relativePath = relativizePath(fullPath, dirname(typeFilePath)) + const generatedTypesHelperImportPath = getGeneratedTypesHelperImportPath(targets) + + if (includesTools || includesIntents) { + try { + require.resolve(generatedTypesHelperImportPath, {paths: [fullPath, typeFilePath]}) + } catch (_) { + const {year, month} = parseApiVersion(apiVersion) ?? {year: 2025, month: 10} + throw new AbortError( + `Type reference for ${generatedTypesHelperImportPath} could not be found. You might be using the wrong @shopify/ui-extensions version.`, + `Fix the error by ensuring you have the correct version of @shopify/ui-extensions, for example ~${year}.${month}.0, in your dependencies.`, + ) + } + } - const shopifyType = await buildShopifyType(targets, resolvedTargetPaths, toolsTypeDefinition) + const relativePath = relativizePath(fullPath, dirname(typeFilePath)) + const shopifyType = await buildShopifyType( + targets, + resolvedTargetPaths, + {includesTools, includesIntents}, + generatedTypesHelperImportPath, + ) if (!shopifyType) return null const lines = [ '//@ts-ignore', `declare module './${relativePath}' {`, - ...(toolsTypeDefinition ? [` ${toolsTypeDefinition}`] : []), + ...(toolsTypeDefinition ? [toolsTypeDefinition] : []), + ...(intentsTypeDefinition ? [intentsTypeDefinition] : []), ` const shopify: ${shopifyType};`, ' const globalThis: { shopify: typeof shopify };', '}', @@ -338,6 +423,90 @@ const ToolDefinitionSchema: zod.ZodType = zod.object({ export const ToolsFileSchema = zod.array(ToolDefinitionSchema) +interface IntentTypeDefinition { + action: string + type: string + inputSchema: object + valueSchema?: object + outputSchema?: object +} + +interface IntentSchemaFile { + value?: object + inputSchema: object + outputSchema?: object +} + +export const IntentSchemaFileSchema: zod.ZodType = zod.object({ + value: zod.object({}).passthrough().optional(), + inputSchema: zod.object({}).passthrough(), + outputSchema: zod.object({}).passthrough().optional(), +}) + +function intentTypeBaseName(intent: Pick): string { + return pascalize(`${intent.action} ${intent.type}`.replace(/[^a-zA-Z0-9]+/g, ' ')) +} + +/** + * Generates TypeScript types for shopify.intents.request and shopify.intents.response.ok + * based on intent schema definitions. + */ +export async function createIntentsTypeDefinition( + intents: IntentTypeDefinition[], + {generatedTypesHelperImportPath}: {generatedTypesHelperImportPath: string}, +): Promise { + if (intents.length === 0) return '' + + const intentKeys = new Set() + const typePromises = intents.map(async (intent) => { + const intentKey = `${intent.action}:${intent.type}` + if (intentKeys.has(intentKey)) { + throw new AbortError(`Intent "${intentKey}" is defined multiple times. Intents must be unique within a target.`) + } + intentKeys.add(intentKey) + + const typeBaseName = intentTypeBaseName(intent) + const inputTypeName = `${typeBaseName}IntentInput` + const valueTypeName = `${typeBaseName}IntentValue` + const outputTypeName = `${typeBaseName}IntentOutput` + const requestTypeName = `${typeBaseName}IntentRequest` + + const inputType = await formatJsonSchemaType(inputTypeName, intent.inputSchema) + const valueType = await formatJsonSchemaType(valueTypeName, intent.valueSchema) + const outputType = await formatJsonSchemaType(outputTypeName, intent.outputSchema) + + const requestType = `interface ${requestTypeName} { + action: ${JSON.stringify(intent.action)}; + type: ${JSON.stringify(intent.type)}; + data: ${inputTypeName}; + value?: ${valueTypeName}; +}` + + return { + inputType, + valueType, + outputType, + requestType, + requestTypeName, + outputTypeName, + } + }) + + const types = await Promise.all(typePromises) + + const generatedIntents = types + .map(({requestTypeName, outputTypeName}) => { + return ` | import('${generatedTypesHelperImportPath}').ShopifyGeneratedIntentVariant<${requestTypeName}, ${outputTypeName}>` + }) + .join('\n') + + return `${types + .map( + ({inputType, valueType, outputType, requestType}) => `${inputType}\n${valueType}\n${outputType}\n${requestType}`, + ) + .join('\n\n')}\n\ntype ShopifyGeneratedIntentVariants =\n${generatedIntents}\n` +} + /** * Generates TypeScript types for shopify.tools.register based on tool definitions * @param tools - Array of tool definitions from tools.json @@ -383,7 +552,7 @@ export async function createToolsTypeDefinition(tools: ToolDefinition[]): Promis .split('\n') .map((line) => ` * ${line}`) .join('\n') - return ` /**\n${formattedDescription}\n */\n register(name: '${name}', handler: (input: ${inputTypeName}) => ${outputTypeName} | Promise<${outputTypeName}>);` + return ` /**\n${formattedDescription}\n */\n register(name: '${name}', handler: (input: ${inputTypeName}) => ${outputTypeName} | Promise<${outputTypeName}>): () => void;` }) .join('\n') @@ -392,8 +561,15 @@ export async function createToolsTypeDefinition(tools: ToolDefinition[]): Promis .join('\n')}\ninterface ShopifyTools {\n${toolRegistrations}\n}\n` } +function renameGeneratedType(typeDefinition: string, name: string): string { + return typeDefinition.replace(/^(interface|type|enum)\s+[A-Za-z0-9_]+/, `$1 ${name}`) +} + async function formatJsonSchemaType(name: string, schema?: object): Promise { - const outputType = schema ? await compile(schema, name, {bannerComment: ''}) : `type ${name} = unknown` - // The json-schema-to-typescript library adds an export keyword to the type definition, we need to remove it - return outputType.startsWith('export ') ? outputType.slice(7) : outputType + if (!schema) return `type ${name} = unknown` + + const outputType = await compile(schema, name, {bannerComment: ''}) + const normalizedOutputType = outputType.startsWith('export ') ? outputType.slice(7) : outputType + + return renameGeneratedType(normalizedOutputType, name) } 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 1b2d15522da..3d5d5f6f1a1 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 @@ -10,6 +10,8 @@ import {err, ok} from '@shopify/cli-kit/node/result' import {zod} from '@shopify/cli-kit/node/schema' import {describe, expect, test, vi} from 'vitest' import {AbortError} from '@shopify/cli-kit/node/error' +import * as output from '@shopify/cli-kit/node/output' +import type {NewExtensionPointSchemaType} from '../schemas.js' describe('ui_extension', async () => { interface GetUIExtensionProps { @@ -1245,6 +1247,19 @@ Please check the configuration in ${uiExtension.configurationPath}`), }) }) + interface TestUIExtensionPoint { + target: string + module: string + tools?: string + intents?: NonNullable + build_manifest: { + assets: { + main: {module: string} + should_render?: {module: string} + } + } + } + async function setupUIExtensionWithNodeModules({ tmpDir, fileContent, @@ -1271,6 +1286,14 @@ Please check the configuration in ${uiExtension.configurationPath}`), const nodeModulesPath = joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions') await mkdir(nodeModulesPath) + await Promise.all( + ['admin', 'checkout', 'point-of-sale', 'customer-account'].map(async (generatedTypesHelperSurface) => { + const generatedTypesHelperPath = joinPath(nodeModulesPath, generatedTypesHelperSurface) + await mkdir(generatedTypesHelperPath) + await writeFile(joinPath(generatedTypesHelperPath, 'index.js'), '// Mock generated types helper exports') + }), + ) + const targetPath = joinPath(nodeModulesPath, target) await mkdir(targetPath) // `require.resolve('@shopify/ui-extensions/')` resolves to this file, @@ -1296,27 +1319,29 @@ Please check the configuration in ${uiExtension.configurationPath}`), const allSpecs = await loadLocalExtensionsSpecifications() const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! + const extensionPoints: TestUIExtensionPoint[] = [ + { + target, + module: `./src/index.jsx`, + build_manifest: { + assets: { + main: { + module: './src/index.jsx', + }, + should_render: shouldRenderFilePath + ? { + module: './src/condition/should-render.js', + } + : undefined, + }, + }, + }, + ] + const extension = new ExtensionInstance({ configuration: { api_version: apiVersion, - extension_points: [ - { - target, - module: `./src/index.jsx`, - build_manifest: { - assets: { - main: { - module: './src/index.jsx', - }, - should_render: shouldRenderFilePath - ? { - module: './src/condition/should-render.js', - } - : undefined, - }, - }, - }, - ], + extension_points: extensionPoints, name: 'Test UI Extension', type: 'ui_extension', metafields: [], @@ -2253,7 +2278,7 @@ Please check the configuration in ${uiExtension.configurationPath}`), await writeFile(joinPath(tmpDir, 'tools.json'), toolsContent) // Update extension configuration to include tools - ;(extension.configuration.extension_points[0] as any).tools = './tools.json' + extension.configuration.extension_points[0]!.tools = './tools.json' // Create tsconfig.json await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') @@ -2271,7 +2296,8 @@ Please check the configuration in ${uiExtension.configurationPath}`), expect(typeDefinition).toContain('interface SearchProductsInput') expect(typeDefinition).toContain('interface SearchProductsOutput') expect(typeDefinition).toContain("name: 'search_products'") - expect(typeDefinition).toContain('tools: ShopifyTools') + expect(typeDefinition).toContain("import('@shopify/ui-extensions/admin').WithGeneratedTools<") + expect(typeDefinition).not.toContain('interface GeneratedToolsConstraint') }) }) @@ -2321,7 +2347,7 @@ Please check the configuration in ${uiExtension.configurationPath}`), await writeFile(joinPath(tmpDir, 'tools.json'), toolsContent) // Update extension configuration to include tools - ;(extension.configuration.extension_points[0] as any).tools = './tools.json' + extension.configuration.extension_points[0]!.tools = './tools.json' // Create tsconfig.json await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') @@ -2356,7 +2382,7 @@ Please check the configuration in ${uiExtension.configurationPath}`), }) // Update extension configuration to reference a non-existent tools file - ;(extension.configuration.extension_points[0] as any).tools = './non-existent-tools.json' + extension.configuration.extension_points[0]!.tools = './non-existent-tools.json' // Create tsconfig.json await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') @@ -2389,7 +2415,7 @@ Please check the configuration in ${uiExtension.configurationPath}`), await writeFile(joinPath(tmpDir, 'tools.json'), 'not valid json {{{') // Update extension configuration to include tools - ;(extension.configuration.extension_points[0] as any).tools = './tools.json' + extension.configuration.extension_points[0]!.tools = './tools.json' // Create tsconfig.json await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') @@ -2427,7 +2453,7 @@ Please check the configuration in ${uiExtension.configurationPath}`), await writeFile(joinPath(tmpDir, 'tools.json'), invalidToolsContent) // Update extension configuration to include tools - ;(extension.configuration.extension_points[0] as any).tools = './tools.json' + extension.configuration.extension_points[0]!.tools = './tools.json' // Create tsconfig.json await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') @@ -2474,7 +2500,7 @@ Please check the configuration in ${uiExtension.configurationPath}`), await writeFile(joinPath(tmpDir, 'tools.json'), toolsContent) // Update extension configuration to include tools - ;(extension.configuration.extension_points[0] as any).tools = './tools.json' + extension.configuration.extension_points[0]!.tools = './tools.json' // Create tsconfig.json await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') @@ -2491,12 +2517,310 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Entry point should have ShopifyTools const entryPointType = types.find((t) => t.includes('./src/index.jsx')) expect(entryPointType).toContain('ShopifyTools') - expect(entryPointType).toContain('tools: ShopifyTools') + expect(entryPointType).toContain("import('@shopify/ui-extensions/admin').WithGeneratedTools<") // Imported file should NOT have ShopifyTools const helperType = types.find((t) => t.includes('./src/utils/helper.js')) expect(helperType).not.toContain('ShopifyTools') }) }) + + test('generates shopify.d.ts with generated intent request and response types when intent schema is present', async () => { + const typeDefinitionsByFile = new Map>() + + await inTemporaryDirectory(async (tmpDir) => { + const {extension} = await setupUIExtensionWithNodeModules({ + tmpDir, + fileContent: '// Extension code', + apiVersion: '2025-10', + target: 'admin.app.intent.render', + }) + + const intentSchemaContent = JSON.stringify({ + inputSchema: { + type: 'object', + properties: { + recipient: {type: 'string'}, + }, + required: ['recipient'], + }, + outputSchema: { + type: 'object', + properties: { + success: {type: 'boolean'}, + }, + }, + }) + await writeFile(joinPath(tmpDir, 'intent-schema.json'), intentSchemaContent) + extension.configuration.extension_points[0]!.intents = [ + { + action: 'create', + type: 'application/email', + schema: './intent-schema.json', + }, + ] + + await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') + + // When + await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) + + const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') + const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + + // Then + expect(types).toHaveLength(1) + const typeDefinition = types[0]! + expect(typeDefinition).toContain('interface CreateApplicationEmailIntentInput') + expect(typeDefinition).toContain('interface CreateApplicationEmailIntentRequest') + expect(typeDefinition).toContain(`action: 'create';`) + expect(typeDefinition).toContain(`type: 'application/email';`) + expect(typeDefinition).not.toContain('interface ShopifyGeneratedIntentResponse') + expect(typeDefinition).not.toContain('interface ShopifyGeneratedIntentsApi<') + expect(typeDefinition).toContain('type ShopifyGeneratedIntentVariants =') + expect(typeDefinition).toContain("import('@shopify/ui-extensions/admin').ShopifyGeneratedIntentVariant<") + expect(typeDefinition).toContain('CreateApplicationEmailIntentRequest') + expect(typeDefinition).toContain('CreateApplicationEmailIntentOutput') + expect(typeDefinition).toContain("import('@shopify/ui-extensions/admin').WithGeneratedIntents<") + }) + }) + + test('uses the target surface package for generated helper types', async () => { + const typeDefinitionsByFile = new Map>() + + await inTemporaryDirectory(async (tmpDir) => { + const {extension} = await setupUIExtensionWithNodeModules({ + tmpDir, + fileContent: '// Extension code', + apiVersion: '2025-10', + target: 'purchase.checkout.block.render', + }) + + const toolsContent = JSON.stringify([ + { + name: 'my_tool', + description: 'A tool', + inputSchema: {type: 'object'}, + }, + ]) + await writeFile(joinPath(tmpDir, 'tools.json'), toolsContent) + extension.configuration.extension_points[0]!.tools = './tools.json' + + await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') + + // When + await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) + + const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') + const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + + // Then + expect(types).toHaveLength(1) + const typeDefinition = types[0]! + expect(typeDefinition).toContain("import('@shopify/ui-extensions/checkout').WithGeneratedTools<") + expect(typeDefinition).not.toContain("import('@shopify/ui-extensions/admin').WithGeneratedTools<") + }) + }) + + test('warns and skips intent type generation when the schema file is missing', async () => { + const typeDefinitionsByFile = new Map>() + const outputWarnSpy = vi.spyOn(output, 'outputWarn').mockImplementation(() => {}) + + try { + await inTemporaryDirectory(async (tmpDir) => { + const {extension} = await setupUIExtensionWithNodeModules({ + tmpDir, + fileContent: '// Extension code', + apiVersion: '2025-10', + target: 'admin.app.intent.render', + }) + + extension.configuration.extension_points[0]!.intents = [ + { + action: 'create', + type: 'application/email', + schema: './missing-intent-schema.json', + }, + ] + + await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') + + // When + await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) + + const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') + const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + + // Then + expect(outputWarnSpy).toHaveBeenCalledWith( + 'Intent schema file "./missing-intent-schema.json" was not found. Skipping intent type generation.', + ) + expect(types).toHaveLength(1) + expect(types[0]).not.toContain('ShopifyGeneratedIntentVariants') + }) + } finally { + outputWarnSpy.mockRestore() + } + }) + + test('throws when intent action/type pairs are duplicated for an entry point', async () => { + const typeDefinitionsByFile = new Map>() + + await inTemporaryDirectory(async (tmpDir) => { + const {extension} = await setupUIExtensionWithNodeModules({ + tmpDir, + fileContent: '// Extension code', + apiVersion: '2025-10', + target: 'admin.app.intent.render', + }) + + const intentSchemaContent = JSON.stringify({ + inputSchema: { + type: 'object', + }, + }) + await writeFile(joinPath(tmpDir, 'intent-schema.json'), intentSchemaContent) + extension.configuration.extension_points[0]!.intents = [ + { + action: 'create', + type: 'application/email', + schema: './intent-schema.json', + }, + { + action: 'create', + type: 'application/email', + schema: './intent-schema.json', + }, + ] + + await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') + + // When/Then + await expect(extension.contributeToSharedTypeFile?.(typeDefinitionsByFile)).rejects.toThrow( + new AbortError( + 'Intent "create:application/email" is defined multiple times. Intents must be unique within a target.', + ), + ) + }) + }) + + test('generates intent types only for entry point file, not for imported files', async () => { + const typeDefinitionsByFile = new Map>() + + await inTemporaryDirectory(async (tmpDir) => { + const {extension} = await setupUIExtensionWithNodeModules({ + tmpDir, + fileContent: ` + import './utils/helper.js'; + // Main extension code + `, + apiVersion: '2025-10', + target: 'admin.app.intent.render', + }) + + const utilsDir = joinPath(tmpDir, 'src', 'utils') + await mkdir(utilsDir) + await writeFile(joinPath(utilsDir, 'helper.js'), 'export const helper = () => {};') + + const intentSchemaContent = JSON.stringify({ + inputSchema: { + type: 'object', + properties: { + recipient: {type: 'string'}, + }, + }, + }) + await writeFile(joinPath(tmpDir, 'intent-schema.json'), intentSchemaContent) + extension.configuration.extension_points[0]!.intents = [ + { + action: 'create', + type: 'application/email', + schema: './intent-schema.json', + }, + ] + + await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') + + // When + await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) + + const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') + const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + + // Then - should have 2 type definitions (entry point and helper) + expect(types).toHaveLength(2) + + const entryPointType = types.find((t) => t.includes('./src/index.jsx')) + expect(entryPointType).toContain('ShopifyGeneratedIntentVariants') + expect(entryPointType).toContain('CreateApplicationEmailIntentRequest') + expect(entryPointType).toContain("import('@shopify/ui-extensions/admin').WithGeneratedIntents<") + + const helperType = types.find((t) => t.includes('./src/utils/helper.js')) + expect(helperType).not.toContain('ShopifyGeneratedIntentVariants') + expect(helperType).not.toContain('CreateApplicationEmailIntentRequest') + }) + }) + + test('generates intent types from an intent schema file that declares a value schema', async () => { + const typeDefinitionsByFile = new Map>() + + await inTemporaryDirectory(async (tmpDir) => { + const {extension} = await setupUIExtensionWithNodeModules({ + tmpDir, + fileContent: '// Extension code', + apiVersion: '2025-10', + target: 'admin.app.intent.render', + }) + + // Given an intent schema file that declares a root-level `value` schema + const intentSchemaContent = JSON.stringify({ + value: { + type: 'object', + properties: { + productId: {type: 'string'}, + }, + required: ['productId'], + }, + inputSchema: { + type: 'object', + properties: { + title: {type: 'string'}, + }, + }, + outputSchema: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + }) + await writeFile(joinPath(tmpDir, 'intent-schema.json'), intentSchemaContent) + extension.configuration.extension_points[0]!.intents = [ + { + action: 'edit', + type: 'shopify/Product', + schema: './intent-schema.json', + }, + ] + + await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') + + // When + await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) + + const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') + const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + + // Then - the value schema is compiled into EditShopifyProductIntentValue + // and wired through the request type. + expect(types).toHaveLength(1) + const typeDefinition = types[0]! + expect(typeDefinition).toContain('interface EditShopifyProductIntentValue') + expect(typeDefinition).toContain('productId: string;') + expect(typeDefinition).toContain('value?: EditShopifyProductIntentValue;') + // Sanity: the value type is not the `unknown` fallback used when no schema is provided. + expect(typeDefinition).not.toContain('type EditShopifyProductIntentValue = unknown') + }) + }) }) }) 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 e2a33fda99c..50fc1212d2a 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -1,9 +1,12 @@ import { findAllImportedFiles, + createIntentsTypeDefinition, createTypeDefinition, + createToolsTypeDefinition, findNearestTsConfigDir, + getGeneratedTypesHelperImportPath, + IntentSchemaFileSchema, parseApiVersion, - createToolsTypeDefinition, ToolsFileSchema, } from './type-generation.js' import {Asset, AssetIdentifier, BuildAsset, ExtensionFeature, createExtensionSpecification} from '../specification.js' @@ -17,6 +20,7 @@ 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' +import {AbortError} from '@shopify/cli-kit/node/error' const dependency = '@shopify/checkout-ui-extensions' @@ -32,6 +36,8 @@ export interface BuildManifest { } } +type GeneratedIntentTypeDefinition = Parameters[0][number] + const missingExtensionPointsMessage = 'No extension targets defined, add a `targeting` field to your configuration' type UIExtensionConfigType = zod.infer @@ -202,6 +208,7 @@ const uiExtensionSpec = createExtensionSpecification({ // Track all files and their associated targets const fileToTargetsMap = new Map() const fileToToolsMap = new Map() + const fileToIntentsMap = new Map>() // First pass: collect all entry point files and their targets for await (const extensionPoint of configuration.extension_points) { @@ -218,6 +225,12 @@ const uiExtensionSpec = createExtensionSpecification({ if (extensionPoint.tools) { fileToToolsMap.set(fullPath, extensionPoint.tools) } + // Add intent schema files if present + if (extensionPoint.intents?.length) { + const currentIntents: NonNullable = fileToIntentsMap.get(fullPath) ?? [] + currentIntents.push(...extensionPoint.intents) + fileToIntentsMap.set(fullPath, currentIntents) + } // Add should render module if present if (extensionPoint.build_manifest.assets[AssetIdentifier.ShouldRender]?.module) { const shouldRenderPath = joinPath( @@ -276,27 +289,18 @@ const uiExtensionSpec = createExtensionSpecification({ // Remove duplicates from targets const uniqueTargets = [...new Set(targets)] + const generatedTypesHelperImportPath = getGeneratedTypesHelperImportPath(uniqueTargets) try { const toolsDefinition = fileToToolsMap.get(filePath) let toolsTypeDefinition = '' if (toolsDefinition) { try { - const toolsFilePath = joinPath(extension.directory, toolsDefinition) - if (await fileExists(toolsFilePath)) { - // Read and parse the tools JSON file - const toolsContent = await readFile(toolsFilePath) - const tools = ToolsFileSchema.safeParse(JSON.parse(toolsContent)) - if (tools.success) { - // Generate tools type definition - toolsTypeDefinition = await createToolsTypeDefinition(tools.data) - } else { - outputWarn( - `Invalid tools definition in "${toolsDefinition}": ${tools.error.issues - .map((issue) => issue.message) - .join(', ')}`, - ) - } + const tools = await readAndValidateJsonAsset(extension.directory, toolsDefinition, ToolsFileSchema) + if (tools.status === 'ok') { + toolsTypeDefinition = await createToolsTypeDefinition(tools.data) + } else if (tools.status === 'invalid') { + outputWarn(`Invalid tools definition in "${toolsDefinition}": ${tools.issues}`) } // eslint-disable-next-line no-catch-all/no-catch-all } catch (error) { @@ -307,12 +311,34 @@ const uiExtensionSpec = createExtensionSpecification({ ) } } + + const intentsDefinitions = fileToIntentsMap.get(filePath) + let intentsTypeDefinition = '' + if (intentsDefinitions?.length) { + const parsedIntents = await parseIntentTypeDefinitions(extension.directory, intentsDefinitions) + + if (parsedIntents.length > 0) { + try { + intentsTypeDefinition = await createIntentsTypeDefinition(parsedIntents, {generatedTypesHelperImportPath}) + } catch (error) { + if (error instanceof AbortError) throw error + + outputWarn( + `Failed to create intent type definition for intent schema files "${intentsDefinitions + .map((intent) => intent.schema) + .join(', ')}": ${error instanceof Error ? error.message : 'Unknown error'}`, + ) + } + } + } + let typeDefinition = await createTypeDefinition({ fullPath: filePath, typeFilePath, targets: uniqueTargets, apiVersion: configuration.api_version, toolsTypeDefinition, + intentsTypeDefinition, }) if (typeDefinition) { const currentTypes = typeDefinitionsByFile.get(typeFilePath) ?? new Set() @@ -357,6 +383,76 @@ function addDistPathToAssets(extP: NewExtensionPointSchemaType & {build_manifest } } +type JsonAssetResult = {status: 'ok'; data: T} | {status: 'missing'} | {status: 'invalid'; issues: string} + +async function readAndValidateJsonAsset( + extensionDirectory: string, + relativePath: string, + schema: zod.ZodType, +): Promise> { + const filePath = joinPath(extensionDirectory, relativePath) + const exists = await fileExists(filePath) + if (!exists) return {status: 'missing'} + + const content = await readFile(filePath) + const parsed = schema.safeParse(JSON.parse(content)) + if (!parsed.success) { + return { + status: 'invalid', + issues: parsed.error.issues.map((issue) => issue.message).join(', '), + } + } + + return {status: 'ok', data: parsed.data} +} + +async function parseIntentTypeDefinitions( + extensionDirectory: string, + intents: NonNullable, +): Promise { + const parsedIntentDefinitions = await Promise.all( + intents.map(async (intent) => { + try { + const intentSchema = await readAndValidateJsonAsset(extensionDirectory, intent.schema, IntentSchemaFileSchema) + if (intentSchema.status === 'missing') { + outputWarn(`Intent schema file "${intent.schema}" was not found. Skipping intent type generation.`) + return null + } + + if (intentSchema.status === 'invalid') { + outputWarn(`Invalid intent schema in "${intent.schema}": ${intentSchema.issues}`) + return null + } + + return { + action: intent.action, + type: intent.type, + inputSchema: intentSchema.data.inputSchema, + valueSchema: intentSchema.data.value, + outputSchema: intentSchema.data.outputSchema, + } + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + outputWarn( + `Failed to create intent type definition for intent schema file "${intent.schema}": ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ) + return null + } + }), + ) + + const parsedIntents: GeneratedIntentTypeDefinition[] = [] + for (const parsedIntentDefinition of parsedIntentDefinitions) { + if (parsedIntentDefinition) { + parsedIntents.push(parsedIntentDefinition) + } + } + + return parsedIntents +} + async function checkForMissingPath( directory: string, assetModule: string | undefined,