From e5849d72fa5ab518c9636cfbfbdbc46af434b2e7 Mon Sep 17 00:00:00 2001 From: Trish Ta Date: Tue, 31 Mar 2026 09:55:56 -0400 Subject: [PATCH 1/2] Support assets for admin links app intents --- .../models/extensions/load-specifications.ts | 2 + .../specifications/admin_link.test.ts | 114 ++++++++++++++++++ .../extensions/specifications/admin_link.ts | 47 ++++++++ 3 files changed, 163 insertions(+) create mode 100644 packages/app/src/cli/models/extensions/specifications/admin_link.test.ts create mode 100644 packages/app/src/cli/models/extensions/specifications/admin_link.ts diff --git a/packages/app/src/cli/models/extensions/load-specifications.ts b/packages/app/src/cli/models/extensions/load-specifications.ts index b2f4c7d3a2f..e41555c3656 100644 --- a/packages/app/src/cli/models/extensions/load-specifications.ts +++ b/packages/app/src/cli/models/extensions/load-specifications.ts @@ -28,6 +28,7 @@ import webPixelSpec from './specifications/web_pixel_extension.js' import editorExtensionCollectionSpecification from './specifications/editor_extension_collection.js' import channelSpecificationSpec from './specifications/channel.js' import adminSpecificationSpec from './specifications/admin.js' +import adminLinkSpec from './specifications/admin_link.js' const SORTED_CONFIGURATION_SPEC_IDENTIFIERS = [ BrandingSpecIdentifier, @@ -80,6 +81,7 @@ function loadSpecifications() { webPixelSpec, editorExtensionCollectionSpecification, channelSpecificationSpec, + adminLinkSpec, ] return [...configModuleSpecs, ...moduleSpecs] as ExtensionSpecification[] diff --git a/packages/app/src/cli/models/extensions/specifications/admin_link.test.ts b/packages/app/src/cli/models/extensions/specifications/admin_link.test.ts new file mode 100644 index 00000000000..fc03ce30671 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/admin_link.test.ts @@ -0,0 +1,114 @@ +import * as loadLocales from '../../../utilities/extensions/locales-configuration.js' +import {ExtensionInstance} from '../extension-instance.js' +import {loadLocalExtensionsSpecifications} from '../load-specifications.js' +import {placeholderAppConfiguration} from '../../app/app.test-data.js' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {describe, expect, test, vi} from 'vitest' + +describe('admin_link', async () => { + async function getTestAdminLink(directory: string, configuration: Record = {}) { + const configurationPath = joinPath(directory, 'shopify.extension.toml') + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'admin_link')! + const parsed = specification.parseConfigurationObject(configuration) + if (parsed.state !== 'ok') { + throw new Error("Couldn't parse configuration") + } + + return new ExtensionInstance({ + configuration: parsed.data, + directory, + specification, + configurationPath, + entryPath: '', + }) + } + + test('has the correct identifier', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const extension = await getTestAdminLink(tmpDir) + expect(extension.specification.identifier).toBe('admin_link') + }) + }) + + test('has localization in appModuleFeatures', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const extension = await getTestAdminLink(tmpDir) + expect(extension.specification.appModuleFeatures()).toContain('localization') + }) + }) + + test('has include_assets client step with generateManifest enabled', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const extension = await getTestAdminLink(tmpDir) + const clientSteps = extension.specification.clientSteps! + expect(clientSteps).toHaveLength(1) + expect(clientSteps[0]!.lifecycle).toBe('deploy') + + const steps = clientSteps[0]!.steps + expect(steps).toHaveLength(1) + expect(steps[0]).toMatchObject({ + id: 'copy-admin-link-assets', + name: 'Copy Admin Link Assets', + type: 'include_assets', + config: { + generateManifest: true, + inclusions: [ + {type: 'configKey', key: 'targeting[].tools'}, + {type: 'configKey', key: 'targeting[].instructions'}, + {type: 'configKey', key: 'targeting[].intents[].schema'}, + ], + }, + }) + }) + }) + + describe('deployConfig()', () => { + test('includes localization in deploy config', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const localization = { + default_locale: 'en', + translations: {title: 'Hello!'}, + } + vi.spyOn(loadLocales, 'loadLocalesConfig').mockResolvedValue(localization) + + const extension = await getTestAdminLink(tmpDir, { + name: 'My Admin Link', + targeting: [{url: 'https://example.com'}], + }) + + const deployConfig = await extension.deployConfig({ + apiKey: 'apiKey', + appConfiguration: placeholderAppConfiguration, + }) + + expect(deployConfig).toMatchObject({localization}) + expect(loadLocales.loadLocalesConfig).toHaveBeenCalledWith(tmpDir, 'admin_link') + }) + }) + + test('strips first-class fields from deploy config', async () => { + await inTemporaryDirectory(async (tmpDir) => { + vi.spyOn(loadLocales, 'loadLocalesConfig').mockResolvedValue({}) + + const extension = await getTestAdminLink(tmpDir, { + type: 'admin_link', + handle: 'my-link', + name: 'My Admin Link', + targeting: [{url: 'https://example.com'}], + }) + + const deployConfig = await extension.deployConfig({ + apiKey: 'apiKey', + appConfiguration: placeholderAppConfiguration, + }) + + expect(deployConfig).not.toHaveProperty('type') + expect(deployConfig).not.toHaveProperty('handle') + expect(deployConfig).toHaveProperty('name', 'My Admin Link') + expect(deployConfig).toHaveProperty('targeting') + }) + }) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/admin_link.ts b/packages/app/src/cli/models/extensions/specifications/admin_link.ts new file mode 100644 index 00000000000..e4fde06c35d --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/admin_link.ts @@ -0,0 +1,47 @@ +import {createContractBasedModuleSpecification} from '../specification.js' + +const adminLinkSpec = createContractBasedModuleSpecification({ + identifier: 'admin_link', + buildConfig: { + mode: 'copy_files', + filePatterns: [], + }, + clientSteps: [ + { + lifecycle: 'deploy', + steps: [ + { + id: 'include-admin-link-assets', + name: 'Include Admin Link Assets', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [ + { + type: 'configKey', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + key: 'extensions[].targeting[].tools', + }, + { + type: 'configKey', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + key: 'extensions[].targeting[].instructions', + }, + { + type: 'configKey', + anchor: 'extensions[].targeting[]', + groupBy: 'target', + key: 'extensions[].targeting[].intents[].schema', + }, + ], + }, + }, + ], + }, + ], + appModuleFeatures: () => ['localization'], +}) + +export default adminLinkSpec From 71d70cf1770ad1836c059268e7e2e01b9b5736fd Mon Sep 17 00:00:00 2001 From: Trish Ta Date: Thu, 2 Apr 2026 17:14:40 -0400 Subject: [PATCH 2/2] Support admin_link extensions in the Dev Server --- .../cli/models/extensions/specification.ts | 2 + .../specifications/admin_link.test.ts | 16 ++++++-- .../extensions/specifications/admin_link.ts | 16 ++++---- .../services/dev/extension/payload.test.ts | 40 ++++++++++++++++++- .../src/cli/services/dev/extension/payload.ts | 3 +- 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 383b62ab8a7..a79c77da94a 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -295,6 +295,7 @@ export function createContractBasedModuleSpecification, ) { return createExtensionSpecification({ @@ -305,6 +306,7 @@ export function createContractBasedModuleSpecification { let parsedConfig = configWithoutFirstClassFields(config) diff --git a/packages/app/src/cli/models/extensions/specifications/admin_link.test.ts b/packages/app/src/cli/models/extensions/specifications/admin_link.test.ts index fc03ce30671..05cd2ce6f80 100644 --- a/packages/app/src/cli/models/extensions/specifications/admin_link.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/admin_link.test.ts @@ -32,10 +32,18 @@ describe('admin_link', async () => { }) }) - test('has localization in appModuleFeatures', async () => { + test('has localization and ui_preview in appModuleFeatures', async () => { await inTemporaryDirectory(async (tmpDir) => { const extension = await getTestAdminLink(tmpDir) expect(extension.specification.appModuleFeatures()).toContain('localization') + expect(extension.specification.appModuleFeatures()).toContain('ui_preview') + }) + }) + + test('is previewable', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const extension = await getTestAdminLink(tmpDir) + expect(extension.isPreviewable).toBe(true) }) }) @@ -49,11 +57,11 @@ describe('admin_link', async () => { const steps = clientSteps[0]!.steps expect(steps).toHaveLength(1) expect(steps[0]).toMatchObject({ - id: 'copy-admin-link-assets', - name: 'Copy Admin Link Assets', + id: 'include-admin-link-assets', + name: 'Include Admin Link Assets', type: 'include_assets', config: { - generateManifest: true, + generatesAssetsManifest: true, inclusions: [ {type: 'configKey', key: 'targeting[].tools'}, {type: 'configKey', key: 'targeting[].instructions'}, diff --git a/packages/app/src/cli/models/extensions/specifications/admin_link.ts b/packages/app/src/cli/models/extensions/specifications/admin_link.ts index e4fde06c35d..0a44ec77c31 100644 --- a/packages/app/src/cli/models/extensions/specifications/admin_link.ts +++ b/packages/app/src/cli/models/extensions/specifications/admin_link.ts @@ -19,21 +19,21 @@ const adminLinkSpec = createContractBasedModuleSpecification({ inclusions: [ { type: 'configKey', - anchor: 'extensions[].targeting[]', + anchor: 'targeting[]', groupBy: 'target', - key: 'extensions[].targeting[].tools', + key: 'targeting[].tools', }, { type: 'configKey', - anchor: 'extensions[].targeting[]', + anchor: 'targeting[]', groupBy: 'target', - key: 'extensions[].targeting[].instructions', + key: 'targeting[].instructions', }, { type: 'configKey', - anchor: 'extensions[].targeting[]', + anchor: 'targeting[]', groupBy: 'target', - key: 'extensions[].targeting[].intents[].schema', + key: 'targeting[].intents[].schema', }, ], }, @@ -41,7 +41,9 @@ const adminLinkSpec = createContractBasedModuleSpecification({ ], }, ], - appModuleFeatures: () => ['localization'], + // This needs to be a file path so the extension directory can be resolved to the correct path + getOutputRelativePath: () => 'shopify.extension.toml', + appModuleFeatures: () => ['localization', 'ui_preview'], }) export default adminLinkSpec diff --git a/packages/app/src/cli/services/dev/extension/payload.test.ts b/packages/app/src/cli/services/dev/extension/payload.test.ts index 0c56694424a..cde3563cc18 100644 --- a/packages/app/src/cli/services/dev/extension/payload.test.ts +++ b/packages/app/src/cli/services/dev/extension/payload.test.ts @@ -1,4 +1,3 @@ -import {UIExtensionPayload} from './payload/models.js' import {getUIExtensionPayload} from './payload.js' import {ExtensionsPayloadStoreOptions} from './payload/store.js' import {testUIExtension} from '../../../models/app/app.test-data.js' @@ -244,6 +243,45 @@ describe('getUIExtensionPayload', () => { }) }) + test('reads from targeting when extension_points is not set', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const uiExtension = await testUIExtension({ + directory: tmpDir, + configuration: { + name: 'test-admin-link', + type: 'admin_link', + targeting: [{target: 'admin.app.link', url: '/editor', tools: './tools.json'}], + } as any, + devUUID: 'devUUID', + }) + + await setupBuildOutput( + uiExtension, + tmpDir, + {'admin.app.link': {tools: 'tools.json'}}, + {'tools.json': '{"tools": []}'}, + ) + + const got = await getUIExtensionPayload(uiExtension, tmpDir, { + ...createMockOptions(tmpDir, [uiExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, + }) + + expect(got.extensionPoints).toMatchObject([ + { + target: 'admin.app.link', + assets: { + tools: { + name: 'tools', + url: 'http://tunnel-url.com/extensions/devUUID/assets/tools.json', + lastUpdated: expect.any(Number), + }, + }, + }, + ]) + }) + }) + test('returns the right payload for post-purchase extensions', async () => { await inTemporaryDirectory(async (tmpDir) => { const outputPath = joinPath(tmpDir, 'test-post-purchase-extension.js') diff --git a/packages/app/src/cli/services/dev/extension/payload.ts b/packages/app/src/cli/services/dev/extension/payload.ts index fad6117648b..97044cd560d 100644 --- a/packages/app/src/cli/services/dev/extension/payload.ts +++ b/packages/app/src/cli/services/dev/extension/payload.ts @@ -104,7 +104,8 @@ export async function getUIExtensionPayload( } async function getExtensionPoints(extension: ExtensionInstance, url: string, buildDirectory: string) { - let extensionPoints = extension.configuration.extension_points as DevNewExtensionPointSchema[] + const config = extension.configuration as Record + let extensionPoints = (config.extension_points ?? config.targeting) as DevNewExtensionPointSchema[] if (extension.type === 'checkout_post_purchase') { // Mock target for post-purchase in order to get the right extension point redirect url