From bc9ca2ee42ed2e2397f9631a181d9df710e68727 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Fri, 17 Apr 2026 14:50:30 +0200 Subject: [PATCH 1/6] Generate types for ui-extension intents --- .../specifications/type-generation.test.ts | 136 +++++++++++- .../specifications/type-generation.ts | 198 ++++++++++++++++-- .../specifications/ui_extension.test.ts | 116 +++++++++- .../extensions/specifications/ui_extension.ts | 86 +++++++- 4 files changed, 517 insertions(+), 19 deletions(-) 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..35584fb5532 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,141 @@ -import {createToolsTypeDefinition} from './type-generation.js' +import {createIntentsTypeDefinition, createToolsTypeDefinition} from './type-generation.js' import {AbortError} from '@shopify/cli-kit/node/error' import {describe, expect, test} from 'vitest' +describe('createIntentsTypeDefinition', () => { + test('returns empty string when intents array is empty', async () => { + // When + const result = await createIntentsTypeDefinition([]) + + // 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) + + // 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 ShopifyGeneratedIntentResponse = { + ok(data?: Data): Promise; +} + +type ShopifyGeneratedIntentsApi = + | { + request: CreateApplicationEmailIntentRequest; + response?: ShopifyGeneratedIntentResponse; + } +`) + }) + + 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) + + // 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).toContain('response?: ShopifyGeneratedIntentResponse;') + }) + + 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)).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 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..34b464e32de 100644 --- a/packages/app/src/cli/models/extensions/specifications/type-generation.ts +++ b/packages/app/src/cli/models/extensions/specifications/type-generation.ts @@ -174,6 +174,12 @@ interface CreateTypeDefinitionOptions { targets: string[] apiVersion: string toolsTypeDefinition?: string + intentsTypeDefinition?: string +} + +interface ShopifyTypeOptions { + includesTools: boolean + includesIntents: boolean } /** @@ -218,23 +224,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 +246,74 @@ 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, +): Promise { + const baseShopifyType = await buildBaseShopifyType(targets, resolvedTargetPaths) + if (!baseShopifyType) return null + + if (!includesTools && !includesIntents) { + return baseShopifyType + } + + const wrappers = [ + ...(includesIntents ? ['WithGeneratedIntents'] : []), + ...(includesTools ? ['WithGeneratedTools'] : []), + ] + + return wrappers.reduce((shopifyType, wrapper) => `${wrapper}<${shopifyType}>`, baseShopifyType) +} + +function buildShopifyUtilityTypes({includesTools, includesIntents}: ShopifyTypeOptions): string { + const utilityTypes: string[] = [] + + if (includesTools) { + utilityTypes.push(`type WithGeneratedTools = T extends {tools?: infer Tools} + ? Omit & {tools: Omit, 'register'> & ShopifyTools} + : T & {tools: ShopifyTools}`) + } + + if (includesIntents) { + utilityTypes.push(`type MergeGeneratedIntentResponse = ShopifyGeneratedIntentsApi extends infer Generated + ? Generated extends {response?: infer GeneratedResponse} + ? Omit & { + response?: Intents extends {response?: infer BaseResponse} + ? Omit, 'ok'> & NonNullable + : NonNullable + } + : Generated + : never`) + + utilityTypes.push(`type WithGeneratedIntents = T extends {intents?: infer Intents} + ? Omit & { + intents: Omit, 'request' | 'response'> & MergeGeneratedIntentResponse> + } + : T & {intents: ShopifyGeneratedIntentsApi}`) + } + + return utilityTypes.join('\n\n') } export async function createTypeDefinition({ @@ -256,6 +322,7 @@ export async function createTypeDefinition({ targets, apiVersion, toolsTypeDefinition, + intentsTypeDefinition, }: CreateTypeDefinitionOptions): Promise { try { const resolvedTargetPaths = new Map() @@ -278,14 +345,20 @@ export async function createTypeDefinition({ } const relativePath = relativizePath(fullPath, dirname(typeFilePath)) + const includesTools = Boolean(toolsTypeDefinition) + const includesIntents = Boolean(intentsTypeDefinition) - const shopifyType = await buildShopifyType(targets, resolvedTargetPaths, toolsTypeDefinition) + const shopifyType = await buildShopifyType(targets, resolvedTargetPaths, {includesTools, includesIntents}) if (!shopifyType) return null + const shopifyUtilityTypes = buildShopifyUtilityTypes({includesTools, includesIntents}) + const lines = [ '//@ts-ignore', `declare module './${relativePath}' {`, - ...(toolsTypeDefinition ? [` ${toolsTypeDefinition}`] : []), + ...(toolsTypeDefinition ? [toolsTypeDefinition] : []), + ...(intentsTypeDefinition ? [intentsTypeDefinition] : []), + ...(shopifyUtilityTypes ? [shopifyUtilityTypes] : []), ` const shopify: ${shopifyType};`, ' const globalThis: { shopify: typeof shopify };', '}', @@ -338,6 +411,92 @@ 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[]): 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 ` | { + request: ${requestTypeName}; + response?: ShopifyGeneratedIntentResponse<${outputTypeName}>; + }` + }) + .join('\n') + + return `${types + .map( + ({inputType, valueType, outputType, requestType}) => `${inputType}\n${valueType}\n${outputType}\n${requestType}`, + ) + .join('\n\n')}\n\ntype ShopifyGeneratedIntentResponse = { + ok(data?: Data): Promise; +}\n\ntype ShopifyGeneratedIntentsApi =\n${generatedIntents}\n` +} + /** * Generates TypeScript types for shopify.tools.register based on tool definitions * @param tools - Array of tool definitions from tools.json @@ -392,8 +551,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..9ec091e820a 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 @@ -2271,7 +2271,7 @@ 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("tools: Omit, 'register'> & ShopifyTools") }) }) @@ -2498,5 +2498,119 @@ Please check the configuration in ${uiExtension.configurationPath}`), 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] as any).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).toContain( + 'response?: ShopifyGeneratedIntentResponse;', + ) + expect(typeDefinition).toContain('type ShopifyGeneratedIntentsApi =') + expect(typeDefinition).toContain('WithGeneratedIntents<') + }) + }) + + 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] as any).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('ShopifyGeneratedIntentsApi') + expect(entryPointType).toContain('CreateApplicationEmailIntentRequest') + + const helperType = types.find((t) => t.includes('./src/utils/helper.js')) + expect(helperType).not.toContain('ShopifyGeneratedIntentsApi') + expect(helperType).not.toContain('CreateApplicationEmailIntentRequest') + }) + }) }) }) 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..179eb874048 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,11 @@ import { findAllImportedFiles, + createIntentsTypeDefinition, createTypeDefinition, + createToolsTypeDefinition, findNearestTsConfigDir, + IntentSchemaFileSchema, parseApiVersion, - createToolsTypeDefinition, ToolsFileSchema, } from './type-generation.js' import {Asset, AssetIdentifier, BuildAsset, ExtensionFeature, createExtensionSpecification} from '../specification.js' @@ -32,6 +34,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 +206,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 +223,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( @@ -307,12 +318,33 @@ 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) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (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 +389,58 @@ function addDistPathToAssets(extP: NewExtensionPointSchemaType & {build_manifest } } +async function parseIntentTypeDefinitions( + extensionDirectory: string, + intents: NonNullable, +): Promise { + const parsedIntentDefinitions = await Promise.all( + intents.map(async (intent) => { + try { + const intentSchemaFilePath = joinPath(extensionDirectory, intent.schema) + const schemaExists = await fileExists(intentSchemaFilePath) + if (!schemaExists) return null + + const intentSchemaContent = await readFile(intentSchemaFilePath) + const intentSchema = IntentSchemaFileSchema.safeParse(JSON.parse(intentSchemaContent)) + + if (!intentSchema.success) { + outputWarn( + `Invalid intent schema in "${intent.schema}": ${intentSchema.error.issues + .map((issue) => issue.message) + .join(', ')}`, + ) + 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, From f7dfedd8e56094deac3e7a72a45ccd6a9ea17330 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Fri, 17 Apr 2026 14:59:03 +0200 Subject: [PATCH 2/6] Formatting --- .../specifications/type-generation.test.ts | 19 ++-- .../specifications/type-generation.ts | 85 +++++++++++----- .../specifications/ui_extension.test.ts | 96 ++++++++++++++----- 3 files changed, 144 insertions(+), 56 deletions(-) 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 35584fb5532..923a4c91ff6 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 @@ -55,15 +55,17 @@ interface CreateApplicationEmailIntentRequest { value?: CreateApplicationEmailIntentValue; } -type ShopifyGeneratedIntentResponse = { +interface ShopifyGeneratedIntentResponse { ok(data?: Data): Promise; } -type ShopifyGeneratedIntentsApi = - | { - request: CreateApplicationEmailIntentRequest; - response?: ShopifyGeneratedIntentResponse; - } +interface ShopifyGeneratedIntentsApi { + request: Request; + response?: ShopifyGeneratedIntentResponse; +} + +type ShopifyGeneratedIntentVariants = + | ShopifyGeneratedIntentsApi `) }) @@ -109,7 +111,10 @@ type ShopifyGeneratedIntentsApi = expect(result).toContain('type CreateApplicationEmailIntentOutput = unknown') expect(result).toContain('interface EditShopifyProductIntentRequest') expect(result).toContain('type EditShopifyProductIntentValue = string') - expect(result).toContain('response?: ShopifyGeneratedIntentResponse;') + expect(result).toContain('response?: ShopifyGeneratedIntentResponse;') + expect(result).toContain( + 'ShopifyGeneratedIntentsApi', + ) }) test('throws AbortError when intent action/type pairs are duplicated', async () => { 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 34b464e32de..8ed0dec0a8c 100644 --- a/packages/app/src/cli/models/extensions/specifications/type-generation.ts +++ b/packages/app/src/cli/models/extensions/specifications/type-generation.ts @@ -290,27 +290,62 @@ function buildShopifyUtilityTypes({includesTools, includesIntents}: ShopifyTypeO const utilityTypes: string[] = [] if (includesTools) { - utilityTypes.push(`type WithGeneratedTools = T extends {tools?: infer Tools} - ? Omit & {tools: Omit, 'register'> & ShopifyTools} - : T & {tools: ShopifyTools}`) + utilityTypes.push(`interface GeneratedToolsConstraint { + tools?: Tools; +} + +interface GeneratedToolsOverride { + tools: Omit, 'register'> & ShopifyTools; +} + +interface GeneratedToolsFallback { + tools: ShopifyTools; +} + +type WithGeneratedTools = T extends GeneratedToolsConstraint + ? Omit & GeneratedToolsOverride + : T & GeneratedToolsFallback;`) } if (includesIntents) { - utilityTypes.push(`type MergeGeneratedIntentResponse = ShopifyGeneratedIntentsApi extends infer Generated - ? Generated extends {response?: infer GeneratedResponse} - ? Omit & { - response?: Intents extends {response?: infer BaseResponse} - ? Omit, 'ok'> & NonNullable - : NonNullable - } - : Generated - : never`) + utilityTypes.push(`interface GeneratedIntentResponseConstraint { + response?: Response; +} - utilityTypes.push(`type WithGeneratedIntents = T extends {intents?: infer Intents} - ? Omit & { - intents: Omit, 'request' | 'response'> & MergeGeneratedIntentResponse> - } - : T & {intents: ShopifyGeneratedIntentsApi}`) +interface GeneratedIntentResponseOverride { + response?: Omit, 'ok'> & NonNullable; +} + +interface GeneratedIntentResponseFallback { + response?: NonNullable; +} + +interface GeneratedIntentsConstraint { + intents?: Intents; +} + +interface GeneratedIntentsOverride { + intents: Omit, 'request' | 'response'> & + MergeGeneratedIntentResponse>; +} + +interface GeneratedIntentsFallback { + intents: ShopifyGeneratedIntentVariants; +} + +type MergeGeneratedIntentResponse = + ShopifyGeneratedIntentVariants extends infer Generated + ? Generated extends GeneratedIntentResponseConstraint + ? Omit & + (Intents extends GeneratedIntentResponseConstraint + ? GeneratedIntentResponseOverride + : GeneratedIntentResponseFallback) + : Generated + : never;`) + + utilityTypes.push(`type WithGeneratedIntents = T extends GeneratedIntentsConstraint + ? Omit & GeneratedIntentsOverride + : T & GeneratedIntentsFallback;`) } return utilityTypes.join('\n\n') @@ -360,7 +395,9 @@ export async function createTypeDefinition({ ...(intentsTypeDefinition ? [intentsTypeDefinition] : []), ...(shopifyUtilityTypes ? [shopifyUtilityTypes] : []), ` const shopify: ${shopifyType};`, - ' const globalThis: { shopify: typeof shopify };', + ' const globalThis: {', + ' shopify: typeof shopify;', + ' };', '}', '', ] @@ -481,10 +518,7 @@ export async function createIntentsTypeDefinition(intents: IntentTypeDefinition[ const generatedIntents = types .map(({requestTypeName, outputTypeName}) => { - return ` | { - request: ${requestTypeName}; - response?: ShopifyGeneratedIntentResponse<${outputTypeName}>; - }` + return ` | ShopifyGeneratedIntentsApi<${requestTypeName}, ${outputTypeName}>` }) .join('\n') @@ -492,9 +526,12 @@ export async function createIntentsTypeDefinition(intents: IntentTypeDefinition[ .map( ({inputType, valueType, outputType, requestType}) => `${inputType}\n${valueType}\n${outputType}\n${requestType}`, ) - .join('\n\n')}\n\ntype ShopifyGeneratedIntentResponse = { + .join('\n\n')}\n\ninterface ShopifyGeneratedIntentResponse { ok(data?: Data): Promise; -}\n\ntype ShopifyGeneratedIntentsApi =\n${generatedIntents}\n` +}\n\ninterface ShopifyGeneratedIntentsApi { + request: Request; + response?: ShopifyGeneratedIntentResponse; +}\n\ntype ShopifyGeneratedIntentVariants =\n${generatedIntents}\n` } /** 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 9ec091e820a..44cc742349d 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 @@ -1364,11 +1364,15 @@ Please check the configuration in ${uiExtension.configurationPath}`), new Set([ `//@ts-ignore\ndeclare module './src/index.jsx' { const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api; - const globalThis: { shopify: typeof shopify }; + const globalThis: { + shopify: typeof shopify; + }; }\n`, `//@ts-ignore\ndeclare module './src/condition/should-render.js' { const shopify: import('@shopify/ui-extensions/admin.product-details.action.should-render').Api; - const globalThis: { shopify: typeof shopify }; + const globalThis: { + shopify: typeof shopify; + }; }\n`, ]), ], @@ -1425,11 +1429,15 @@ Please check the configuration in ${uiExtension.configurationPath}`), new Set([ `//@ts-ignore\ndeclare module './src/index.jsx' { const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api; - const globalThis: { shopify: typeof shopify }; + const globalThis: { + shopify: typeof shopify; + }; }\n`, `//@ts-ignore\ndeclare module './src/another-target-module.jsx' { const shopify: import('@shopify/ui-extensions/admin.orders-details.block.render').Api; - const globalThis: { shopify: typeof shopify }; + const globalThis: { + shopify: typeof shopify; + }; }\n`, ]), ], @@ -1438,7 +1446,9 @@ Please check the configuration in ${uiExtension.configurationPath}`), new Set([ `//@ts-ignore\ndeclare module './should-render.js' { const shopify: import('@shopify/ui-extensions/admin.product-details.action.should-render').Api; - const globalThis: { shopify: typeof shopify }; + const globalThis: { + shopify: typeof shopify; + }; }\n`, ]), ], @@ -1681,10 +1691,14 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Then - should include types for imported modules when single target expect(Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? [])).toContain( - `//@ts-ignore\ndeclare module './src/utils/helper.js' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './src/utils/helper.js' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { + shopify: typeof shopify; + };\n}\n`, ) expect(Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? [])).toContain( - `//@ts-ignore\ndeclare module './src/components/Button.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './src/components/Button.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { + shopify: typeof shopify; + };\n}\n`, ) }) }) @@ -1743,7 +1757,9 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Then - should generate union type for shared module expect(Array.from(types ?? [])).toContain( - `//@ts-ignore\ndeclare module './shared/utils.js' {\n const shopify:\n | import('@shopify/ui-extensions/admin.product-details.action.render').Api\n | import('@shopify/ui-extensions/admin.orders-details.block.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './shared/utils.js' {\n const shopify:\n | import('@shopify/ui-extensions/admin.product-details.action.render').Api\n | import('@shopify/ui-extensions/admin.orders-details.block.render').Api;\n const globalThis: { + shopify: typeof shopify; + };\n}\n`, ) }) }) @@ -1804,7 +1820,9 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Then - should generate union types for shared files // when targets are from different surfaces (admin vs checkout) expect(types).toContain( - `//@ts-ignore\ndeclare module './src/components/Shared.jsx' {\n const shopify:\n | import('@shopify/ui-extensions/admin.product-details.action.render').Api\n | import('@shopify/ui-extensions/purchase.checkout.block.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './src/components/Shared.jsx' {\n const shopify:\n | import('@shopify/ui-extensions/admin.product-details.action.render').Api\n | import('@shopify/ui-extensions/purchase.checkout.block.render').Api;\n const globalThis: { + shopify: typeof shopify; + };\n}\n`, ) }) }) @@ -1854,10 +1872,14 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Then - should resolve aliased imports and include types expect(Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? [])).toContain( - `//@ts-ignore\ndeclare module './src/utils/helper.js' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './src/utils/helper.js' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { + shopify: typeof shopify; + };\n}\n`, ) expect(Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? [])).toContain( - `//@ts-ignore\ndeclare module './src/components/Button.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './src/components/Button.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { + shopify: typeof shopify; + };\n}\n`, ) }) }) @@ -1928,7 +1950,9 @@ Please check the configuration in ${uiExtension.configurationPath}`), const extensionTypes = typeDefinitionsByFile.get(extensionShopifyDtsPath) expect(Array.from(extensionTypes ?? [])).toContain( - `//@ts-ignore\ndeclare module './src/index.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './src/index.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { + shopify: typeof shopify; + };\n}\n`, ) expect(Array.from(extensionTypes ?? [])).not.toContain(expect.stringContaining('helpers/utils.ts')) @@ -2002,10 +2026,14 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Then - should include type definition for both the main file and the root-level shared file expect(Array.from(types ?? [])).toContain( - `//@ts-ignore\ndeclare module './src/extension.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './src/extension.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { + shopify: typeof shopify; + };\n}\n`, ) expect(Array.from(types ?? [])).toContain( - `//@ts-ignore\ndeclare module './shared_file.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './shared_file.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { + shopify: typeof shopify; + };\n}\n`, ) }) }) @@ -2098,16 +2126,24 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Then - should include type definitions for all files: // main file, component, and both root-level shared files expect(types).toContain( - `//@ts-ignore\ndeclare module './src/extension.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './src/extension.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { + shopify: typeof shopify; + };\n}\n`, ) expect(types).toContain( - `//@ts-ignore\ndeclare module './src/components/Component.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './src/components/Component.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { + shopify: typeof shopify; + };\n}\n`, ) expect(types).toContain( - `//@ts-ignore\ndeclare module './shared_file.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './shared_file.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { + shopify: typeof shopify; + };\n}\n`, ) expect(types).toContain( - `//@ts-ignore\ndeclare module './utils.js' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './utils.js' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { + shopify: typeof shopify; + };\n}\n`, ) }) }) @@ -2202,17 +2238,23 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Then - should include type definitions for all files in the chain: // 1. Main extension file expect(types).toContain( - `//@ts-ignore\ndeclare module './src/extension.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './src/extension.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { + shopify: typeof shopify; + };\n}\n`, ) // 2. Component file that imports from root expect(types).toContain( - `//@ts-ignore\ndeclare module './src/components/Button.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './src/components/Button.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { + shopify: typeof shopify; + };\n}\n`, ) // 3. Root-level shared file (imported by component, not directly by extension) expect(types).toContain( - `//@ts-ignore\ndeclare module './shared_utils.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './shared_utils.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { + shopify: typeof shopify; + };\n}\n`, ) // Verify we have exactly 3 type definitions (no duplicates) @@ -2271,6 +2313,7 @@ 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('interface GeneratedToolsConstraint') expect(typeDefinition).toContain("tools: Omit, 'register'> & ShopifyTools") }) }) @@ -2549,10 +2592,13 @@ Please check the configuration in ${uiExtension.configurationPath}`), expect(typeDefinition).toContain('interface CreateApplicationEmailIntentRequest') expect(typeDefinition).toContain(`action: 'create';`) expect(typeDefinition).toContain(`type: 'application/email';`) - expect(typeDefinition).toContain( - 'response?: ShopifyGeneratedIntentResponse;', - ) - expect(typeDefinition).toContain('type ShopifyGeneratedIntentsApi =') + expect(typeDefinition).toContain('interface ShopifyGeneratedIntentResponse') + expect(typeDefinition).toContain('interface ShopifyGeneratedIntentsApi<') + expect(typeDefinition).toContain('Request = unknown,') + expect(typeDefinition).toContain('ResponseData = unknown,') + expect(typeDefinition).toContain('type ShopifyGeneratedIntentVariants = ShopifyGeneratedIntentsApi<') + expect(typeDefinition).toContain('CreateApplicationEmailIntentRequest,') + expect(typeDefinition).toContain('CreateApplicationEmailIntentOutput') expect(typeDefinition).toContain('WithGeneratedIntents<') }) }) From f398fd4a63602974c1e8dc6d0c58f2c8437cfb3a Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Fri, 17 Apr 2026 15:08:35 +0200 Subject: [PATCH 3/6] Add intent value test --- .../specifications/ui_extension.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) 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 44cc742349d..680d5e2e593 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 @@ -2658,5 +2658,67 @@ Please check the configuration in ${uiExtension.configurationPath}`), 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] as any).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') + }) + }) }) }) From 1c24aa31051e261c0eb45dffcdc85e6be56dd0b3 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Fri, 17 Apr 2026 15:22:27 +0200 Subject: [PATCH 4/6] Standardize json reading --- .../specifications/type-generation.ts | 30 +++++++-- .../extensions/specifications/ui_extension.ts | 61 ++++++++++--------- 2 files changed, 59 insertions(+), 32 deletions(-) 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 8ed0dec0a8c..eab3aa38840 100644 --- a/packages/app/src/cli/models/extensions/specifications/type-generation.ts +++ b/packages/app/src/cli/models/extensions/specifications/type-generation.ts @@ -320,6 +320,20 @@ interface GeneratedIntentResponseFallback { response?: NonNullable; } +interface GeneratedIntentRequestConstraint { + request: Request; +} + +type ReplaceSubscribableValue = Base extends {value: unknown; subscribe: (callback: (value: infer _) => void) => () => void} + ? Omit & { + readonly value: Value; + subscribe: (callback: (value: Value) => void) => () => void; + } + : { + readonly value: Value; + subscribe: (callback: (value: Value) => void) => () => void; + }; + interface GeneratedIntentsConstraint { intents?: Intents; } @@ -335,11 +349,19 @@ interface GeneratedIntentsFallback { type MergeGeneratedIntentResponse = ShopifyGeneratedIntentVariants extends infer Generated - ? Generated extends GeneratedIntentResponseConstraint - ? Omit & - (Intents extends GeneratedIntentResponseConstraint + ? Generated extends GeneratedIntentRequestConstraint + ? Omit & { + request: Intents extends GeneratedIntentRequestConstraint + ? ReplaceSubscribableValue + : { + readonly value: GeneratedRequest | null; + subscribe: (callback: (value: GeneratedRequest | null) => void) => () => void; + }; + } & (Generated extends GeneratedIntentResponseConstraint + ? Intents extends GeneratedIntentResponseConstraint ? GeneratedIntentResponseOverride - : GeneratedIntentResponseFallback) + : GeneratedIntentResponseFallback + : unknown) : Generated : never;`) 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 179eb874048..907c0746bec 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -293,21 +293,11 @@ const uiExtensionSpec = createExtensionSpecification({ 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) { @@ -389,6 +379,29 @@ 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, @@ -396,19 +409,11 @@ async function parseIntentTypeDefinitions( const parsedIntentDefinitions = await Promise.all( intents.map(async (intent) => { try { - const intentSchemaFilePath = joinPath(extensionDirectory, intent.schema) - const schemaExists = await fileExists(intentSchemaFilePath) - if (!schemaExists) return null - - const intentSchemaContent = await readFile(intentSchemaFilePath) - const intentSchema = IntentSchemaFileSchema.safeParse(JSON.parse(intentSchemaContent)) - - if (!intentSchema.success) { - outputWarn( - `Invalid intent schema in "${intent.schema}": ${intentSchema.error.issues - .map((issue) => issue.message) - .join(', ')}`, - ) + const intentSchema = await readAndValidateJsonAsset(extensionDirectory, intent.schema, IntentSchemaFileSchema) + if (intentSchema.status === 'missing') return null + + if (intentSchema.status === 'invalid') { + outputWarn(`Invalid intent schema in "${intent.schema}": ${intentSchema.issues}`) return null } From 08eb78da9b8ff78c5c3e82b4b4e307817c66fb10 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 20 Apr 2026 11:18:54 +0200 Subject: [PATCH 5/6] Reduce diff --- .../specifications/type-generation.ts | 4 +- .../specifications/ui_extension.test.ts | 84 +++++-------------- 2 files changed, 22 insertions(+), 66 deletions(-) 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 eab3aa38840..20d5935b442 100644 --- a/packages/app/src/cli/models/extensions/specifications/type-generation.ts +++ b/packages/app/src/cli/models/extensions/specifications/type-generation.ts @@ -417,9 +417,7 @@ export async function createTypeDefinition({ ...(intentsTypeDefinition ? [intentsTypeDefinition] : []), ...(shopifyUtilityTypes ? [shopifyUtilityTypes] : []), ` const shopify: ${shopifyType};`, - ' const globalThis: {', - ' shopify: typeof shopify;', - ' };', + ' const globalThis: { shopify: typeof shopify };', '}', '', ] 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 680d5e2e593..9c030518c5f 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 @@ -1364,15 +1364,11 @@ Please check the configuration in ${uiExtension.configurationPath}`), new Set([ `//@ts-ignore\ndeclare module './src/index.jsx' { const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api; - const globalThis: { - shopify: typeof shopify; - }; + const globalThis: { shopify: typeof shopify }; }\n`, `//@ts-ignore\ndeclare module './src/condition/should-render.js' { const shopify: import('@shopify/ui-extensions/admin.product-details.action.should-render').Api; - const globalThis: { - shopify: typeof shopify; - }; + const globalThis: { shopify: typeof shopify }; }\n`, ]), ], @@ -1429,15 +1425,11 @@ Please check the configuration in ${uiExtension.configurationPath}`), new Set([ `//@ts-ignore\ndeclare module './src/index.jsx' { const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api; - const globalThis: { - shopify: typeof shopify; - }; + const globalThis: { shopify: typeof shopify }; }\n`, `//@ts-ignore\ndeclare module './src/another-target-module.jsx' { const shopify: import('@shopify/ui-extensions/admin.orders-details.block.render').Api; - const globalThis: { - shopify: typeof shopify; - }; + const globalThis: { shopify: typeof shopify }; }\n`, ]), ], @@ -1446,9 +1438,7 @@ Please check the configuration in ${uiExtension.configurationPath}`), new Set([ `//@ts-ignore\ndeclare module './should-render.js' { const shopify: import('@shopify/ui-extensions/admin.product-details.action.should-render').Api; - const globalThis: { - shopify: typeof shopify; - }; + const globalThis: { shopify: typeof shopify }; }\n`, ]), ], @@ -1691,14 +1681,10 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Then - should include types for imported modules when single target expect(Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? [])).toContain( - `//@ts-ignore\ndeclare module './src/utils/helper.js' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { - shopify: typeof shopify; - };\n}\n`, + `//@ts-ignore\ndeclare module './src/utils/helper.js' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) expect(Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? [])).toContain( - `//@ts-ignore\ndeclare module './src/components/Button.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { - shopify: typeof shopify; - };\n}\n`, + `//@ts-ignore\ndeclare module './src/components/Button.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) }) }) @@ -1757,9 +1743,7 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Then - should generate union type for shared module expect(Array.from(types ?? [])).toContain( - `//@ts-ignore\ndeclare module './shared/utils.js' {\n const shopify:\n | import('@shopify/ui-extensions/admin.product-details.action.render').Api\n | import('@shopify/ui-extensions/admin.orders-details.block.render').Api;\n const globalThis: { - shopify: typeof shopify; - };\n}\n`, + `//@ts-ignore\ndeclare module './shared/utils.js' {\n const shopify:\n | import('@shopify/ui-extensions/admin.product-details.action.render').Api\n | import('@shopify/ui-extensions/admin.orders-details.block.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) }) }) @@ -1820,9 +1804,7 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Then - should generate union types for shared files // when targets are from different surfaces (admin vs checkout) expect(types).toContain( - `//@ts-ignore\ndeclare module './src/components/Shared.jsx' {\n const shopify:\n | import('@shopify/ui-extensions/admin.product-details.action.render').Api\n | import('@shopify/ui-extensions/purchase.checkout.block.render').Api;\n const globalThis: { - shopify: typeof shopify; - };\n}\n`, + `//@ts-ignore\ndeclare module './src/components/Shared.jsx' {\n const shopify:\n | import('@shopify/ui-extensions/admin.product-details.action.render').Api\n | import('@shopify/ui-extensions/purchase.checkout.block.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) }) }) @@ -1872,14 +1854,10 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Then - should resolve aliased imports and include types expect(Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? [])).toContain( - `//@ts-ignore\ndeclare module './src/utils/helper.js' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { - shopify: typeof shopify; - };\n}\n`, + `//@ts-ignore\ndeclare module './src/utils/helper.js' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) expect(Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? [])).toContain( - `//@ts-ignore\ndeclare module './src/components/Button.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { - shopify: typeof shopify; - };\n}\n`, + `//@ts-ignore\ndeclare module './src/components/Button.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) }) }) @@ -1950,9 +1928,7 @@ Please check the configuration in ${uiExtension.configurationPath}`), const extensionTypes = typeDefinitionsByFile.get(extensionShopifyDtsPath) expect(Array.from(extensionTypes ?? [])).toContain( - `//@ts-ignore\ndeclare module './src/index.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { - shopify: typeof shopify; - };\n}\n`, + `//@ts-ignore\ndeclare module './src/index.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) expect(Array.from(extensionTypes ?? [])).not.toContain(expect.stringContaining('helpers/utils.ts')) @@ -2026,14 +2002,10 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Then - should include type definition for both the main file and the root-level shared file expect(Array.from(types ?? [])).toContain( - `//@ts-ignore\ndeclare module './src/extension.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { - shopify: typeof shopify; - };\n}\n`, + `//@ts-ignore\ndeclare module './src/extension.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) expect(Array.from(types ?? [])).toContain( - `//@ts-ignore\ndeclare module './shared_file.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { - shopify: typeof shopify; - };\n}\n`, + `//@ts-ignore\ndeclare module './shared_file.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) }) }) @@ -2126,24 +2098,16 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Then - should include type definitions for all files: // main file, component, and both root-level shared files expect(types).toContain( - `//@ts-ignore\ndeclare module './src/extension.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { - shopify: typeof shopify; - };\n}\n`, + `//@ts-ignore\ndeclare module './src/extension.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) expect(types).toContain( - `//@ts-ignore\ndeclare module './src/components/Component.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { - shopify: typeof shopify; - };\n}\n`, + `//@ts-ignore\ndeclare module './src/components/Component.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) expect(types).toContain( - `//@ts-ignore\ndeclare module './shared_file.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { - shopify: typeof shopify; - };\n}\n`, + `//@ts-ignore\ndeclare module './shared_file.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) expect(types).toContain( - `//@ts-ignore\ndeclare module './utils.js' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { - shopify: typeof shopify; - };\n}\n`, + `//@ts-ignore\ndeclare module './utils.js' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) }) }) @@ -2238,23 +2202,17 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Then - should include type definitions for all files in the chain: // 1. Main extension file expect(types).toContain( - `//@ts-ignore\ndeclare module './src/extension.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { - shopify: typeof shopify; - };\n}\n`, + `//@ts-ignore\ndeclare module './src/extension.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) // 2. Component file that imports from root expect(types).toContain( - `//@ts-ignore\ndeclare module './src/components/Button.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { - shopify: typeof shopify; - };\n}\n`, + `//@ts-ignore\ndeclare module './src/components/Button.jsx' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) // 3. Root-level shared file (imported by component, not directly by extension) expect(types).toContain( - `//@ts-ignore\ndeclare module './shared_utils.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { - shopify: typeof shopify; - };\n}\n`, + `//@ts-ignore\ndeclare module './shared_utils.ts' {\n const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) // Verify we have exactly 3 type definitions (no duplicates) From dd6da364987d4f89ae50ffeca2f5e4b3d26c362c Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Thu, 23 Apr 2026 17:03:50 +0200 Subject: [PATCH 6/6] Use types from ui-extensions --- .../specifications/type-generation.test.ts | 103 ++++++-- .../specifications/type-generation.ts | 161 +++++-------- .../specifications/ui_extension.test.ts | 222 +++++++++++++++--- .../extensions/specifications/ui_extension.ts | 13 +- 4 files changed, 327 insertions(+), 172 deletions(-) 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 923a4c91ff6..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,11 +1,32 @@ -import {createIntentsTypeDefinition, 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([]) + const result = await createIntentsTypeDefinition([], { + generatedTypesHelperImportPath: adminGeneratedTypesHelperImportPath, + }) // Then expect(result).toBe('') @@ -34,7 +55,9 @@ describe('createIntentsTypeDefinition', () => { ] // When - const result = await createIntentsTypeDefinition(intents) + const result = await createIntentsTypeDefinition(intents, { + generatedTypesHelperImportPath: adminGeneratedTypesHelperImportPath, + }) // Then expect(result).toBe(`interface CreateApplicationEmailIntentInput { @@ -55,17 +78,8 @@ interface CreateApplicationEmailIntentRequest { value?: CreateApplicationEmailIntentValue; } -interface ShopifyGeneratedIntentResponse { - ok(data?: Data): Promise; -} - -interface ShopifyGeneratedIntentsApi { - request: Request; - response?: ShopifyGeneratedIntentResponse; -} - type ShopifyGeneratedIntentVariants = - | ShopifyGeneratedIntentsApi + | import('@shopify/ui-extensions/admin').ShopifyGeneratedIntentVariant `) }) @@ -104,16 +118,18 @@ type ShopifyGeneratedIntentVariants = ] // When - const result = await createIntentsTypeDefinition(intents) + 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).toContain('response?: ShopifyGeneratedIntentResponse;') + expect(result).not.toContain('ShopifyGeneratedIntentsApi') expect(result).toContain( - 'ShopifyGeneratedIntentsApi', + "import('@shopify/ui-extensions/admin').ShopifyGeneratedIntentVariant", ) }) @@ -133,7 +149,9 @@ type ShopifyGeneratedIntentVariants = ] // When & Then - await expect(createIntentsTypeDefinition(intents)).rejects.toThrow( + 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.', ), @@ -192,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; } `) }) @@ -226,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; } `) }) @@ -296,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; } `) }) @@ -351,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; } `) }) @@ -381,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; } `) }) @@ -413,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; } `) }) @@ -484,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; } `) }) @@ -512,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 20d5935b442..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('-') @@ -270,6 +291,7 @@ async function buildShopifyType( targets: string[], resolvedTargetPaths: Map, {includesTools, includesIntents}: ShopifyTypeOptions, + generatedTypesHelperImportPath: string, ): Promise { const baseShopifyType = await buildBaseShopifyType(targets, resolvedTargetPaths) if (!baseShopifyType) return null @@ -278,99 +300,17 @@ async function buildShopifyType( return baseShopifyType } - const wrappers = [ - ...(includesIntents ? ['WithGeneratedIntents'] : []), - ...(includesTools ? ['WithGeneratedTools'] : []), - ] - - return wrappers.reduce((shopifyType, wrapper) => `${wrapper}<${shopifyType}>`, baseShopifyType) -} - -function buildShopifyUtilityTypes({includesTools, includesIntents}: ShopifyTypeOptions): string { - const utilityTypes: string[] = [] - - if (includesTools) { - utilityTypes.push(`interface GeneratedToolsConstraint { - tools?: Tools; -} - -interface GeneratedToolsOverride { - tools: Omit, 'register'> & ShopifyTools; -} - -interface GeneratedToolsFallback { - tools: ShopifyTools; -} - -type WithGeneratedTools = T extends GeneratedToolsConstraint - ? Omit & GeneratedToolsOverride - : T & GeneratedToolsFallback;`) - } + let shopifyType = baseShopifyType if (includesIntents) { - utilityTypes.push(`interface GeneratedIntentResponseConstraint { - response?: Response; -} - -interface GeneratedIntentResponseOverride { - response?: Omit, 'ok'> & NonNullable; -} - -interface GeneratedIntentResponseFallback { - response?: NonNullable; -} - -interface GeneratedIntentRequestConstraint { - request: Request; -} - -type ReplaceSubscribableValue = Base extends {value: unknown; subscribe: (callback: (value: infer _) => void) => () => void} - ? Omit & { - readonly value: Value; - subscribe: (callback: (value: Value) => void) => () => void; - } - : { - readonly value: Value; - subscribe: (callback: (value: Value) => void) => () => void; - }; - -interface GeneratedIntentsConstraint { - intents?: Intents; -} - -interface GeneratedIntentsOverride { - intents: Omit, 'request' | 'response'> & - MergeGeneratedIntentResponse>; -} - -interface GeneratedIntentsFallback { - intents: ShopifyGeneratedIntentVariants; -} + shopifyType = `import('${generatedTypesHelperImportPath}').WithGeneratedIntents<${shopifyType}, ShopifyGeneratedIntentVariants>` + } -type MergeGeneratedIntentResponse = - ShopifyGeneratedIntentVariants extends infer Generated - ? Generated extends GeneratedIntentRequestConstraint - ? Omit & { - request: Intents extends GeneratedIntentRequestConstraint - ? ReplaceSubscribableValue - : { - readonly value: GeneratedRequest | null; - subscribe: (callback: (value: GeneratedRequest | null) => void) => () => void; - }; - } & (Generated extends GeneratedIntentResponseConstraint - ? Intents extends GeneratedIntentResponseConstraint - ? GeneratedIntentResponseOverride - : GeneratedIntentResponseFallback - : unknown) - : Generated - : never;`) - - utilityTypes.push(`type WithGeneratedIntents = T extends GeneratedIntentsConstraint - ? Omit & GeneratedIntentsOverride - : T & GeneratedIntentsFallback;`) + if (includesTools) { + shopifyType = `import('${generatedTypesHelperImportPath}').WithGeneratedTools<${shopifyType}, ShopifyTools>` } - return utilityTypes.join('\n\n') + return shopifyType } export async function createTypeDefinition({ @@ -383,6 +323,8 @@ export async function createTypeDefinition({ }: 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. @@ -401,21 +343,34 @@ export async function createTypeDefinition({ } } - const relativePath = relativizePath(fullPath, dirname(typeFilePath)) - const includesTools = Boolean(toolsTypeDefinition) - const includesIntents = Boolean(intentsTypeDefinition) + const generatedTypesHelperImportPath = getGeneratedTypesHelperImportPath(targets) - const shopifyType = await buildShopifyType(targets, resolvedTargetPaths, {includesTools, includesIntents}) - if (!shopifyType) return null + 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 shopifyUtilityTypes = buildShopifyUtilityTypes({includesTools, includesIntents}) + 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] : []), ...(intentsTypeDefinition ? [intentsTypeDefinition] : []), - ...(shopifyUtilityTypes ? [shopifyUtilityTypes] : []), ` const shopify: ${shopifyType};`, ' const globalThis: { shopify: typeof shopify };', '}', @@ -496,7 +451,10 @@ function intentTypeBaseName(intent: Pick { +export async function createIntentsTypeDefinition( + intents: IntentTypeDefinition[], + {generatedTypesHelperImportPath}: {generatedTypesHelperImportPath: string}, +): Promise { if (intents.length === 0) return '' const intentKeys = new Set() @@ -538,7 +496,7 @@ export async function createIntentsTypeDefinition(intents: IntentTypeDefinition[ const generatedIntents = types .map(({requestTypeName, outputTypeName}) => { - return ` | ShopifyGeneratedIntentsApi<${requestTypeName}, ${outputTypeName}>` + return ` | import('${generatedTypesHelperImportPath}').ShopifyGeneratedIntentVariant<${requestTypeName}, ${outputTypeName}>` }) .join('\n') @@ -546,12 +504,7 @@ export async function createIntentsTypeDefinition(intents: IntentTypeDefinition[ .map( ({inputType, valueType, outputType, requestType}) => `${inputType}\n${valueType}\n${outputType}\n${requestType}`, ) - .join('\n\n')}\n\ninterface ShopifyGeneratedIntentResponse { - ok(data?: Data): Promise; -}\n\ninterface ShopifyGeneratedIntentsApi { - request: Request; - response?: ShopifyGeneratedIntentResponse; -}\n\ntype ShopifyGeneratedIntentVariants =\n${generatedIntents}\n` + .join('\n\n')}\n\ntype ShopifyGeneratedIntentVariants =\n${generatedIntents}\n` } /** @@ -599,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') 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 9c030518c5f..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,8 +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('interface GeneratedToolsConstraint') - expect(typeDefinition).toContain("tools: Omit, 'register'> & ShopifyTools") + expect(typeDefinition).toContain("import('@shopify/ui-extensions/admin').WithGeneratedTools<") + expect(typeDefinition).not.toContain('interface GeneratedToolsConstraint') }) }) @@ -2322,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'), '{}') @@ -2357,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'), '{}') @@ -2390,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'), '{}') @@ -2428,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'), '{}') @@ -2475,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'), '{}') @@ -2492,7 +2517,7 @@ 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')) @@ -2527,7 +2552,7 @@ Please check the configuration in ${uiExtension.configurationPath}`), }, }) await writeFile(joinPath(tmpDir, 'intent-schema.json'), intentSchemaContent) - ;(extension.configuration.extension_points[0] as any).intents = [ + extension.configuration.extension_points[0]!.intents = [ { action: 'create', type: 'application/email', @@ -2550,14 +2575,132 @@ Please check the configuration in ${uiExtension.configurationPath}`), expect(typeDefinition).toContain('interface CreateApplicationEmailIntentRequest') expect(typeDefinition).toContain(`action: 'create';`) expect(typeDefinition).toContain(`type: 'application/email';`) - expect(typeDefinition).toContain('interface ShopifyGeneratedIntentResponse') - expect(typeDefinition).toContain('interface ShopifyGeneratedIntentsApi<') - expect(typeDefinition).toContain('Request = unknown,') - expect(typeDefinition).toContain('ResponseData = unknown,') - expect(typeDefinition).toContain('type ShopifyGeneratedIntentVariants = ShopifyGeneratedIntentsApi<') - expect(typeDefinition).toContain('CreateApplicationEmailIntentRequest,') + 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('WithGeneratedIntents<') + 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.', + ), + ) }) }) @@ -2588,7 +2731,7 @@ Please check the configuration in ${uiExtension.configurationPath}`), }, }) await writeFile(joinPath(tmpDir, 'intent-schema.json'), intentSchemaContent) - ;(extension.configuration.extension_points[0] as any).intents = [ + extension.configuration.extension_points[0]!.intents = [ { action: 'create', type: 'application/email', @@ -2608,11 +2751,12 @@ Please check the configuration in ${uiExtension.configurationPath}`), expect(types).toHaveLength(2) const entryPointType = types.find((t) => t.includes('./src/index.jsx')) - expect(entryPointType).toContain('ShopifyGeneratedIntentsApi') + 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('ShopifyGeneratedIntentsApi') + expect(helperType).not.toContain('ShopifyGeneratedIntentVariants') expect(helperType).not.toContain('CreateApplicationEmailIntentRequest') }) }) @@ -2651,7 +2795,7 @@ Please check the configuration in ${uiExtension.configurationPath}`), }, }) await writeFile(joinPath(tmpDir, 'intent-schema.json'), intentSchemaContent) - ;(extension.configuration.extension_points[0] as any).intents = [ + extension.configuration.extension_points[0]!.intents = [ { action: 'edit', type: 'shopify/Product', 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 907c0746bec..50fc1212d2a 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -4,6 +4,7 @@ import { createTypeDefinition, createToolsTypeDefinition, findNearestTsConfigDir, + getGeneratedTypesHelperImportPath, IntentSchemaFileSchema, parseApiVersion, ToolsFileSchema, @@ -19,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' @@ -287,6 +289,7 @@ const uiExtensionSpec = createExtensionSpecification({ // Remove duplicates from targets const uniqueTargets = [...new Set(targets)] + const generatedTypesHelperImportPath = getGeneratedTypesHelperImportPath(uniqueTargets) try { const toolsDefinition = fileToToolsMap.get(filePath) @@ -316,9 +319,10 @@ const uiExtensionSpec = createExtensionSpecification({ if (parsedIntents.length > 0) { try { - intentsTypeDefinition = await createIntentsTypeDefinition(parsedIntents) - // eslint-disable-next-line no-catch-all/no-catch-all + 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) @@ -410,7 +414,10 @@ async function parseIntentTypeDefinitions( intents.map(async (intent) => { try { const intentSchema = await readAndValidateJsonAsset(extensionDirectory, intent.schema, IntentSchemaFileSchema) - if (intentSchema.status === 'missing') return null + 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}`)