diff --git a/packages/app/src/cli/models/extensions/load-specifications.ts b/packages/app/src/cli/models/extensions/load-specifications.ts index 85d255daff5..7bf89e2834d 100644 --- a/packages/app/src/cli/models/extensions/load-specifications.ts +++ b/packages/app/src/cli/models/extensions/load-specifications.ts @@ -29,6 +29,7 @@ import editorExtensionCollectionSpecification from './specifications/editor_exte import channelSpecificationSpec from './specifications/channel.js' import orderAttributionConfigSpec from './specifications/order_attribution_config.js' import adminSpecificationSpec from './specifications/admin.js' +import adminLinkSpec from './specifications/admin_link.js' const SORTED_CONFIGURATION_SPEC_IDENTIFIERS = [ BrandingSpecIdentifier, @@ -82,6 +83,7 @@ function loadSpecifications() { editorExtensionCollectionSpecification, channelSpecificationSpec, orderAttributionConfigSpec, + 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..0030fb7b266 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/admin_link.test.ts @@ -0,0 +1,122 @@ +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 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) + }) + }) + + test('has include_assets client step with generatesAssetsManifest 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: 'include-admin-link-assets', + name: 'Include Admin Link Assets', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [ + {type: 'configKey', anchor: 'targeting[]', groupBy: 'target', key: 'targeting[].tools'}, + {type: 'configKey', anchor: 'targeting[]', groupBy: 'target', key: 'targeting[].instructions'}, + {type: 'configKey', anchor: 'targeting[]', groupBy: 'target', 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..14c9e63857e --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/admin_link.ts @@ -0,0 +1,43 @@ +import {createContractBasedModuleSpecification} from '../specification.js' + +const adminLinkSpec = createContractBasedModuleSpecification({ + identifier: 'admin_link', + clientSteps: [ + { + lifecycle: 'deploy', + steps: [ + { + id: 'include-admin-link-assets', + name: 'Include Admin Link Assets', + type: 'include_assets', + config: { + generatesAssetsManifest: true, + inclusions: [ + { + type: 'configKey', + anchor: 'targeting[]', + groupBy: 'target', + key: 'targeting[].tools', + }, + { + type: 'configKey', + anchor: 'targeting[]', + groupBy: 'target', + key: 'targeting[].instructions', + }, + { + type: 'configKey', + anchor: 'targeting[]', + groupBy: 'target', + key: 'targeting[].intents[].schema', + }, + ], + }, + }, + ], + }, + ], + 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 f4115b73451..eb9719a7e13 100644 --- a/packages/app/src/cli/services/dev/extension/payload.test.ts +++ b/packages/app/src/cli/services/dev/extension/payload.test.ts @@ -1,6 +1,8 @@ import {getUIExtensionPayload} from './payload.js' import {ExtensionsPayloadStoreOptions} from './payload/store.js' import {testUIExtension} from '../../../models/app/app.test-data.js' +import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' +import {loadLocalExtensionsSpecifications} from '../../../models/extensions/load-specifications.js' import * as appModel from '../../../models/app/app.js' import {describe, expect, test, vi, beforeEach} from 'vitest' import {inTemporaryDirectory, mkdir, touchFile, writeFile} from '@shopify/cli-kit/node/fs' @@ -57,6 +59,28 @@ describe('getUIExtensionPayload', () => { } } + async function testAdminLink( + directory: string, + configuration: Record, + overrides: {devUUID?: string} = {}, + ) { + 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 admin_link configuration") + } + const extension = new ExtensionInstance({ + configuration: parsed.data, + directory, + specification, + configurationPath: joinPath(directory, 'shopify.extension.toml'), + entryPath: '', + }) + if (overrides.devUUID) extension.devUUID = overrides.devUUID + return extension + } + test('returns the right payload', async () => { await inTemporaryDirectory(async (tmpDir) => { const outputPath = joinPath(tmpDir, 'test-ui-extension.js') @@ -367,6 +391,44 @@ describe('getUIExtensionPayload', () => { }) }) + test('reads from targeting when extension_points is not set (admin_link)', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const adminLinkExtension = await testAdminLink( + tmpDir, + { + name: 'test-admin-link', + targeting: [{target: 'admin.app.intent.link', url: '/editor', tools: './tools.json'}], + }, + {devUUID: 'devUUID'}, + ) + + await setupBuildOutput( + adminLinkExtension, + tmpDir, + {'admin.app.intent.link': {tools: 'tools.json'}}, + {'tools.json': '{"tools": []}'}, + ) + + const got = await getUIExtensionPayload(adminLinkExtension, tmpDir, { + ...createMockOptions(tmpDir, [adminLinkExtension]), + currentDevelopmentPayload: {hidden: true, status: 'success'}, + }) + + expect(got.extensionPoints).toMatchObject([ + { + target: 'admin.app.intent.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 9cda8b17d24..990bf59be19 100644 --- a/packages/app/src/cli/services/dev/extension/payload.ts +++ b/packages/app/src/cli/services/dev/extension/payload.ts @@ -31,7 +31,9 @@ export async function getUIExtensionPayload( const url = `${options.url}/extensions/${extension.devUUID}` const {localization, status: localizationStatus} = await getLocalization(extension, options) const renderer = await getUIExtensionRendererVersion(extension) - const buildDirectory = dirname(extensionOutputPath) + // If the extension has a custom output relative path, use that as the build directory + // ex. ext/dist/handle.js -> ext/dist + const buildDirectory = extension.outputRelativePath ? dirname(extensionOutputPath) : extensionOutputPath const extensionPoints = await getExtensionPoints(extension, url, buildDirectory) let metafields: {namespace: string; key: string}[] | null = null @@ -103,7 +105,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