From 5cac057a10e70379621814a8c765ea436a4fab46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 8 Jul 2026 15:16:46 +0200 Subject: [PATCH] new refactor deploy matching version 2 --- packages/app/src/cli/models/app/app.test.ts | 7 +- packages/app/src/cli/models/app/app.ts | 8 +- .../src/cli/models/app/identifiers.test.ts | 38 +- .../app/src/cli/models/app/identifiers.ts | 42 +- .../extensions/extension-instance.test.ts | 27 +- .../models/extensions/extension-instance.ts | 29 +- .../app/write-app-configuration-file.ts | 48 - packages/app/src/cli/services/context.test.ts | 50 +- packages/app/src/cli/services/context.ts | 12 +- .../context/breakdown-extensions.test.ts | 1772 ++--------------- .../services/context/breakdown-extensions.ts | 375 +--- .../context/deploy-app-version-migrations.ts | 100 + .../context/deploy-app-version.test.ts | 774 +++++++ .../services/context/deploy-app-version.ts | 191 ++ .../context/id-manual-matching.test.ts | 181 -- .../services/context/id-manual-matching.ts | 50 - .../cli/services/context/id-matching.test.ts | 322 --- .../src/cli/services/context/id-matching.ts | 231 --- .../context/identifiers-extensions.test.ts | 1099 ---------- .../context/identifiers-extensions.ts | 322 --- .../cli/services/context/identifiers.test.ts | 249 --- .../src/cli/services/context/identifiers.ts | 64 +- .../app/src/cli/services/context/prompts.ts | 34 +- packages/app/src/cli/services/deploy.test.ts | 45 +- packages/app/src/cli/services/deploy.ts | 149 +- .../src/cli/services/deploy/bundle.test.ts | 108 +- .../app/src/cli/services/deploy/bundle.ts | 10 - .../src/cli/services/deploy/upload.test.ts | 10 +- .../app/src/cli/services/deploy/upload.ts | 22 +- .../cli/services/dev/migrate-app-module.ts | 32 +- .../services/dev/migrate-flow-extension.ts | 2 +- .../services/dev/migrate-to-ui-extension.ts | 2 +- .../app/src/cli/services/import-extensions.ts | 10 +- packages/app/src/cli/services/release.test.ts | 4 +- packages/app/src/cli/services/release.ts | 21 +- .../app-management-client.ts | 10 +- .../cli-kit/src/public/common/object.test.ts | 35 + packages/cli-kit/src/public/common/object.ts | 31 + 38 files changed, 1537 insertions(+), 4979 deletions(-) create mode 100644 packages/app/src/cli/services/context/deploy-app-version-migrations.ts create mode 100644 packages/app/src/cli/services/context/deploy-app-version.test.ts create mode 100644 packages/app/src/cli/services/context/deploy-app-version.ts delete mode 100644 packages/app/src/cli/services/context/id-manual-matching.test.ts delete mode 100644 packages/app/src/cli/services/context/id-manual-matching.ts delete mode 100644 packages/app/src/cli/services/context/id-matching.test.ts delete mode 100644 packages/app/src/cli/services/context/id-matching.ts delete mode 100644 packages/app/src/cli/services/context/identifiers-extensions.test.ts delete mode 100644 packages/app/src/cli/services/context/identifiers-extensions.ts delete mode 100644 packages/app/src/cli/services/context/identifiers.test.ts diff --git a/packages/app/src/cli/models/app/app.test.ts b/packages/app/src/cli/models/app/app.test.ts index ed48a11f709..89c4e4ffd64 100644 --- a/packages/app/src/cli/models/app/app.test.ts +++ b/packages/app/src/cli/models/app/app.test.ts @@ -651,12 +651,7 @@ describe('manifest', () => { }) // When - const manifest = await app.manifest({ - app: 'API_KEY', - extensions: {app_access: 'UUID_A'}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - }) + const manifest = await app.manifest({app_access: 'UUID_A'}) // Then expect(manifest).toEqual({ diff --git a/packages/app/src/cli/models/app/app.ts b/packages/app/src/cli/models/app/app.ts index c2fa8ddf53a..73eec01f3fc 100644 --- a/packages/app/src/cli/models/app/app.ts +++ b/packages/app/src/cli/models/app/app.ts @@ -1,6 +1,6 @@ import {AppErrors, isWebType} from './loader.js' import {ensurePathStartsWithSlash} from './validation/common.js' -import {Identifiers} from './identifiers.js' +import {ExtensionUuidsByLocalIdentifier} from './identifiers.js' import {ExtensionInstance} from '../extensions/extension-instance.js' import {FunctionConfigType} from '../extensions/specifications/function.js' import {ExtensionSpecification, RemoteAwareExtensionSpecification} from '../extensions/specification.js' @@ -245,7 +245,7 @@ export interface AppInterface< * If creating an app on the platform based on this app and its configuration, what default options should the app take? */ creationDefaultOptions(): CreateAppOptions - manifest: (identifiers: Identifiers | undefined) => Promise + manifest: (appModuleUuids: ExtensionUuidsByLocalIdentifier | undefined) => Promise removeExtension: (extensionUid: string) => void updateHiddenConfig: (values: Partial) => Promise setDevApplicationURLs: (devApplicationURLs: ApplicationURLs) => void @@ -331,7 +331,7 @@ export class App< this.realExtensions.forEach((ext) => ext.patchWithAppDevURLs(devApplicationURLs)) } - async manifest(identifiers: Identifiers | undefined): Promise { + async manifest(appModuleUuids: ExtensionUuidsByLocalIdentifier | undefined): Promise { const modules = await Promise.all( this.realExtensions.map(async (module) => { const config = await module.deployConfig({ @@ -342,7 +342,7 @@ export class App< type: module.externalType, handle: module.handle, uid: module.uid, - uuid: identifiers?.extensions[module.localIdentifier], + uuid: appModuleUuids?.[module.localIdentifier], assets: module.uid, target: module.contextValue, config: (config ?? {}) as JsonMapType, diff --git a/packages/app/src/cli/models/app/identifiers.test.ts b/packages/app/src/cli/models/app/identifiers.test.ts index e0615febd4c..b39f43327b7 100644 --- a/packages/app/src/cli/models/app/identifiers.test.ts +++ b/packages/app/src/cli/models/app/identifiers.test.ts @@ -18,11 +18,9 @@ describe('updateAppIdentifiers', () => { // When const gotApp = await updateAppIdentifiers({ app, - identifiers: { - app: 'FOO', - extensions: { - my_extension: 'BAR', - }, + appApiKey: 'FOO', + extensionUuids: { + my_extension: 'BAR', }, command: 'deploy', }) @@ -51,11 +49,9 @@ describe('updateAppIdentifiers', () => { // When const gotApp = await updateAppIdentifiers({ app, - identifiers: { - app: 'FOO', - extensions: { - my_extension: 'BAR', - }, + appApiKey: 'FOO', + extensionUuids: { + my_extension: 'BAR', }, command: 'deploy', }) @@ -85,11 +81,9 @@ describe('updateAppIdentifiers', () => { await updateAppIdentifiers( { app, - identifiers: { - app: 'FOO', - extensions: { - my_extension: 'BAR', - }, + appApiKey: 'FOO', + extensionUuids: { + my_extension: 'BAR', }, command: 'deploy', }, @@ -152,11 +146,9 @@ type = "ui_extension"`, await updateAppIdentifiers( { app, - identifiers: { - app: 'FOO', - extensions: { - my_extension: 'BAR', - }, + appApiKey: 'FOO', + extensionUuids: { + my_extension: 'BAR', }, command: 'deploy', }, @@ -203,8 +195,7 @@ describe('getAppIdentifiers', () => { }) // Then - expect(got.app).toEqual('FOO') - expect((got.extensions ?? {})['test-ui-extension']).toEqual('BAR') + expect(got['test-ui-extension']).toEqual('BAR') }) }) @@ -229,8 +220,7 @@ describe('getAppIdentifiers', () => { ) // Then - expect(got.app).toEqual('FOO') - expect((got.extensions ?? {})['test-ui-extension']).toEqual('BAR') + expect(got['test-ui-extension']).toEqual('BAR') }) }) }) diff --git a/packages/app/src/cli/models/app/identifiers.ts b/packages/app/src/cli/models/app/identifiers.ts index ed38e1ecdb0..ab92c8d8c39 100644 --- a/packages/app/src/cli/models/app/identifiers.ts +++ b/packages/app/src/cli/models/app/identifiers.ts @@ -6,35 +6,20 @@ import {joinPath} from '@shopify/cli-kit/node/path' import {fileExists, readFile, writeFile} from '@shopify/cli-kit/node/fs' import type {AppInterface} from './app.js' -export interface IdentifiersExtensions { +export interface ExtensionUuidsByLocalIdentifier { [localIdentifier: string]: string } -export interface Identifiers { - /** Application's API Key */ - app: string - - /** - * The extensions' unique identifiers. - */ - extensions: IdentifiersExtensions - - /** - * The extensions' numeric identifiers (expressed as a string). - */ - extensionIds: IdentifiersExtensions - - /** - * The extensions' unique identifiers which uuid is not managed. - */ - extensionsNonUuidManaged: IdentifiersExtensions +export interface DeployIdentifiers { + appModuleUuids: ExtensionUuidsByLocalIdentifier + appModuleRegistrationIds: ExtensionUuidsByLocalIdentifier } -type UuidOnlyIdentifiers = Omit type UpdateAppIdentifiersCommand = 'dev' | 'deploy' | 'release' | 'import-extensions' interface UpdateAppIdentifiersOptions { app: AppInterface - identifiers: UuidOnlyIdentifiers + appApiKey: string + extensionUuids: ExtensionUuidsByLocalIdentifier command: UpdateAppIdentifiersCommand } @@ -44,7 +29,7 @@ interface UpdateAppIdentifiersOptions { * @returns An copy of the app with the environment updated to reflect the updated identifiers. */ export async function updateAppIdentifiers( - {app, identifiers, command}: UpdateAppIdentifiersOptions, + {app, appApiKey, extensionUuids, command}: UpdateAppIdentifiersOptions, systemEnvironment = process.env, ): Promise { let dotenvFile = app.dotenv @@ -55,12 +40,12 @@ export async function updateAppIdentifiers( } const updatedVariables: {[key: string]: string} = {...(app.dotenv?.variables ?? {})} if (!systemEnvironment[app.idEnvironmentVariableName]) { - updatedVariables[app.idEnvironmentVariableName] = identifiers.app + updatedVariables[app.idEnvironmentVariableName] = appApiKey } - Object.keys(identifiers.extensions).forEach((identifier) => { + Object.keys(extensionUuids).forEach((identifier) => { const envVariable = `SHOPIFY_${constantize(identifier)}_ID` if (!systemEnvironment[envVariable]) { - updatedVariables[envVariable] = identifiers.extensions[identifier]! + updatedVariables[envVariable] = extensionUuids[identifier]! } }) @@ -91,7 +76,7 @@ interface GetAppIdentifiersOptions { export function getAppIdentifiers( {app}: GetAppIdentifiersOptions, systemEnvironment = process.env, -): Partial { +): ExtensionUuidsByLocalIdentifier { const envVariables = { ...app.dotenv?.variables, ...(systemEnvironment as {[variable: string]: string}), @@ -104,8 +89,5 @@ export function getAppIdentifiers( } app.allExtensions.forEach(processExtension) - return { - app: envVariables[app.idEnvironmentVariableName], - extensions: extensionsIdentifiers, - } + return extensionsIdentifiers } diff --git a/packages/app/src/cli/models/extensions/extension-instance.test.ts b/packages/app/src/cli/models/extensions/extension-instance.test.ts index bfb1a1680fe..99191649959 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.test.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.test.ts @@ -233,16 +233,11 @@ describe('deployConfig', async () => { }) describe('bundleConfig', async () => { - test('returns the uuid from extensions when the extension is uuid managed', async () => { + test('returns the uuid from appModuleUuids', async () => { const extensionInstance = await testThemeExtensions() const got = await extensionInstance.bundleConfig({ - identifiers: { - extensions: {'theme-extension-name': 'theme-uuid'}, - extensionIds: {}, - app: 'My app', - extensionsNonUuidManaged: {}, - }, + appModuleUuids: {'theme-extension-name': 'theme-uuid'}, developerPlatformClient, apiKey: 'apiKey', appConfiguration: placeholderAppConfiguration, @@ -260,12 +255,7 @@ describe('bundleConfig', async () => { const extensionInstance = await testPaymentExtensions() const got = await extensionInstance.bundleConfig({ - identifiers: { - extensions: {'payment-extension-name': 'payment-uuid'}, - extensionIds: {}, - app: 'My app', - extensionsNonUuidManaged: {}, - }, + appModuleUuids: {'payment-extension-name': 'payment-uuid'}, developerPlatformClient, apiKey: 'apiKey', appConfiguration: placeholderAppConfiguration, @@ -279,16 +269,11 @@ describe('bundleConfig', async () => { ) }) - test('returns the uuid from extensionsNonUuidManaged when the extension is not uuid managed', async () => { + test('returns the uuid for a non-uuid-managed extension', async () => { const extensionInstance = await testAppConfigExtensions() const got = await extensionInstance.bundleConfig({ - identifiers: { - extensions: {}, - extensionIds: {}, - app: 'My app', - extensionsNonUuidManaged: {point_of_sale: 'uuid'}, - }, + appModuleUuids: {point_of_sale: 'uuid'}, developerPlatformClient, apiKey: 'apiKey', appConfiguration: placeholderAppConfiguration, @@ -416,7 +401,7 @@ describe('buildUIDFromStrategy', async () => { // Given const extensionInstance = await testSingleWebhookSubscriptionExtension() // Then - expect(extensionInstance.uid).toBe('orders/delete::undefined::https://my-app.com/webhooks') + expect(extensionInstance.uid).toBe('orders/delete::::https://my-app.com/webhooks') }) test('returns a custom string when strategy is dynamic and it is a webhook subscription extension with filters', async () => { diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index f651bfd5c12..23ae5cb345c 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -3,7 +3,7 @@ import {FunctionConfigType} from './specifications/function.js' import {DevSessionWatchConfig, ExtensionFeature, ExtensionSpecification} from './specification.js' import {SingleWebhookSubscriptionType} from './specifications/app_config_webhook_schemas/webhooks_schema.js' import {ExtensionBuildOptions} from '../../services/build/extension.js' -import {Identifiers} from '../app/identifiers.js' +import {ExtensionUuidsByLocalIdentifier} from '../app/identifiers.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {AppConfiguration} from '../app/app.js' import {ApplicationURLs} from '../../services/dev/urls.js' @@ -245,10 +245,8 @@ export class ExtensionInstance(schema: T, config: unknown): unknown => { - if (schema === null || schema === undefined) return null - if (schema instanceof zod.ZodNullable || schema instanceof zod.ZodOptional) - return rewriteConfiguration(schema.unwrap(), config) - if (schema instanceof zod.ZodArray) { - return (config as unknown[]).map((item) => rewriteConfiguration(schema.element, item)) - } - if (schema instanceof zod.ZodEffects) { - return rewriteConfiguration(schema._def.schema, config) - } - if (schema instanceof zod.ZodObject) { - const entries = Object.entries(schema.shape) - const confObj = config as {[key: string]: unknown} - let result: {[key: string]: unknown} = {} - entries.forEach(([key, subSchema]) => { - if (confObj !== undefined && confObj[key] !== undefined) { - let value = rewriteConfiguration(subSchema as T, confObj[key]) - if (!(value instanceof Array) && value instanceof Object && Object.keys(value as object).length === 0) { - value = undefined - } - result = {...result, [key]: value} - } - }) - - // if dynamic config was enabled, its possible to have more keys in the file than the schema - const blockedKeys = ['scopes'] - - Object.entries(confObj) - .filter(([key]) => !blockedKeys.includes(key)) - .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) - .forEach(([key, value]) => { - if (!entries.map(([key]) => key).includes(key)) { - result = {...result, [key]: value} - } - }) - - return result - } - return config -} - function addDefaultCommentsToToml(fileString: string) { const appTomlInitialComment = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration\n` const appTomlScopesComment = `\n# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes` diff --git a/packages/app/src/cli/services/context.test.ts b/packages/app/src/cli/services/context.test.ts index dd8bf8e6488..d3cde5781c9 100644 --- a/packages/app/src/cli/services/context.test.ts +++ b/packages/app/src/cli/services/context.test.ts @@ -1,7 +1,7 @@ import {fetchOrganizations, fetchOrgFromId} from './dev/fetch.js' import {selectOrCreateApp} from './dev/select-app.js' import {selectStore} from './dev/select-store.js' -import {ensureDeploymentIdsPresence} from './context/identifiers.js' +import {ensureDeployIdentifiersFromAppVersion} from './context/deploy-app-version.js' import {appFromIdentifiers, ensureDeployContext} from './context.js' import {CachedAppInfo} from './local-storage.js' import link from './app/config/link.js' @@ -110,7 +110,7 @@ vi.mock('./dev/select-store') vi.mock('../prompts/dev') vi.mock('@shopify/organizations') vi.mock('../models/app/identifiers') -vi.mock('./context/identifiers') +vi.mock('./context/deploy-app-version') vi.mock('../models/app/loader.js') vi.mock('@shopify/cli-kit/node/node-package-manager.js') vi.mock('@shopify/cli-kit/node/ui') @@ -134,7 +134,7 @@ beforeAll(async () => { }) beforeEach(async () => { - vi.mocked(getAppIdentifiers).mockReturnValue({app: undefined}) + vi.mocked(getAppIdentifiers).mockReturnValue({}) vi.mocked(selectOrganizationPrompt).mockResolvedValue(ORG1) vi.mocked(selectOrCreateApp).mockResolvedValue(APP1) vi.mocked(selectStore).mockResolvedValue(STORE1) @@ -174,13 +174,7 @@ describe('ensureDeployContext', () => { test('does not abort when force is true and include_config_on_deploy is not set', async () => { // Given const app = testAppWithConfig({config: {client_id: APP2.apiKey}}) - const identifiers = { - app: APP2.apiKey, - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - vi.mocked(ensureDeploymentIdsPresence).mockResolvedValue(identifiers) + vi.mocked(ensureDeployIdentifiersFromAppVersion).mockResolvedValue(emptyDeployIdentifiers()) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') const options = { @@ -196,13 +190,7 @@ describe('ensureDeployContext', () => { test('removes the include_config_on_deploy field when using app management API and the value is true', async () => { // Given const app = testAppWithConfig({config: {client_id: APP2.apiKey, build: {include_config_on_deploy: true}}}) - const identifiers = { - app: APP2.apiKey, - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - vi.mocked(ensureDeploymentIdsPresence).mockResolvedValue(identifiers) + vi.mocked(ensureDeployIdentifiersFromAppVersion).mockResolvedValue(emptyDeployIdentifiers()) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') mockTomlFileRemove.mockResolvedValue(undefined) @@ -238,13 +226,7 @@ describe('ensureDeployContext', () => { test('removes the include_config_on_deploy field when using app management API and the value is false', async () => { // Given const app = testAppWithConfig({config: {client_id: APP2.apiKey, build: {include_config_on_deploy: false}}}) - const identifiers = { - app: APP2.apiKey, - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - vi.mocked(ensureDeploymentIdsPresence).mockResolvedValue(identifiers) + vi.mocked(ensureDeployIdentifiersFromAppVersion).mockResolvedValue(emptyDeployIdentifiers()) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') mockTomlFileRemove.mockResolvedValue(undefined) @@ -280,13 +262,7 @@ describe('ensureDeployContext', () => { test('sets didMigrateExtensionsToDevDash to true when app modules are missing registration IDs', async () => { // Given const app = testAppWithConfig({config: {client_id: APP2.apiKey}}) - const identifiers = { - app: APP2.apiKey, - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - vi.mocked(ensureDeploymentIdsPresence).mockResolvedValue(identifiers) + vi.mocked(ensureDeployIdentifiersFromAppVersion).mockResolvedValue(emptyDeployIdentifiers()) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') const activeAppVersion = { @@ -325,13 +301,7 @@ describe('ensureDeployContext', () => { test('sets didMigrateExtensionsToDevDash to false when all app modules have registration IDs', async () => { // Given const app = testAppWithConfig({config: {client_id: APP2.apiKey}}) - const identifiers = { - app: APP2.apiKey, - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - vi.mocked(ensureDeploymentIdsPresence).mockResolvedValue(identifiers) + vi.mocked(ensureDeployIdentifiersFromAppVersion).mockResolvedValue(emptyDeployIdentifiers()) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') const activeAppVersion = { @@ -422,6 +392,10 @@ describe('appFromIdentifiers', () => { }) }) +function emptyDeployIdentifiers() { + return {appModuleUuids: {}, appModuleRegistrationIds: {}} +} + const renderTryMessage = (isOrg: boolean, identifier: string) => [ { list: { diff --git a/packages/app/src/cli/services/context.ts b/packages/app/src/cli/services/context.ts index d2d81730080..8f7373f83c5 100644 --- a/packages/app/src/cli/services/context.ts +++ b/packages/app/src/cli/services/context.ts @@ -1,11 +1,11 @@ import {selectOrCreateApp} from './dev/select-app.js' import {fetchOrganizations, fetchOrgFromId} from './dev/fetch.js' -import {ensureDeploymentIdsPresence} from './context/identifiers.js' +import {ensureDeployIdentifiersFromAppVersion} from './context/deploy-app-version.js' import {CachedAppInfo} from './local-storage.js' import {DeployOptions} from './deploy.js' import {formatConfigInfoBody} from './format-config-info-body.js' import {AppInterface, AppLinkedInterface} from '../models/app/app.js' -import {Identifiers, updateAppIdentifiers, getAppIdentifiers} from '../models/app/identifiers.js' +import {DeployIdentifiers, getAppIdentifiers} from '../models/app/identifiers.js' import {Organization, OrganizationApp, OrganizationSource, OrganizationStore} from '../models/organization.js' import metadata from '../metadata.js' import {getAppConfigurationFileName} from '../models/app/loader.js' @@ -101,7 +101,7 @@ export const appFromIdentifiers = async (options: AppFromIdOptions): Promise !version.registrationId) } - return {identifiers, didMigrateExtensionsToDevDash} + return {deployIdentifiers, didMigrateExtensionsToDevDash} } async function removeIncludeConfigOnDeployField(localApp: AppInterface) { diff --git a/packages/app/src/cli/services/context/breakdown-extensions.test.ts b/packages/app/src/cli/services/context/breakdown-extensions.test.ts index 3e67d706db5..1d8d8c81d3e 100644 --- a/packages/app/src/cli/services/context/breakdown-extensions.test.ts +++ b/packages/app/src/cli/services/context/breakdown-extensions.test.ts @@ -1,184 +1,23 @@ -/* eslint-disable @shopify/prefer-module-scope-constants */ -import {ensureExtensionsIds} from './identifiers-extensions.js' import { + buildConfigExtensionIdentifiersBreakdown, buildDashboardBreakdownInfo, buildExtensionBreakdownInfo, - configExtensionsIdentifiersBreakdown, - extensionsIdentifiersDeployBreakdown, + configExtensionsIdentifiersReleaseBreakdown, extensionsIdentifiersReleaseBreakdown, } from './breakdown-extensions.js' -import {RemoteSource} from './identifiers.js' -import {AppInterface, CurrentAppConfiguration} from '../../models/app/app.js' +import {AppModuleVersion} from '../../utilities/developer-platform-client.js' +import {AppVersionsDiffExtensionSchema} from '../../api/graphql/app_versions_diff.js' import { - buildVersionedAppSchema, testApp, testAppConfigExtensions, testDeveloperPlatformClient, - testUIExtension, testOrganizationApp, } from '../../models/app/app.test-data.js' -import {OrganizationApp, MinimalAppIdentifiers} from '../../models/organization.js' -import {ExtensionInstance} from '../../models/extensions/extension-instance.js' -import {AppVersionsDiffExtensionSchema} from '../../api/graphql/app_versions_diff.js' -import {versionDiffByVersion} from '../release/version-diff.js' -import {AppVersion, AppModuleVersion, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-specifications.js' -import {describe, vi, test, beforeAll, expect} from 'vitest' -import {setPathValue} from '@shopify/cli-kit/common/object' - -const REGISTRATION_A: RemoteSource = { - uuid: 'UUID_A', - id: 'A', - title: 'A', - type: 'CHECKOUT_POST_PURCHASE', -} - -const REGISTRATION_DASH_MIGRATED_A: RemoteSource = { - uuid: 'UUID_DM_A', - id: 'DM_A', - title: 'DM A', - type: 'CUSTOMER_ACCOUNTS_UI_EXTENSION', -} - -const REGISTRATION_DASHBOARD_A = { - id: 'D_A', - title: 'Dashboard A', - uuid: 'UUID_D_A', - type: 'flow_action_definition', - activeVersion: { - config: '{}', - }, -} - -const REGISTRATION_DASHBOARD_NEW = { - id: 'D_NEW', - title: 'Dashboard New', - uuid: 'UUID_D_NEW', - type: 'flow_action_definition', - activeVersion: { - config: '{}', - }, -} - -const MODULE_CLI_A: AppModuleVersion = { - registrationId: 'A', - registrationUuid: 'UUID_A', - registrationTitle: 'Checkout post purchase', - type: 'checkout_post_purchase', - specification: { - identifier: 'checkout_post_purchase', - name: 'Post purchase UI extension', - experience: 'extension', - options: { - managementExperience: 'cli', - }, - }, -} - -const MODULE_CLI_A_NO_UID: AppModuleVersion = { - registrationId: '', - registrationUuid: 'UUID_A', - registrationTitle: 'Checkout post purchase', - type: 'checkout_post_purchase', - specification: { - identifier: 'checkout_post_purchase', - name: 'Post purchase UI extension', - experience: 'extension', - options: { - managementExperience: 'cli', - }, - }, -} - -const MODULE_CLI_A_EXTERNAL_IDENTIFIER: AppModuleVersion = { - registrationId: 'A', - registrationUuid: 'UUID_A', - registrationTitle: 'Checkout post purchase', - type: 'checkout_post_purchase_external', - specification: { - identifier: 'checkout_post_purchase_external', - name: 'Post purchase UI extension', - experience: 'extension', - options: { - managementExperience: 'cli', - }, - }, -} - -const MODULE_DASHBOARD_MIGRATED_CLI_A: AppModuleVersion = { - registrationId: 'A', - registrationUuid: 'UUID_A', - registrationTitle: 'Checkout post purchase', - type: 'checkout_post_purchase', - specification: { - identifier: 'checkout_post_purchase', - name: 'Post purchase UI extension', - experience: 'extension', - options: { - managementExperience: 'cli', - }, - }, -} - -const MODULE_CONFIG_A: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'app_access', - specification: { - identifier: 'app_access', - name: 'App access', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, -} - -const MODULE_DASHBOARD_A: AppModuleVersion = { - registrationId: 'D_A', - registrationUuid: 'UUID_D_A', - registrationTitle: 'Dashboard A', - type: 'flow_action_definition', - specification: { - identifier: 'flow_action_definition', - name: 'Flow action definition', - experience: 'deprecated', - options: { - managementExperience: 'dashboard', - }, - }, -} - -const MODULE_DELETED_DASHBOARD_B: AppModuleVersion = { - registrationId: 'D_B', - registrationUuid: 'UUID_D_B', - registrationTitle: 'Dashboard Deleted B', - type: 'flow_action_definition', - specification: { - identifier: 'flow_action_definition', - name: 'Flow action definition', - experience: 'deprecated', - options: { - managementExperience: 'dashboard', - }, - }, -} +import {versionDiffByVersion} from '../release/version-diff.js' +import {describe, expect, test, vi} from 'vitest' -const MODULE_DELETED_CLI_B: AppModuleVersion = { - registrationId: 'B', - registrationUuid: 'UUID_B', - registrationTitle: 'Checkout post purchase Deleted B', - type: 'checkout_post_purchase', - specification: { - identifier: 'checkout_post_purchase', - name: 'Post purchase UI extension', - experience: 'extension', - options: { - managementExperience: 'cli', - }, - }, -} +vi.mock('../release/version-diff') const VERSION_DIFF_CONFIG_A: AppVersionsDiffExtensionSchema = { uuid: 'UUID_C_A', @@ -240,372 +79,21 @@ const VERSION_DIFF_DELETED_CLI_WEBHOOK: AppVersionsDiffExtensionSchema = { }, } -const APP_URL_SPEC: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp.com'}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, -} - -const API_VERSION_SPEC: AppModuleVersion = { - registrationId: 'C_Z', - registrationUuid: 'UUID_C_Z', - registrationTitle: 'Registration title', - type: 'webhooks', - config: {api_version: '2023-04'}, - specification: { - identifier: 'webhooks', - name: 'Webhooks', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, -} - -const APP_CONFIGURATION: CurrentAppConfiguration = { - name: 'my app', - client_id: '12345', - webhooks: { - api_version: '2023-04', - }, - application_url: 'https://myapp.com', - embedded: false, - build: { - include_config_on_deploy: true, - }, -} - -const LOCAL_APP = async ( - uiExtensions: ExtensionInstance[], - configuration: CurrentAppConfiguration = APP_CONFIGURATION, - flags = [], -): Promise => { - const versionSchema = await buildVersionedAppSchema() - - const localApp = testApp({ - name: 'my-app', - directory: '/app', - configuration, - allExtensions: [...uiExtensions, await testAppConfigExtensions()], - specifications: await loadLocalExtensionsSpecifications(), - configSchema: versionSchema.schema, - }) - - setPathValue(localApp, 'remoteFlags', flags) - return localApp -} - -const options = async (params: { - uiExtensions: ExtensionInstance[] - identifiers?: any - remoteApp?: OrganizationApp - release?: boolean - developerPlatformClient?: DeveloperPlatformClient - activeAppVersion?: AppVersion -}) => { - return { - app: await LOCAL_APP(params.uiExtensions), - developerPlatformClient: params.developerPlatformClient ?? testDeveloperPlatformClient(), - appId: 'appId', - appName: 'appName', - envIdentifiers: {extensions: params.identifiers ?? {}}, - force: false, - remoteApp: params.remoteApp ?? testOrganizationApp(), - release: params.release ?? true, - activeAppVersion: params.activeAppVersion, - } -} - -const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient() - -let EXTENSION_A: ExtensionInstance -let EXTENSION_A_2: ExtensionInstance -let DASH_MIGRATED_EXTENSION_A: ExtensionInstance -let uiExtensions: ExtensionInstance[] - -vi.mock('@shopify/cli-kit/node/session') -vi.mock('../dev/fetch') -vi.mock('./identifiers-extensions') -vi.mock('../release/version-diff') -vi.mock('../../prompts/deploy-release') - -beforeAll(async () => { - EXTENSION_A = await testUIExtension({ - directory: '/EXTENSION_A', - configuration: { - name: 'EXTENSION A', - type: 'checkout_post_purchase', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - }) - - EXTENSION_A_2 = await testUIExtension({ - directory: '/EXTENSION_A_2', - configuration: { - name: 'EXTENSION A 2', - type: 'checkout_post_purchase', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - }) - - DASH_MIGRATED_EXTENSION_A = await testUIExtension({ - directory: '/DASH_MIGRATED_EXTENSION_A', - configuration: { - name: 'DASH MIGRATED EXTENSION A', - type: 'pos_ui_extension', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - }) - - uiExtensions = [EXTENSION_A, EXTENSION_A_2] -}) - -describe('extensionsIdentifiersDeployBreakdown', () => { - describe('deploy with no release', () => { - test('returns the current valid local extensions content', async () => { - // Given - const extensionsToConfirm = { - validMatches: {EXTENSION_A: 'UUID_A'}, - dashboardOnlyExtensions: [], - extensionsToCreate: [EXTENSION_A_2], - didMigrateDashboardExtensions: false, - } - vi.mocked(ensureExtensionsIds).mockResolvedValue(extensionsToConfirm) - const remoteExtensionRegistrations = { - app: { - extensionRegistrations: [REGISTRATION_A], - configurationRegistrations: [], - dashboardManagedExtensionRegistrations: [REGISTRATION_DASHBOARD_A], - }, - } - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - appExtensionRegistrations: (_app: MinimalAppIdentifiers) => Promise.resolve(remoteExtensionRegistrations), - }) - - // When - const result = await extensionsIdentifiersDeployBreakdown( - await options({uiExtensions, release: false, developerPlatformClient}), - ) - - // Then - expect(result).toEqual({ - extensionIdentifiersBreakdown: { - onlyRemote: [], - toCreate: [], - unchanged: [ - buildExtensionBreakdownInfo('EXTENSION_A', undefined), - buildExtensionBreakdownInfo('extension-a-2', undefined), - ], - toUpdate: [], - }, - extensionsToConfirm, - remoteExtensionsRegistrations: remoteExtensionRegistrations.app, - }) - }) - }) - describe('deploy with release', () => { - test('and there is no active version then every extension should be created', async () => { - // Given - const extensionsToConfirm = { - validMatches: {EXTENSION_A: 'UUID_A'}, - dashboardOnlyExtensions: [REGISTRATION_DASHBOARD_A], - extensionsToCreate: [EXTENSION_A_2], - didMigrateDashboardExtensions: false, - } - vi.mocked(ensureExtensionsIds).mockResolvedValue(extensionsToConfirm) - const remoteExtensionRegistrations = { - app: { - extensionRegistrations: [REGISTRATION_A], - configurationRegistrations: [], - dashboardManagedExtensionRegistrations: [REGISTRATION_DASHBOARD_A], - }, - } - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - appExtensionRegistrations: (_app: MinimalAppIdentifiers) => Promise.resolve(remoteExtensionRegistrations), - }) - - // When - const result = await extensionsIdentifiersDeployBreakdown(await options({uiExtensions, developerPlatformClient})) - - // Then - expect(result).toEqual({ - extensionIdentifiersBreakdown: { - onlyRemote: [], - toCreate: [ - buildExtensionBreakdownInfo('EXTENSION_A', 'UUID_A'), - buildExtensionBreakdownInfo('extension-a-2', 'test-ui-extension-uid'), - buildDashboardBreakdownInfo('Dashboard A'), - ], - toUpdate: [], - unchanged: [], - }, - extensionsToConfirm, - remoteExtensionsRegistrations: remoteExtensionRegistrations.app, - }) - }) - test('and there is an active version with only app config app modules then every extension should be created', async () => { - // Given - const extensionsToConfirm = { - validMatches: {EXTENSION_A: 'UUID_A'}, - dashboardOnlyExtensions: [REGISTRATION_DASHBOARD_A], - extensionsToCreate: [EXTENSION_A_2], - didMigrateDashboardExtensions: false, - } - vi.mocked(ensureExtensionsIds).mockResolvedValue(extensionsToConfirm) - const remoteExtensionRegistrations = { - app: { - extensionRegistrations: [REGISTRATION_A], - configurationRegistrations: [], - dashboardManagedExtensionRegistrations: [REGISTRATION_DASHBOARD_A], - }, - } - - const activeVersion = {appModuleVersions: [MODULE_CONFIG_A, MODULE_DASHBOARD_A]} - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - appExtensionRegistrations: (_app: MinimalAppIdentifiers) => Promise.resolve(remoteExtensionRegistrations), - activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(activeVersion), - }) - - // When - const result = await extensionsIdentifiersDeployBreakdown(await options({uiExtensions, developerPlatformClient})) - - // Then - expect(result).toEqual({ - extensionIdentifiersBreakdown: { - onlyRemote: [], - toCreate: [ - buildExtensionBreakdownInfo('EXTENSION_A', 'UUID_A'), - buildExtensionBreakdownInfo('extension-a-2', 'test-ui-extension-uid'), - ], - toUpdate: [], - unchanged: [buildDashboardBreakdownInfo('Dashboard A')], - }, - extensionsToConfirm, - remoteExtensionsRegistrations: remoteExtensionRegistrations.app, - }) - }) - test('and there is an active version with modules without UID, those should be returned as toUpdate, the rest, unchanged', async () => { - // Given - const extensionsToConfirm = { - validMatches: {EXTENSION_A: 'UUID_A'}, - dashboardOnlyExtensions: [REGISTRATION_DASHBOARD_A], - extensionsToCreate: [EXTENSION_A_2], - didMigrateDashboardExtensions: false, - } - vi.mocked(ensureExtensionsIds).mockResolvedValue(extensionsToConfirm) - const remoteExtensionRegistrations = { - app: { - extensionRegistrations: [REGISTRATION_A], - configurationRegistrations: [], - dashboardManagedExtensionRegistrations: [REGISTRATION_DASHBOARD_A], - }, - } - const activeAppVersion = { - appModuleVersions: [MODULE_CONFIG_A, MODULE_DASHBOARD_A, MODULE_CLI_A_NO_UID], - } - let fetchActiveAppVersionCalled = false - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - appExtensionRegistrations: (_app: MinimalAppIdentifiers) => Promise.resolve(remoteExtensionRegistrations), - activeAppVersion: (_app: MinimalAppIdentifiers) => { - fetchActiveAppVersionCalled = true - return Promise.resolve(activeAppVersion) - }, - }) - - // When - const result = await extensionsIdentifiersDeployBreakdown( - await options({uiExtensions, developerPlatformClient, activeAppVersion}), - ) - - // Then - expect(fetchActiveAppVersionCalled).toBe(false) - expect(result).toEqual({ - extensionIdentifiersBreakdown: { - onlyRemote: [], - toCreate: [buildExtensionBreakdownInfo('extension-a-2', 'test-ui-extension-uid')], - toUpdate: [buildExtensionBreakdownInfo('EXTENSION_A', undefined)], - unchanged: [buildDashboardBreakdownInfo('Dashboard A')], - }, - extensionsToConfirm, - remoteExtensionsRegistrations: remoteExtensionRegistrations.app, - }) - }) - }) -}) - describe('extensionsIdentifiersReleaseBreakdown', () => { test('when active version only includes app config modules then the response will be empty', async () => { - // Given - const versionDiff = { - versionsDiff: { - added: [], - updated: [VERSION_DIFF_CONFIG_A], - removed: [], - }, - versionDetails: { - id: 1, - uuid: 'uuid', - location: 'location', - versionTag: '1.0.0', - message: 'message', - appModuleVersions: [], - }, - } + const versionDiff = buildVersionDiff({ + added: [], + updated: [VERSION_DIFF_CONFIG_A], + removed: [], + }) vi.mocked(versionDiffByVersion).mockResolvedValue(versionDiff) - // When - const result = await extensionsIdentifiersReleaseBreakdown(developerPlatformClient, testOrganizationApp(), ' 1.0.0') + const result = await extensionsIdentifiersReleaseBreakdown( + testDeveloperPlatformClient(), + testOrganizationApp(), + '1.0.0', + ) - // Then expect(result).toEqual({ extensionIdentifiersBreakdown: { onlyRemote: [], @@ -617,63 +105,54 @@ describe('extensionsIdentifiersReleaseBreakdown', () => { }) }) - test('when active version only includes not only app config modules then the response will return them', async () => { - // Given - const versionDiff = { - versionsDiff: { - added: [VERSION_DIFF_CLI_A], - updated: [VERSION_DIFF_CONFIG_A, VERSION_DIFF_DASH_A], - removed: [VERSION_DIFF_DELETED_CLI_B], - }, - versionDetails: { - id: 1, - uuid: 'uuid', - location: 'location', - versionTag: '1.0.0', - message: 'message', - appModuleVersions: [], - }, - } + test('maps release version extension and dashboard changes into the prompt breakdown', async () => { + const versionDiff = buildVersionDiff({ + added: [VERSION_DIFF_CLI_A, VERSION_DIFF_DASH_A], + updated: [VERSION_DIFF_CLI_A, VERSION_DIFF_DASH_A], + removed: [VERSION_DIFF_DELETED_CLI_B, VERSION_DIFF_DASH_A], + }) vi.mocked(versionDiffByVersion).mockResolvedValue(versionDiff) - // When - const result = await extensionsIdentifiersReleaseBreakdown(developerPlatformClient, testOrganizationApp(), ' 1.0.0') + const result = await extensionsIdentifiersReleaseBreakdown( + testDeveloperPlatformClient(), + testOrganizationApp(), + '1.0.0', + ) - // Then expect(result).toEqual({ extensionIdentifiersBreakdown: { - onlyRemote: [buildExtensionBreakdownInfo('Checkout post purchase Deleted B', undefined)], - toCreate: [buildExtensionBreakdownInfo('Checkout post purchase', undefined)], + onlyRemote: [ + buildExtensionBreakdownInfo('Checkout post purchase Deleted B', undefined), + buildDashboardBreakdownInfo('Dashboard A'), + ], + toCreate: [ + buildExtensionBreakdownInfo('Checkout post purchase', undefined), + buildDashboardBreakdownInfo('Dashboard A'), + ], toUpdate: [], - unchanged: [buildDashboardBreakdownInfo('Dashboard A')], + unchanged: [ + buildExtensionBreakdownInfo('Checkout post purchase', undefined), + buildDashboardBreakdownInfo('Dashboard A'), + ], }, versionDetails: versionDiff.versionDetails, }) }) - test('exclude webhook subscription modules from the version diff', async () => { - // Given - const versionDiff = { - versionsDiff: { - added: [], - updated: [], - removed: [VERSION_DIFF_DELETED_CLI_B, VERSION_DIFF_DELETED_CLI_WEBHOOK], - }, - versionDetails: { - id: 1, - uuid: 'uuid', - location: 'location', - versionTag: '1.0.0', - message: 'message', - appModuleVersions: [], - }, - } + test('does not include webhook subscriptions in release extension changes', async () => { + const versionDiff = buildVersionDiff({ + added: [], + updated: [], + removed: [VERSION_DIFF_DELETED_CLI_B, VERSION_DIFF_DELETED_CLI_WEBHOOK], + }) vi.mocked(versionDiffByVersion).mockResolvedValue(versionDiff) - // When - const result = await extensionsIdentifiersReleaseBreakdown(developerPlatformClient, testOrganizationApp(), ' 1.0.0') + const result = await extensionsIdentifiersReleaseBreakdown( + testDeveloperPlatformClient(), + testOrganizationApp(), + '1.0.0', + ) - // Then expect(result).toEqual({ extensionIdentifiersBreakdown: { toCreate: [], @@ -686,1084 +165,119 @@ describe('extensionsIdentifiersReleaseBreakdown', () => { }) }) -describe('configExtensionsIdentifiersBreakdown', () => { - describe('deploy with no release', () => { - test('returns the list of the local config versioned top level fields', async () => { - // Given - const configuration = { - name: 'my app', - client_id: '12345', - application_url: 'https://myapp.com', +describe('buildConfigExtensionIdentifiersBreakdown', () => { + test('compares aggregate config content with order-insensitive arrays', () => { + const result = buildConfigExtensionIdentifiersBreakdown( + { embedded: true, - pos: { - embedded: false, - }, - app_proxy: { - url: 'https://my-proxy-new.dev', - subpath: 'subpath-whatever', - prefix: 'apps', - }, - build: { - automatically_update_urls_on_dev: false, - dev_store_url: 'https://my-dev-store.com', - include_config_on_deploy: true, - }, - webhooks: { - api_version: '2023-04', - }, - } - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient() - - // When - const result = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - remoteApp: testOrganizationApp(), - apiKey: 'apiKey', - localApp: await LOCAL_APP([], configuration), - release: false, - }) - - // Then - expect(result).toEqual({ - existingFieldNames: ['name', 'application_url', 'embedded', 'pos', 'app_proxy', 'webhooks'], - existingUpdatedFieldNames: [], - newFieldNames: [], - deletedFieldNames: [], - }) - }) - }) - describe('deploy with release using local configuration', () => { - test('when the same local config and remote app module type exists and have same values it will be returned in the existing list', async () => { - // Given - const configuration = { name: 'my app', - client_id: '12345', - application_url: 'https://myapp.com', - embedded: true, webhooks: { - api_version: '2023-04', - }, - build: { - include_config_on_deploy: true, - }, - } - const configActiveAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp.com', embedded: true}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const brandingActiveAppModule: AppModuleVersion = { - registrationId: 'C_B', - registrationUuid: 'UUID_C_B', - registrationTitle: 'Registration title', - type: 'branding', - config: {name: 'my app'}, - specification: { - identifier: 'branding', - name: 'branding', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const webhooksActiveAppModule: AppModuleVersion = { - registrationId: 'C_C', - registrationUuid: 'UUID_C_C', - registrationTitle: 'Registration title', - type: 'webhooks', - config: {api_version: '2023-04'}, - specification: { - identifier: 'webhooks', - name: 'webhooks', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, + subscriptions: [ + {topics: ['products/update', 'products/create'], uri: 'https://example.com/products'}, + {topics: ['orders/create'], uri: 'https://example.com/orders'}, + ], }, - } - const activeVersion = { - appModuleVersions: [ - configActiveAppModule, - brandingActiveAppModule, - webhooksActiveAppModule, - MODULE_DASHBOARD_A, - ], - } - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(activeVersion), - }) - - // When - const result = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - apiKey: 'apiKey', - remoteApp: testOrganizationApp(), - localApp: await LOCAL_APP([], configuration), - release: true, - }) - - // Then - expect(result).toEqual({ - existingFieldNames: ['name', 'application_url', 'embedded', 'webhooks'], - existingUpdatedFieldNames: [], - newFieldNames: [], - deletedFieldNames: [], - }) - }) - - test('when the same local config and remote app module type exists and have different values it will be returned in the update list', async () => { - // Given - const configuration = { + }, + { + app_proxy: {prefix: 'apps'}, name: 'my app', - client_id: '12345', - application_url: 'https://myapp.com', - embedded: true, webhooks: { - api_version: '2023-04', - }, - build: { - include_config_on_deploy: true, - }, - } - const configActiveAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp-edited.com', embedded: false}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const brandingActiveAppModule: AppModuleVersion = { - registrationId: 'C_B', - registrationUuid: 'UUID_C_B', - registrationTitle: 'Registration title', - type: 'branding', - config: {name: 'my app'}, - specification: { - identifier: 'branding', - name: 'branding', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const webhooksActiveAppModule: AppModuleVersion = { - registrationId: 'C_C', - registrationUuid: 'UUID_C_C', - registrationTitle: 'Registration title', - type: 'webhooks', - config: {api_version: '2023-04'}, - specification: { - identifier: 'webhooks', - name: 'webhooks', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, + subscriptions: [ + {topics: ['orders/create'], uri: 'https://example.com/orders'}, + {topics: ['products/create', 'products/update'], uri: 'https://example.com/products'}, + ], }, - } - const activeVersion = { - appModuleVersions: [ - configActiveAppModule, - brandingActiveAppModule, - webhooksActiveAppModule, - MODULE_DASHBOARD_A, - ], - } - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(activeVersion), - }) - - // When - const result = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - apiKey: 'apiKey', - remoteApp: testOrganizationApp(), - localApp: await LOCAL_APP([], configuration), - release: true, - }) + }, + ) - // Then - expect(result).toEqual({ - existingFieldNames: ['name', 'webhooks'], - existingUpdatedFieldNames: ['application_url', 'embedded'], - newFieldNames: [], - deletedFieldNames: [], - }) + expect(result).toEqual({ + existingFieldNames: ['name', 'webhooks'], + existingUpdatedFieldNames: [], + newFieldNames: ['embedded'], + deletedFieldNames: ['app_proxy'], }) - test('when a new local config app module type exists it will be returned in the new list', async () => { - // Given - const configuration = { - name: 'my app', - client_id: '12345', - application_url: 'https://myapp.com', - embedded: true, - pos: { - embedded: false, - }, - webhooks: { - api_version: '2023-04', - }, - build: { - include_config_on_deploy: true, - }, - } - const configActiveAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp.com', embedded: true}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const activeVersion = { - appModuleVersions: [configActiveAppModule, MODULE_DASHBOARD_A], - } - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(activeVersion), - }) + }) - // When - const result = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - apiKey: 'apiKey', - remoteApp: testOrganizationApp(), - localApp: await LOCAL_APP([], configuration), - release: true, - }) + test('returns undefined when both configs are empty', () => { + expect(buildConfigExtensionIdentifiersBreakdown({}, {})).toBeUndefined() + }) +}) - // Then - expect(result).toEqual({ - existingFieldNames: ['application_url', 'embedded'], - existingUpdatedFieldNames: [], - newFieldNames: ['name', 'webhooks', 'pos'], - deletedFieldNames: [], - }) +describe('configExtensionsIdentifiersReleaseBreakdown', () => { + test('compares the selected version config against the active version config', async () => { + const app = testApp({ + allExtensions: [await testAppConfigExtensions()], + specifications: await loadLocalExtensionsSpecifications(), }) - test('when a remote config app module type not exists locally it will be returned in the delete list', async () => { - // Given - const configuration = { - name: 'my app', - client_id: '12345', - application_url: 'https://myapp.com', - embedded: true, - webhooks: { - api_version: '2023-04', - }, - build: { - include_config_on_deploy: true, - }, - } - const configActiveAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp.com', embedded: true}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const brandingActiveAppModule: AppModuleVersion = { - registrationId: 'C_B', - registrationUuid: 'UUID_C_B', - registrationTitle: 'Registration title', - type: 'branding', - config: {name: 'my app', app_handle: 'handle'}, - specification: { - identifier: 'branding', - name: 'Branding', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const webhooksActiveAppModule: AppModuleVersion = { - registrationId: 'C_C', - registrationUuid: 'UUID_C_C', - registrationTitle: 'Registration title', - type: 'webhooks', - config: {api_version: '2023-04'}, - specification: { - identifier: 'webhooks', - name: 'webhooks', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const configActivePosConfigurationAppModule: AppModuleVersion = { - registrationId: 'C_B', - registrationUuid: 'UUID_C_B', - registrationTitle: 'Registration title', - type: 'point_of_sale', - config: {embedded: false}, - specification: { - identifier: 'point_of_sale', - name: 'Pos configuration', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const activeVersion = { + const result = configExtensionsIdentifiersReleaseBreakdown({ + localApp: app, + versionAppModules: [ + configModule('branding', {name: 'my app'}), + configModule('app_home', {app_url: 'https://new.example.com', embedded: false}), + configModule('point_of_sale', {embedded: false}), + configModule('webhooks', {api_version: '2025-01'}), + ], + activeAppVersion: { appModuleVersions: [ - configActiveAppModule, - configActivePosConfigurationAppModule, - brandingActiveAppModule, - webhooksActiveAppModule, - MODULE_DASHBOARD_A, + configModule('branding', {name: 'my app'}), + configModule('app_home', {app_url: 'https://old.example.com', embedded: false}), + configModule('webhooks', {api_version: '2025-01'}), ], - } - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(activeVersion), - }) - - // When - const result = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - apiKey: 'apiKey', - remoteApp: testOrganizationApp(), - localApp: await LOCAL_APP([], configuration), - release: true, - }) - - // Then - expect(result).toEqual({ - existingFieldNames: ['name', 'application_url', 'embedded', 'webhooks'], - existingUpdatedFieldNames: [], - newFieldNames: [], - deletedFieldNames: ['pos'], - }) - }) - }) - describe('deploy with release using a remote version configuration', () => { - test('when the version to release config and remote remote current app exists and have same values it will be returned in the existing list', async () => { - // Given - const configToReleaseAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp.com', embedded: true}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const configActiveAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp.com', embedded: true}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const activeVersion = {appModuleVersions: [configActiveAppModule, MODULE_DASHBOARD_A]} - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(activeVersion), - }) - - // When - const result = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - apiKey: 'apiKey', - remoteApp: testOrganizationApp(), - localApp: await LOCAL_APP([], APP_CONFIGURATION), - versionAppModules: [configToReleaseAppModule], - release: true, - }) - - // Then - expect(result).toEqual({ - existingFieldNames: ['application_url', 'embedded'], - existingUpdatedFieldNames: [], - newFieldNames: [], - deletedFieldNames: [], - }) - }) - test('when the version to release config and remote remote current app exists and have different values it will be returned in the update list', async () => { - // Given - const configToReleaseAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp.com', embedded: true}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const configActiveAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp-edited.com', embedded: false}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const activeVersion = {appModuleVersions: [configActiveAppModule, MODULE_DASHBOARD_A]} - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(activeVersion), - }) - - // When - const result = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - apiKey: 'apiKey', - remoteApp: testOrganizationApp(), - localApp: await LOCAL_APP([], APP_CONFIGURATION), - versionAppModules: [configToReleaseAppModule], - release: true, - }) - - // Then - expect(result).toEqual({ - existingFieldNames: [], - existingUpdatedFieldNames: ['application_url', 'embedded'], - newFieldNames: [], - deletedFieldNames: [], - }) - }) - test('when the version to release includes a new config it will be returned in the new list', async () => { - // Given - const configToReleaseAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp.com', embedded: true}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const configToReleasePosAppModule: AppModuleVersion = { - registrationId: 'C_B', - registrationUuid: 'UUID_C_B', - registrationTitle: 'Registration title', - type: 'point_of_sale', - config: {embedded: false}, - specification: { - identifier: 'point_of_sale', - name: 'Pos configuration', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const configActiveAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp.com', embedded: true}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const activeVersion = {appModuleVersions: [configActiveAppModule, MODULE_DASHBOARD_A]} - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(activeVersion), - }) - - // When - const result = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - apiKey: 'apiKey', - remoteApp: testOrganizationApp(), - localApp: await LOCAL_APP([], APP_CONFIGURATION), - versionAppModules: [configToReleaseAppModule, configToReleasePosAppModule], - release: true, - }) - - // Then - expect(result).toEqual({ - existingFieldNames: ['application_url', 'embedded'], - existingUpdatedFieldNames: [], - newFieldNames: ['pos'], - deletedFieldNames: [], - }) - }) - test('when the version to release config doesnt include a config module that exists in the remote remote current app it will be returned in the delete list', async () => { - // Given - const configToReleaseAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp.com', embedded: true}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const configActiveAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp.com', embedded: true}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const configActivePosConfigurationAppModule: AppModuleVersion = { - registrationId: 'C_B', - registrationUuid: 'UUID_C_B', - registrationTitle: 'Registration title', - type: 'point_of_sale', - config: {embedded: false}, - specification: { - identifier: 'point_of_sale', - name: 'Pos configuration', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const activeVersion = { - appModuleVersions: [configActiveAppModule, configActivePosConfigurationAppModule, MODULE_DASHBOARD_A], - } - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(activeVersion), - }) - - // When - const result = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - apiKey: 'apiKey', - remoteApp: testOrganizationApp(), - localApp: await LOCAL_APP([], APP_CONFIGURATION), - versionAppModules: [configToReleaseAppModule], - release: true, - }) - - // Then - expect(result).toEqual({ - existingFieldNames: ['application_url', 'embedded'], - existingUpdatedFieldNames: [], - newFieldNames: [], - deletedFieldNames: ['pos'], - }) - }) - - test('relative path declarative webhook subscriptions do not show up in the diff when not changed', async () => { - // Given - const configToReleaseAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'webhook_subscription', - config: {topics: ['products/create'], uri: '/webhooks'}, - specification: { - identifier: 'webhook_subscription', - name: 'Webhook Subscription', - experience: 'extension', - options: { - managementExperience: 'cli', - }, - }, - } - const configActiveAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'webhook_subscription', - config: { - topics: ['products/create'], - uri: 'https://myapp.com/webhooks', - }, - specification: { - identifier: 'webhook_subscription', - name: 'Webhook Subscription', - experience: 'extension', - options: { - managementExperience: 'cli', - }, - }, - } - const activeVersion = {appModuleVersions: [configActiveAppModule, APP_URL_SPEC, API_VERSION_SPEC]} - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(activeVersion), - }) - - // When - const result = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - apiKey: 'apiKey', - remoteApp: testOrganizationApp(), - localApp: await LOCAL_APP([], APP_CONFIGURATION), - versionAppModules: [configToReleaseAppModule, APP_URL_SPEC, API_VERSION_SPEC], - release: true, - }) - - // Then - expect(result).toEqual({ - existingFieldNames: ['webhooks', 'application_url'], - existingUpdatedFieldNames: [], - newFieldNames: [], - deletedFieldNames: [], - }) - }) - test('relative path declarative webhook subscriptions show up in the diff when changed', async () => { - // Given - const configToReleaseAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'webhook_subscription', - config: {topics: ['products/create'], uri: '/webhooks-new'}, - specification: { - identifier: 'webhook_subscription', - name: 'Webhook Subscription', - experience: 'extension', - options: { - managementExperience: 'cli', - }, - }, - } - const configActiveAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'webhook_subscription', - config: { - topics: ['products/create'], - uri: 'https://myapp.com/webhooks', - }, - specification: { - identifier: 'webhook_subscription', - name: 'Webhook Subscription', - experience: 'extension', - options: { - managementExperience: 'cli', - }, - }, - } - const activeVersion = {appModuleVersions: [configActiveAppModule, APP_URL_SPEC, API_VERSION_SPEC]} - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(activeVersion), - }) - - // When - const result = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - apiKey: 'apiKey', - remoteApp: testOrganizationApp(), - localApp: await LOCAL_APP([], APP_CONFIGURATION), - versionAppModules: [configToReleaseAppModule, APP_URL_SPEC, API_VERSION_SPEC], - release: true, - }) - - // Then - expect(result).toEqual({ - existingFieldNames: ['application_url'], - existingUpdatedFieldNames: ['webhooks'], - newFieldNames: [], - deletedFieldNames: [], - }) - }) - test('relative path declarative webhook subscriptions show up in the diff when application_url is changed', async () => { - // Given - const configToReleaseAppModule: AppModuleVersion = { - registrationId: 'C_Y', - registrationUuid: 'UUID_C_Y', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp.com/new'}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const configActiveAppModule: AppModuleVersion = { - registrationId: 'C_Y', - registrationUuid: 'UUID_C_Y', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp.com'}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const webhookConfigToReleaseAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'webhook_subscription', - config: {topics: ['products/create'], uri: '/webhooks'}, - specification: { - identifier: 'webhook_subscription', - name: 'Webhook Subscription', - experience: 'extension', - options: { - managementExperience: 'cli', - }, - }, - } - const webhookConfigActiveAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'webhook_subscription', - config: {topics: ['products/create'], uri: 'https://myapp.com/webhooks'}, - specification: { - identifier: 'webhook_subscription', - name: 'Webhook Subscription', - experience: 'extension', - options: { - managementExperience: 'cli', - }, - }, - } - const activeVersion = {appModuleVersions: [configActiveAppModule, API_VERSION_SPEC, webhookConfigActiveAppModule]} - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(activeVersion), - }) - - // When - const result = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - apiKey: 'apiKey', - remoteApp: testOrganizationApp(), - localApp: await LOCAL_APP([], APP_CONFIGURATION), - versionAppModules: [configToReleaseAppModule, API_VERSION_SPEC, webhookConfigToReleaseAppModule], - release: true, - }) - - // Then - expect(result).toEqual({ - existingFieldNames: [], - existingUpdatedFieldNames: ['application_url', 'webhooks'], - newFieldNames: [], - deletedFieldNames: [], - }) + }, }) - test('webhook subscriptions are properly sorted by URI and topics', async () => { - // Given - const unsortedWebhookModules: AppModuleVersion[] = [ - { - registrationId: 'W_1', - registrationUuid: 'UUID_W_1', - registrationTitle: 'Webhook 1', - type: 'webhook_subscription', - config: { - // Unsorted topics - topics: ['products/update', 'products/create'], - uri: 'https://myapp.com/webhooks/products', - }, - specification: { - identifier: 'webhook_subscription', - name: 'Webhook Subscription', - experience: 'extension', - options: { - managementExperience: 'cli', - }, - }, - }, - { - registrationId: 'W_2', - registrationUuid: 'UUID_W_2', - registrationTitle: 'Webhook 2', - type: 'webhook_subscription', - config: { - topics: ['orders/create'], - uri: 'https://myapp.com/webhooks/orders', - }, - specification: { - identifier: 'webhook_subscription', - name: 'Webhook Subscription', - experience: 'extension', - options: { - managementExperience: 'cli', - }, - }, - }, - { - registrationId: 'W_4', - registrationUuid: 'UUID_W_4', - registrationTitle: 'Webhook 4', - type: 'webhook_subscription', - config: { - topics: ['products/delete'], - // Same URI as W_1 - uri: 'https://myapp.com/webhooks/products', - }, - specification: { - identifier: 'webhook_subscription', - name: 'Webhook Subscription', - experience: 'extension', - options: { - managementExperience: 'cli', - }, - }, - }, - { - registrationId: 'W_3', - registrationUuid: 'UUID_W_3', - registrationTitle: 'Webhook 3', - type: 'webhook_subscription', - config: { - // Unsorted topics - topics: ['customers/create', 'customers/update'], - uri: 'https://myapp.com/webhooks/customers', - }, - specification: { - identifier: 'webhook_subscription', - name: 'Webhook Subscription', - experience: 'extension', - options: { - managementExperience: 'cli', - }, - }, - }, - ] - - const activeVersion = {appModuleVersions: [...unsortedWebhookModules, APP_URL_SPEC, API_VERSION_SPEC]} - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(activeVersion), - }) - - const appConfiguration = { - ...APP_CONFIGURATION, - webhooks: { - api_version: '2023-04', - // Subscriptions in a different order than remote - subscriptions: [ - { - topics: ['products/delete'], - uri: 'https://myapp.com/webhooks/products', - }, - { - topics: ['orders/create'], - uri: 'https://myapp.com/webhooks/orders', - }, - { - // Different order from remote - topics: ['customers/update', 'customers/create'], - uri: 'https://myapp.com/webhooks/customers', - }, - { - // Different order from remote - topics: ['products/create', 'products/update'], - uri: 'https://myapp.com/webhooks/products', - }, - ], - }, - } - - // When - const result = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - apiKey: 'apiKey', - remoteApp: testOrganizationApp(), - localApp: await LOCAL_APP([], appConfiguration), - release: true, - }) - - // Then - // Even though the webhook subscriptions are in different orders and have unsorted topics, - // they should be considered the same after normalization - expect(result).toEqual({ - existingFieldNames: ['webhooks', 'application_url'], - existingUpdatedFieldNames: [], - newFieldNames: ['name', 'embedded'], - deletedFieldNames: [], - }) + expect(result).toEqual({ + existingFieldNames: ['name', 'embedded', 'webhooks'], + existingUpdatedFieldNames: ['application_url'], + newFieldNames: ['pos'], + deletedFieldNames: [], }) }) - describe('deploy with release using local configuration when there is no remote app version', () => { - test('all local configuration will be returned in the new list', async () => { - // Given - const configuration = { - name: 'my app', - client_id: '12345', - application_url: 'https://myapp.com', - embedded: true, - webhooks: { - api_version: '2023-04', - }, - build: { - include_config_on_deploy: true, - }, - } - - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(undefined), - }) - // When - const result = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - apiKey: 'apiKey', - remoteApp: testOrganizationApp(), - localApp: await LOCAL_APP([], configuration), - release: true, - }) + test('returns undefined when the local app has no config extensions', () => { + const app = testApp({allExtensions: []}) - // Then - expect(result).toEqual({ - existingFieldNames: [], - existingUpdatedFieldNames: [], - newFieldNames: expect.arrayContaining(['name', 'application_url', 'embedded', 'webhooks']), - deletedFieldNames: [], - }) - expect(result!.newFieldNames).toHaveLength(4) + const result = configExtensionsIdentifiersReleaseBreakdown({ + localApp: app, + versionAppModules: [configModule('branding', {name: 'my app'})], }) - }) - describe('deploy not including the configuration app modules', () => { - test('when the include_config_on_deploy is not true the configuration breakdown info is not returned', async () => { - // Given - const configuration = { - name: 'my app', - client_id: '12345', - application_url: 'https://myapp.com', - embedded: true, - pos: { - embedded: false, - }, - build: { - include_config_on_deploy: false, - }, - webhooks: { - api_version: '2023-04', - }, - } - const configToReleaseAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp.com', embedded: true}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const configActiveAppModule: AppModuleVersion = { - registrationId: 'C_A', - registrationUuid: 'UUID_C_A', - registrationTitle: 'Registration title', - type: 'app_home', - config: {app_url: 'https://myapp.com', embedded: true}, - specification: { - identifier: 'app_home', - name: 'App Ui', - experience: 'configuration', - options: { - managementExperience: 'cli', - }, - }, - } - const activeVersion = {appModuleVersions: [configActiveAppModule, MODULE_DASHBOARD_A]} - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(activeVersion), - }) - - // When - const result = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - apiKey: 'apiKey', - remoteApp: testOrganizationApp(), - localApp: await LOCAL_APP([], configuration), - versionAppModules: [configToReleaseAppModule], - release: true, - }) - // Then - expect(result).toBeUndefined() - }) + expect(result).toBeUndefined() }) }) + +function buildVersionDiff(versionsDiff: { + added: AppVersionsDiffExtensionSchema[] + updated: AppVersionsDiffExtensionSchema[] + removed: AppVersionsDiffExtensionSchema[] +}) { + return { + versionsDiff, + versionDetails: { + id: 1, + uuid: 'uuid', + location: 'location', + versionTag: '1.0.0', + message: 'message', + appModuleVersions: [], + }, + } +} + +function configModule(identifier: string, config: {[key: string]: unknown}): AppModuleVersion { + return { + registrationId: `${identifier}-id`, + registrationUuid: `${identifier}-uuid`, + registrationTitle: identifier, + type: identifier, + config, + specification: { + identifier, + name: identifier, + experience: 'configuration', + options: { + managementExperience: 'cli', + }, + }, + } +} diff --git a/packages/app/src/cli/services/context/breakdown-extensions.ts b/packages/app/src/cli/services/context/breakdown-extensions.ts index dfb5ea8c9f5..07dff7d2437 100644 --- a/packages/app/src/cli/services/context/breakdown-extensions.ts +++ b/packages/app/src/cli/services/context/breakdown-extensions.ts @@ -1,22 +1,10 @@ -import {ensureExtensionsIds} from './identifiers-extensions.js' -import {EnsureDeploymentIdsPresenceOptions, LocalSource, RemoteSource} from './identifiers.js' import {versionDiffByVersion} from '../release/version-diff.js' import {AppVersionsDiffExtensionSchema} from '../../api/graphql/app_versions_diff.js' -import {AppInterface, CurrentAppConfiguration, filterNonVersionedAppFields} from '../../models/app/app.js' +import {AppInterface} from '../../models/app/app.js' import {MinimalOrganizationApp} from '../../models/organization.js' -import {IdentifiersExtensions} from '../../models/app/identifiers.js' -import {fetchAppRemoteConfiguration, remoteAppConfigurationExtensionContent} from '../app/select-app.js' +import {remoteAppConfigurationExtensionContent} from '../app/select-app.js' import {AppVersion, AppModuleVersion, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' -import { - AllAppExtensionRegistrationsQuerySchema, - RemoteExtensionRegistrations, -} from '../../api/graphql/all_app_extension_registrations.js' -import {ExtensionSpecification, isAppConfigSpecification} from '../../models/extensions/specification.js' -import {rewriteConfiguration} from '../app/write-app-configuration-file.js' -import {AppConfigurationUsedByCli} from '../../models/extensions/specifications/types/app_config.js' -import {removeTrailingSlash} from '../../models/extensions/specifications/validation/common.js' -import {deepCompare, deepDifference} from '@shopify/cli-kit/common/object' -import {zod} from '@shopify/cli-kit/node/schema' +import {deepCompareWithOrderInsensitiveArrays} from '@shopify/cli-kit/common/object' export interface ConfigExtensionIdentifiersBreakdown { existingFieldNames: string[] @@ -46,49 +34,6 @@ export interface ExtensionIdentifiersBreakdown { unchanged: ExtensionIdentifierBreakdownInfo[] } -export async function extensionsIdentifiersDeployBreakdown(options: EnsureDeploymentIdsPresenceOptions): Promise<{ - extensionIdentifiersBreakdown: ExtensionIdentifiersBreakdown - extensionsToConfirm: { - validMatches: IdentifiersExtensions - extensionsToCreate: LocalSource[] - dashboardOnlyExtensions: RemoteSource[] - } - remoteExtensionsRegistrations: RemoteExtensionRegistrations -}> { - let remoteExtensionsRegistrations = await fetchRemoteExtensionsRegistrations(options) - - const extensionsToConfirm = await ensureExtensionsIds(options, remoteExtensionsRegistrations.app) - - if (extensionsToConfirm.didMigrateDashboardExtensions) { - // If we migrated dashboard extensions, we need to refetch the active app version - const newActiveAppVersion = await options.developerPlatformClient.activeAppVersion(options.remoteApp) - // eslint-disable-next-line require-atomic-updates - options.activeAppVersion = newActiveAppVersion - } - - if (extensionsToConfirm.dashboardOnlyExtensions.length > 0) { - remoteExtensionsRegistrations = await fetchRemoteExtensionsRegistrations({...options, activeAppVersion: undefined}) - } - let extensionIdentifiersBreakdown = loadLocalExtensionsIdentifiersBreakdown(extensionsToConfirm) - if (options.release) { - extensionIdentifiersBreakdown = - (await resolveRemoteExtensionIdentifiersBreakdown( - options.developerPlatformClient, - options.remoteApp, - extensionsToConfirm.validMatches, - extensionsToConfirm.extensionsToCreate, - extensionsToConfirm.dashboardOnlyExtensions, - options.app.specifications ?? [], - options.activeAppVersion, - )) ?? extensionIdentifiersBreakdown - } - return { - extensionIdentifiersBreakdown, - extensionsToConfirm, - remoteExtensionsRegistrations: remoteExtensionsRegistrations.app, - } -} - export async function extensionsIdentifiersReleaseBreakdown( developerPlatformClient: DeveloperPlatformClient, app: MinimalOrganizationApp, @@ -119,303 +64,57 @@ export async function extensionsIdentifiersReleaseBreakdown( return {extensionIdentifiersBreakdown, versionDetails} } -export async function configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - remoteApp, +export function configExtensionsIdentifiersReleaseBreakdown({ localApp, versionAppModules, - release, activeAppVersion, }: { - developerPlatformClient: DeveloperPlatformClient - apiKey: string - remoteApp: MinimalOrganizationApp localApp: AppInterface - versionAppModules?: AppModuleVersion[] - release?: boolean + versionAppModules: AppModuleVersion[] activeAppVersion?: AppVersion }) { if (localApp.allExtensions.filter((extension) => extension.isAppConfigExtension).length === 0) return - if (!release) return loadLocalConfigExtensionIdentifiersBreakdown(localApp) - - return resolveRemoteConfigExtensionIdentifiersBreakdown( - developerPlatformClient, - remoteApp, - localApp, + const versionConfig = remoteAppConfigurationExtensionContent( versionAppModules, - activeAppVersion, - ) -} - -function loadLocalConfigExtensionIdentifiersBreakdown(app: AppInterface): ConfigExtensionIdentifiersBreakdown { - return { - existingFieldNames: filterNonVersionedAppFields(app.configuration), - existingUpdatedFieldNames: [] as string[], - newFieldNames: [] as string[], - deletedFieldNames: [] as string[], - } -} - -async function fetchRemoteExtensionsRegistrations( - options: EnsureDeploymentIdsPresenceOptions, -): Promise { - return options.developerPlatformClient.appExtensionRegistrations(options.remoteApp, options.activeAppVersion) -} - -async function resolveRemoteConfigExtensionIdentifiersBreakdown( - developerPlatformClient: DeveloperPlatformClient, - remoteApp: MinimalOrganizationApp, - app: AppInterface, - versionAppModules?: AppModuleVersion[], - activeAppVersion?: AppVersion, -) { - const remoteConfig: Partial = - (await fetchAppRemoteConfiguration( - remoteApp, - developerPlatformClient, - app.specifications ?? [], - app.remoteFlags, - activeAppVersion, - )) ?? {} - const baselineConfig = ( - versionAppModules - ? remoteAppConfigurationExtensionContent(versionAppModules, app.specifications ?? [], app.remoteFlags) - : app.configuration - ) as CurrentAppConfiguration - - // modify relative path webhook subscriptions to include app url so client vs server diff checking is consistent - if (baselineConfig?.webhooks?.subscriptions?.length) { - baselineConfig.webhooks.subscriptions = baselineConfig.webhooks.subscriptions.map((subscription) => { - if (subscription.uri.startsWith('/')) { - subscription.uri = `${removeTrailingSlash(baselineConfig.application_url)}${subscription.uri}` - } - return subscription - }) - - // Sort webhook subscriptions by uri & topics in both baseline and remote config - // Ensuring that deepCompare will not fail if the order is different - baselineConfig.webhooks.subscriptions.sort((webhookA, webhookB) => { - const keyA = webhookA.uri + (webhookA.topics?.sort().join(',') ?? '') - const keyB = webhookB.uri + (webhookB.topics?.sort().join(',') ?? '') - return keyA.localeCompare(keyB) - }) - remoteConfig.webhooks?.subscriptions?.sort((webhookA, webhookB) => { - const keyA = webhookA.uri + (webhookA.topics?.sort().join(',') ?? '') - const keyB = webhookB.uri + (webhookB.topics?.sort().join(',') ?? '') - return keyA.localeCompare(keyB) - }) - } - - const diffFieldNames = buildDiffFieldNames(baselineConfig, remoteConfig, app.configSchema) - - // List of field included in the config except the ones that only affect the CLI and are not pushed to the server - // (versioned fields) - const versionedLocalFieldNames = filterNonVersionedAppFields(baselineConfig) - // List of remote fields that have different values to the local ones or are not present in the local config - const remoteDiffModifications = diffFieldNames?.baselineFieldNames ?? [] - // List of local fields that have different values to the remote ones or are not present in the remote config - const localDiffModifications = diffFieldNames?.updatedFieldNames ?? [] - // List of versioned field that exists locally and remotely and have the same value - const notModifiedVersionedLocalFieldNames = versionedLocalFieldNames.filter( - (field) => !remoteDiffModifications.includes(field) && !localDiffModifications.includes(field), - ) - // List of versioned field that exists locally and remotely and have different values - const modifiedVersionedLocalFieldNames = versionedLocalFieldNames.filter( - (field) => remoteDiffModifications.includes(field) && localDiffModifications.includes(field), - ) - // List of versioned field that exists locally but not remotely - const newVersionedLocalFieldNames = localDiffModifications.filter( - (field) => !remoteDiffModifications.includes(field) && versionedLocalFieldNames.includes(field), - ) - // List of versioned field that exists remotely but not locally - // `handle` property won't be temporary shown in the list of removed properties - const deletedVersionedLocalFieldNames = remoteDiffModifications.filter( - (field) => !localDiffModifications.includes(field) && field !== 'handle', - ) - - return { - existingFieldNames: notModifiedVersionedLocalFieldNames, - existingUpdatedFieldNames: modifiedVersionedLocalFieldNames, - newFieldNames: newVersionedLocalFieldNames, - deletedFieldNames: deletedVersionedLocalFieldNames, - } -} - -/** - * Computes the diff between local and remote config (after schema rewriting) and returns - * the top-level field names that differ on each side. - */ -function buildDiffFieldNames( - localConfig: CurrentAppConfiguration, - remoteConfig: Partial, - schema: zod.ZodTypeAny, -) { - const [updated, baseline] = deepDifference( - {...(rewriteConfiguration(schema, localConfig) as object), build: undefined}, - {...(rewriteConfiguration(schema, remoteConfig) as object), build: undefined}, + localApp.specifications ?? [], + localApp.remoteFlags, ) - - if (deepCompare(updated, baseline)) { - return undefined - } - - const definedKeys = (obj: object) => - Object.entries(obj) - .filter(([_, value]) => value !== undefined) - .map(([key]) => key) - - return { - updatedFieldNames: definedKeys(updated), - baselineFieldNames: definedKeys(baseline), - } -} - -function loadLocalExtensionsIdentifiersBreakdown({ - validMatches: localRegistration, - extensionsToCreate: localSourceToCreate, - dashboardOnlyExtensions, -}: { - validMatches: IdentifiersExtensions - extensionsToCreate: LocalSource[] - dashboardOnlyExtensions: RemoteSource[] -}): ExtensionIdentifiersBreakdown { - const identifiersToUpdate = Object.keys(localRegistration).map((identifier) => - buildExtensionBreakdownInfo(identifier, undefined), - ) - const identifiersToCreate = localSourceToCreate.map((source) => - buildExtensionBreakdownInfo(source.localIdentifier, undefined), - ) - const dashboardToUpdate = dashboardOnlyExtensions - .filter((dashboard) => !Object.values(localRegistration).includes(dashboard.uuid)) - .map((dashboard) => buildDashboardBreakdownInfo(dashboard.title)) - return { - onlyRemote: [] as ExtensionIdentifierBreakdownInfo[], - toCreate: [] as ExtensionIdentifierBreakdownInfo[], - toUpdate: [] as ExtensionIdentifierBreakdownInfo[], - unchanged: [...identifiersToUpdate, ...identifiersToCreate, ...dashboardToUpdate], - } -} - -async function resolveRemoteExtensionIdentifiersBreakdown( - developerPlatformClient: DeveloperPlatformClient, - remoteApp: MinimalOrganizationApp, - validMatches: IdentifiersExtensions, - toCreate: LocalSource[], - dashboardOnly: RemoteSource[], - specs: ExtensionSpecification[], - activeAppVersion?: AppVersion, -): Promise { - const version = activeAppVersion ?? (await developerPlatformClient.activeAppVersion(remoteApp)) - if (!version) return - - const extensionIdentifiersBreakdown = loadExtensionsIdentifiersBreakdown(version, validMatches, toCreate, specs) - - const dashboardOnlyFinal = dashboardOnly.filter( - (dashboardOnly) => - !Object.values(validMatches).includes(dashboardOnly.uuid) && - !toCreate.map((source) => source.localIdentifier).includes(dashboardOnly.uuid), + const activeConfig = remoteAppConfigurationExtensionContent( + activeAppVersion?.appModuleVersions ?? [], + localApp.specifications ?? [], + localApp.remoteFlags, ) - const dashboardIdentifiersBreakdown = loadDashboardIdentifiersBreakdown(dashboardOnlyFinal, version) - - return { - onlyRemote: [...extensionIdentifiersBreakdown.onlyRemote, ...dashboardIdentifiersBreakdown.onlyRemote], - toCreate: [...extensionIdentifiersBreakdown.toCreate, ...dashboardIdentifiersBreakdown.toCreate], - toUpdate: [...extensionIdentifiersBreakdown.toUpdate, ...dashboardIdentifiersBreakdown.toUpdate], - unchanged: [...extensionIdentifiersBreakdown.unchanged, ...dashboardIdentifiersBreakdown.unchanged], - } + return buildConfigExtensionIdentifiersBreakdown(versionConfig, activeConfig) } -function loadExtensionsIdentifiersBreakdown( - activeAppVersion: AppVersion, - validMatches: IdentifiersExtensions, - toCreate: LocalSource[], - specs: ExtensionSpecification[], -) { - const extensionModules = activeAppVersion?.appModuleVersions.filter((ext) => { - const spec = specs.find( - (spec) => - spec.identifier === ext.specification?.identifier || spec.externalIdentifier === ext.specification?.identifier, - ) - return spec && !isAppConfigSpecification(spec) - }) +export function buildConfigExtensionIdentifiersBreakdown( + localConfig: {[key: string]: unknown}, + remoteConfig: {[key: string]: unknown}, +): ConfigExtensionIdentifiersBreakdown | undefined { + const fieldNames = new Set([...Object.keys(localConfig), ...Object.keys(remoteConfig)]) + if (fieldNames.size === 0) return undefined - // In AppManagement, matching has to be via UID, but we acccept UUID matches if the UID is empty (migration pending) - // In Partners, we keep the legacy match of only UUID. - function moduleHasUUIDorUID(module: AppModuleVersion, identifier: string) { - const UuidMatch = module.registrationUuid === identifier - const UidMatch = module.registrationId === identifier - const pendingMigration = module.registrationId.length === 0 - - return UidMatch || (pendingMigration && UuidMatch) + const breakdown: ConfigExtensionIdentifiersBreakdown = { + existingFieldNames: [], + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], } - const allExistingExtensions = Object.entries(validMatches) - .filter(([_identifier, uuid]) => extensionModules.some((module) => moduleHasUUIDorUID(module, uuid))) - .map(([identifier, _uuid]) => identifier) - - // If registationId is empty, it means the extension doesn't have a UID yet, so this deploy will create one. - const extensionsBeingMigratedToDevDash = extensionModules.filter((module) => module.registrationId === '') - const extensionsToUpdate = allExistingExtensions.filter((identifier) => - extensionsBeingMigratedToDevDash.some((module) => module.registrationUuid === validMatches[identifier]), - ) - - const unchangedExtensions = allExistingExtensions.filter( - (identifier) => - !extensionsBeingMigratedToDevDash.some((module) => module.registrationUuid === validMatches[identifier]), - ) - - const extensionsToCreate = Object.entries(validMatches) - .filter(([_identifier, uuid]) => !extensionModules.some((module) => moduleHasUUIDorUID(module, uuid))) - .map(([identifier, uuid]) => ({title: identifier, uid: uuid})) - const originalToCreate = toCreate.map((source) => ({title: source.localIdentifier, uid: source.uid})) + for (const fieldName of fieldNames) { + const localValue = localConfig[fieldName] + const remoteValue = remoteConfig[fieldName] - originalToCreate.forEach((source) => { - const index = extensionsToCreate.findIndex((extension) => extension.title === source.title) - index === -1 ? extensionsToCreate.push(source) : (extensionsToCreate[index] = source) - }) - - const extensionsOnlyRemote = extensionModules - .filter( - (module) => - !Object.values(validMatches).some((uuid) => moduleHasUUIDorUID(module, uuid)) && - !toCreate.map((source) => source.localIdentifier).some((identifier) => moduleHasUUIDorUID(module, identifier)), - ) - .map((module) => ({title: module.registrationTitle, uid: module.registrationId})) - - return { - onlyRemote: extensionsOnlyRemote.map(({title, uid}) => buildExtensionBreakdownInfo(title, uid)), - toCreate: extensionsToCreate.map(({title, uid}) => buildExtensionBreakdownInfo(title, uid)), - toUpdate: extensionsToUpdate.map((title) => buildExtensionBreakdownInfo(title, undefined)), - unchanged: unchangedExtensions.map((title) => buildExtensionBreakdownInfo(title, undefined)), + if (localValue === undefined) { + breakdown.deletedFieldNames.push(fieldName) + } else if (remoteValue === undefined) { + breakdown.newFieldNames.push(fieldName) + } else if (deepCompareWithOrderInsensitiveArrays(localValue, remoteValue)) { + breakdown.existingFieldNames.push(fieldName) + } else { + breakdown.existingUpdatedFieldNames.push(fieldName) + } } -} - -function loadDashboardIdentifiersBreakdown(currentRegistrations: RemoteSource[], activeAppVersion: AppVersion) { - const currentVersions = - activeAppVersion?.appModuleVersions.filter( - (module) => module.specification!.options.managementExperience === 'dashboard', - ) || [] - const versionsNotIncluded = (version: AppModuleVersion) => - !currentRegistrations.map((registration) => registration.uuid).includes(version.registrationUuid!) - const onlyRemote = currentVersions - .filter(versionsNotIncluded) - .map((module) => buildDashboardBreakdownInfo(module.registrationTitle)) - - const registrationIncluded = (registration: RemoteSource) => - currentVersions.map((version) => version.registrationUuid).includes(registration.uuid) - const registrationNotIncluded = (registration: RemoteSource) => !registrationIncluded(registration) - const toCreate = currentRegistrations - .filter(registrationNotIncluded) - .map((registration) => buildDashboardBreakdownInfo(registration.title)) - const toUpdate = currentRegistrations - .filter(registrationIncluded) - .map((registration) => buildDashboardBreakdownInfo(registration.title)) - - return { - onlyRemote, - toCreate, - toUpdate: [], - unchanged: toUpdate, - } + return breakdown } diff --git a/packages/app/src/cli/services/context/deploy-app-version-migrations.ts b/packages/app/src/cli/services/context/deploy-app-version-migrations.ts new file mode 100644 index 00000000000..9e862120cd4 --- /dev/null +++ b/packages/app/src/cli/services/context/deploy-app-version-migrations.ts @@ -0,0 +1,100 @@ +import {EnsureDeploymentIdsPresenceOptions, RemoteSource} from './identifiers.js' +import {extensionMigrationPrompt} from './prompts.js' +import {migrateFlowExtensions} from '../dev/migrate-flow-extension.js' +import {migrateExtensionsToUIExtension} from '../dev/migrate-to-ui-extension.js' +import { + AdminLinkModulesMap, + FlowModulesMap, + getModulesToMigrate, + MarketingModulesMap, + migrateAppModules, + UIModulesMap, +} from '../dev/migrate-app-module.js' +import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import {PartnersClient} from '../../utilities/developer-platform-client/partners-client.js' +import {AbortSilentError} from '@shopify/cli-kit/node/error' + +/** Returns a fresh active app version when migrations changed remote modules. */ +export async function activeAppVersionAfterMigrations(options: EnsureDeploymentIdsPresenceOptions) { + const didMigrateExtensions = await migrateExtensionsIfNeeded(options) + return didMigrateExtensions + ? options.developerPlatformClient.activeAppVersion(options.remoteApp) + : options.activeAppVersion +} + +/** Runs supported legacy extension migrations before deploy classification. */ +async function migrateExtensionsIfNeeded(options: EnsureDeploymentIdsPresenceOptions) { + const {app, appId, developerPlatformClient, envIdentifiers, remoteApp} = options + const registrations = await developerPlatformClient.appExtensionRegistrations(remoteApp, options.activeAppVersion) + const localExtensions = app.allExtensions + const identifiers = envIdentifiers + const remoteExtensions = registrations.app.extensionRegistrations + const dashboardExtensions = registrations.app.dashboardManagedExtensionRegistrations + const migrationClient = PartnersClient.getInstance() + + let didMigrate = false + let migratedRemoteExtensions = remoteExtensions + + const uiExtensionsToMigrate = getModulesToMigrate( + localExtensions, + migratedRemoteExtensions, + identifiers, + UIModulesMap, + ) + if (uiExtensionsToMigrate.length > 0) { + const confirmedMigration = await extensionMigrationPrompt(uiExtensionsToMigrate) + if (!confirmedMigration) throw new AbortSilentError() + migratedRemoteExtensions = await migrateExtensionsToUIExtension({ + extensionsToMigrate: uiExtensionsToMigrate, + appId, + remoteExtensions: migratedRemoteExtensions, + migrationClient, + }) + didMigrate = true + } + + const dashboardMigrations = [ + {typesMap: FlowModulesMap, migrate: migrateFlowExtensions}, + migrationGroup(MarketingModulesMap, 'marketing_activity'), + migrationGroup(AdminLinkModulesMap, 'admin_link'), + ] + + for (const migration of dashboardMigrations) { + const extensionsToMigrate = getModulesToMigrate( + localExtensions, + dashboardExtensions, + identifiers, + migration.typesMap, + ) + if (extensionsToMigrate.length === 0) continue + + // eslint-disable-next-line no-await-in-loop + const confirmedMigration = await extensionMigrationPrompt(extensionsToMigrate, false) + if (!confirmedMigration) throw new AbortSilentError() + + // eslint-disable-next-line no-await-in-loop + const newRemoteExtensions = await migration.migrate({ + extensionsToMigrate, + appId, + remoteExtensions: dashboardExtensions, + migrationClient, + }) + migratedRemoteExtensions = migratedRemoteExtensions.concat(newRemoteExtensions) + didMigrate = true + } + + return didMigrate +} + +/** Adapts app-module migrations to the shared dashboard migration loop. */ +function migrationGroup(typesMap: {[key: string]: string[]}, type: string) { + return { + typesMap, + migrate: (options: { + extensionsToMigrate: Parameters[0]['extensionsToMigrate'] + appId: string + remoteExtensions: RemoteSource[] + migrationClient: DeveloperPlatformClient + }) => migrateAppModules({...options, type}), + } +} diff --git a/packages/app/src/cli/services/context/deploy-app-version.test.ts b/packages/app/src/cli/services/context/deploy-app-version.test.ts new file mode 100644 index 00000000000..90917396bb3 --- /dev/null +++ b/packages/app/src/cli/services/context/deploy-app-version.test.ts @@ -0,0 +1,774 @@ +/* eslint-disable @shopify/prefer-module-scope-constants */ +import {classifyDeployExtensionChanges, ensureDeployIdentifiersFromAppVersion} from './deploy-app-version.js' +import {extensionMigrationPrompt} from './prompts.js' +import {AppInterface} from '../../models/app/app.js' +import { + testApp, + testAppConfigExtensions, + testDeveloperPlatformClient, + testOrganizationApp, + testSingleWebhookSubscriptionExtension, + testUIExtension, +} from '../../models/app/app.test-data.js' +import {OrganizationApp} from '../../models/organization.js' +import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import {BaseConfigType} from '../../models/extensions/schemas.js' +import {createConfigExtensionSpecification} from '../../models/extensions/specification.js' +import {AppModuleVersion, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' +import {migrateExtensionsToUIExtension} from '../dev/migrate-to-ui-extension.js' +import {beforeAll, beforeEach, describe, expect, test, vi} from 'vitest' +import {AbortSilentError} from '@shopify/cli-kit/node/error' +import {zod} from '@shopify/cli-kit/node/schema' + +vi.mock('../../prompts/deploy-release.js') +vi.mock('./prompts.js') +vi.mock('../dev/migrate-to-ui-extension.js') + +const REMOTE_APP: OrganizationApp = testOrganizationApp({ + id: 'app-id', + apiKey: 'api-key', + organizationId: 'org-id', +}) + +let EXTENSION_A: ExtensionInstance +let EXTENSION_B: ExtensionInstance +let EXTENSION_TO_MIGRATE: ExtensionInstance +let CONFIG_EXTENSION: ExtensionInstance +let WEBHOOK_SUBSCRIPTION_EXTENSION: ExtensionInstance +let APP: AppInterface +let REMOTE_EXTENSION_A: AppModuleVersion +let REMOTE_EXTENSION_DELETED: AppModuleVersion +let REMOTE_CONFIG_EXTENSION: AppModuleVersion + +beforeAll(async () => { + EXTENSION_A = await testUIExtension({ + directory: '/extension-a', + configuration: { + name: 'Extension A', + type: 'checkout_post_purchase', + metafields: [], + capabilities: { + network_access: false, + block_progress: false, + api_access: false, + collect_buyer_consent: { + sms_marketing: false, + customer_privacy: false, + }, + iframe: { + sources: [], + }, + }, + }, + entrySourceFilePath: '', + devUUID: 'devUUID', + uid: 'uid-a', + }) + + EXTENSION_B = await testUIExtension({ + directory: '/extension-b', + configuration: { + name: 'Extension B', + type: 'checkout_post_purchase', + metafields: [], + capabilities: { + network_access: false, + block_progress: false, + api_access: false, + collect_buyer_consent: { + sms_marketing: false, + customer_privacy: false, + }, + iframe: { + sources: [], + }, + }, + }, + entrySourceFilePath: '', + devUUID: 'devUUID', + uid: 'uid-b', + }) + + EXTENSION_TO_MIGRATE = await testUIExtension({ + directory: '/extension-to-migrate', + configuration: { + name: 'Legacy UI', + type: 'ui_extension', + metafields: [], + capabilities: { + network_access: false, + block_progress: false, + api_access: false, + collect_buyer_consent: { + sms_marketing: false, + customer_privacy: false, + }, + iframe: { + sources: [], + }, + }, + }, + entrySourceFilePath: '', + devUUID: 'devUUID', + uid: 'uid-migration', + }) + + CONFIG_EXTENSION = await testAppConfigExtensions() + WEBHOOK_SUBSCRIPTION_EXTENSION = await testSingleWebhookSubscriptionExtension() + + APP = testApp({ + name: 'my-app', + directory: '/app', + configuration: { + client_id: REMOTE_APP.apiKey, + name: 'my-app', + application_url: 'https://example.com', + embedded: true, + access_scopes: { + scopes: 'read_products', + }, + }, + allExtensions: [EXTENSION_A, EXTENSION_B, CONFIG_EXTENSION], + }) + + REMOTE_EXTENSION_A = { + registrationId: EXTENSION_A.uid, + registrationUuid: 'remote-uuid-a', + registrationTitle: 'Remote Extension A', + type: 'checkout_post_purchase', + config: await EXTENSION_A.deployConfig({apiKey: REMOTE_APP.apiKey, appConfiguration: APP.configuration}), + specification: { + identifier: 'checkout_post_purchase', + name: 'Post purchase UI extension', + experience: 'extension', + options: {managementExperience: 'cli'}, + }, + } + + REMOTE_EXTENSION_DELETED = { + registrationId: 'deleted-uid', + registrationUuid: 'deleted-uuid', + registrationTitle: 'Deleted Extension', + type: 'checkout_post_purchase', + config: {}, + specification: { + identifier: 'checkout_post_purchase', + name: 'Post purchase UI extension', + experience: 'extension', + options: {managementExperience: 'cli'}, + }, + } + + REMOTE_CONFIG_EXTENSION = { + registrationId: CONFIG_EXTENSION.uid, + registrationUuid: 'remote-config-uuid', + registrationTitle: 'Point of Sale', + type: 'point_of_sale', + config: {}, + specification: { + identifier: 'point_of_sale', + name: 'Point of Sale', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } +}) + +beforeEach(() => { + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) + vi.mocked(extensionMigrationPrompt).mockResolvedValue(true) + vi.mocked(migrateExtensionsToUIExtension).mockResolvedValue([]) +}) + +function deployOptionsFor(app: AppInterface, envIdentifiers: {[localIdentifier: string]: string}) { + return { + app, + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient: testDeveloperPlatformClient(), + envIdentifiers, + remoteApp: REMOTE_APP, + } +} + +describe('classifyDeployExtensionChanges', () => { + test('classifies local and active app version extensions in one pass', async () => { + const changes = await classifyDeployExtensionChanges({ + options: deployOptionsFor(APP, {}), + activeAppVersion: {appModuleVersions: [REMOTE_EXTENSION_A, REMOTE_EXTENSION_DELETED, REMOTE_CONFIG_EXTENSION]}, + }) + + expect( + changes.map((change) => ({ + status: change.status, + local: change.local?.localIdentifier, + remote: change.remote?.registrationTitle, + })), + ).toEqual([ + {status: 'unchanged', local: EXTENSION_A.localIdentifier, remote: 'Remote Extension A'}, + {status: 'created', local: EXTENSION_B.localIdentifier, remote: undefined}, + {status: 'unchanged', local: CONFIG_EXTENSION.localIdentifier, remote: 'Point of Sale'}, + {status: 'deleted', local: undefined, remote: 'Deleted Extension'}, + ]) + }) + + test('classifies UUID fallback matches as updated', async () => { + const remoteWithoutMatchingUID = { + ...REMOTE_EXTENSION_A, + registrationId: '', + } + + const changes = await classifyDeployExtensionChanges({ + options: deployOptionsFor(testApp({...APP, allExtensions: [EXTENSION_A]}), { + [EXTENSION_A.localIdentifier]: REMOTE_EXTENSION_A.registrationUuid!, + }), + activeAppVersion: {appModuleVersions: [remoteWithoutMatchingUID]}, + }) + + expect(changes).toMatchObject([ + { + status: 'updated', + local: {localIdentifier: EXTENSION_A.localIdentifier}, + remote: {registrationUuid: REMOTE_EXTENSION_A.registrationUuid}, + }, + ]) + }) + + test('keeps same-UID non-config extensions unchanged even when config differs', async () => { + const changes = await classifyDeployExtensionChanges({ + options: deployOptionsFor(testApp({...APP, allExtensions: [EXTENSION_A]}), {}), + activeAppVersion: { + appModuleVersions: [ + { + ...REMOTE_EXTENSION_A, + config: {changed: true}, + }, + ], + }, + }) + + expect(changes).toMatchObject([ + { + status: 'unchanged', + local: {localIdentifier: EXTENSION_A.localIdentifier}, + remote: {registrationUuid: REMOTE_EXTENSION_A.registrationUuid}, + }, + ]) + }) + + test('groups remote-only webhook subscription changes under configuration webhooks', async () => { + const remoteWebhookSubscriptions = [ + { + registrationId: 'app/uninstalled:::https://example.com/webhooks/app/uninstalled', + registrationUuid: 'webhook-subscription-uuid-1', + registrationTitle: 'app/uninstalled:::https://example.com/webhooks/app/uninstalled', + type: 'webhook_subscription', + config: { + topic: 'app/uninstalled', + uri: 'https://example.com/webhooks/app/uninstalled', + }, + specification: { + identifier: 'webhook_subscription', + name: 'Webhook Subscription', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + }, + { + registrationId: 'scopes/update:::https://example.com/webhooks/scopes/update', + registrationUuid: 'webhook-subscription-uuid-2', + registrationTitle: 'scopes/update:::https://example.com/webhooks/scopes/update', + type: 'webhook_subscription', + config: { + topic: 'scopes/update', + uri: 'https://example.com/webhooks/scopes/update', + }, + specification: { + identifier: 'webhook_subscription', + name: 'Webhook Subscription', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + }, + ] as AppModuleVersion[] + const app = testApp({ + ...APP, + allExtensions: [], + specifications: [WEBHOOK_SUBSCRIPTION_EXTENSION.specification], + }) + + await ensureDeployIdentifiersFromAppVersion({ + app, + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient: testDeveloperPlatformClient(), + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: remoteWebhookSubscriptions}, + allowDeletes: true, + }) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + extensionIdentifiersBreakdown: { + onlyRemote: [], + toCreate: [], + toUpdate: [], + unchanged: [], + }, + configExtensionIdentifiersBreakdown: { + existingFieldNames: [], + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: ['webhooks'], + }, + }), + ) + }) + + test('trusts remote experience when deciding if remote-only modules are configuration', async () => { + const remoteConfigLikeExtension = { + registrationId: 'remote-config-id', + registrationUuid: 'remote-config-uuid', + registrationTitle: 'Remote Config-Like Extension', + type: 'unknown_remote_type', + config: {}, + specification: { + identifier: 'unknown_remote_type', + name: 'Unknown Remote Type', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } as AppModuleVersion + const app = testApp({...APP, allExtensions: [], specifications: []}) + + await ensureDeployIdentifiersFromAppVersion({ + app, + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient: testDeveloperPlatformClient(), + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: [remoteConfigLikeExtension]}, + allowDeletes: true, + }) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + extensionIdentifiersBreakdown: { + onlyRemote: [], + toCreate: [], + toUpdate: [], + unchanged: [], + }, + configExtensionIdentifiersBreakdown: undefined, + }), + ) + }) + + test('shows each config field once and collapses mixed webhook changes to updated', async () => { + const remoteWebhookSubscription = { + registrationId: 'app/uninstalled:::https://example.com/webhooks/app/uninstalled', + registrationUuid: 'webhook-subscription-uuid-1', + registrationTitle: 'app/uninstalled:::https://example.com/webhooks/app/uninstalled', + type: 'webhook_subscription', + config: { + topic: 'app/uninstalled', + uri: 'https://example.com/webhooks/app/uninstalled', + }, + specification: { + identifier: 'webhook_subscription', + name: 'Webhook Subscription', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } as AppModuleVersion + const app = testApp({ + ...APP, + allExtensions: [WEBHOOK_SUBSCRIPTION_EXTENSION], + specifications: [WEBHOOK_SUBSCRIPTION_EXTENSION.specification], + }) + + await ensureDeployIdentifiersFromAppVersion({ + app, + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient: testDeveloperPlatformClient(), + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: [remoteWebhookSubscription]}, + allowDeletes: true, + }) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: { + existingFieldNames: [], + existingUpdatedFieldNames: ['webhooks'], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + }) + + test('only marks changed fields as updated when one config specification owns multiple fields', async () => { + type DataConfig = BaseConfigType & { + product?: {enabled: boolean} + metaobject?: {enabled: boolean} + } + const dataSpec = createConfigExtensionSpecification({ + identifier: 'data', + schema: zod.object({ + product: zod.object({enabled: zod.boolean()}).optional(), + metaobject: zod.object({enabled: zod.boolean()}).optional(), + }), + transformConfig: { + product: 'product', + metaobject: 'metaobject', + }, + }) + const dataExtension = new ExtensionInstance({ + configuration: { + product: {enabled: true}, + metaobject: {enabled: true}, + }, + configurationPath: 'shopify.app.toml', + directory: '/app', + specification: dataSpec, + }) + const remoteDataModule = { + registrationId: dataExtension.uid, + registrationUuid: 'data-uuid', + registrationTitle: 'Data', + type: 'data', + config: { + product: {enabled: false}, + metaobject: {enabled: true}, + }, + specification: { + identifier: 'data', + name: 'Data', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } as AppModuleVersion + + await ensureDeployIdentifiersFromAppVersion({ + app: testApp({...APP, allExtensions: [dataExtension], specifications: [dataSpec]}), + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient: testDeveloperPlatformClient(), + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: [remoteDataModule]}, + allowDeletes: true, + }) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: { + existingFieldNames: ['metaobject'], + existingUpdatedFieldNames: ['product'], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + }) + + test('compares webhook subscriptions by config content instead of matching by UID', async () => { + const localWebhookSubscription = await testSingleWebhookSubscriptionExtension({ + config: { + topic: 'orders/delete', + api_version: '2024-01', + uri: '/webhooks/orders/delete', + }, + }) + const remoteWebhookSubscription = { + registrationId: 'orders/delete::::https://example.com/webhooks/orders/delete', + registrationUuid: 'webhook-subscription-uuid', + registrationTitle: 'orders/delete::::https://example.com/webhooks/orders/delete', + type: 'webhook_subscription', + config: { + topic: 'orders/delete', + api_version: '2024-01', + uri: 'https://example.com/webhooks/orders/delete', + }, + specification: { + identifier: 'webhook_subscription', + name: 'Webhook Subscription', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } as AppModuleVersion + + await ensureDeployIdentifiersFromAppVersion({ + app: testApp({ + ...APP, + allExtensions: [localWebhookSubscription], + specifications: [WEBHOOK_SUBSCRIPTION_EXTENSION.specification], + }), + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient: testDeveloperPlatformClient(), + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: [remoteWebhookSubscription]}, + allowDeletes: true, + }) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + extensionIdentifiersBreakdown: { + onlyRemote: [], + toCreate: [], + toUpdate: [], + unchanged: [], + }, + configExtensionIdentifiersBreakdown: { + existingFieldNames: ['webhooks'], + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + }) + + test('does not mark webhooks as updated when subscriptions are in a different order', async () => { + const localWebhookSubscriptions = [ + await testSingleWebhookSubscriptionExtension({ + config: { + topic: 'orders/delete', + api_version: '2024-01', + uri: 'https://example.com/webhooks/orders/delete', + }, + }), + await testSingleWebhookSubscriptionExtension({ + config: { + topic: 'products/update', + api_version: '2024-01', + uri: 'https://example.com/webhooks/products/update', + }, + }), + ] + const remoteWebhookSubscriptions = [ + { + registrationId: 'products/update::::https://example.com/webhooks/products/update', + registrationUuid: 'webhook-subscription-uuid-1', + registrationTitle: 'products/update::::https://example.com/webhooks/products/update', + type: 'webhook_subscription', + config: { + topic: 'products/update', + api_version: '2024-01', + uri: 'https://example.com/webhooks/products/update', + }, + specification: { + identifier: 'webhook_subscription', + name: 'Webhook Subscription', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + }, + { + registrationId: 'orders/delete::::https://example.com/webhooks/orders/delete', + registrationUuid: 'webhook-subscription-uuid-2', + registrationTitle: 'orders/delete::::https://example.com/webhooks/orders/delete', + type: 'webhook_subscription', + config: { + topic: 'orders/delete', + api_version: '2024-01', + uri: 'https://example.com/webhooks/orders/delete', + }, + specification: { + identifier: 'webhook_subscription', + name: 'Webhook Subscription', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + }, + ] as AppModuleVersion[] + + await ensureDeployIdentifiersFromAppVersion({ + app: testApp({ + ...APP, + allExtensions: localWebhookSubscriptions, + specifications: [WEBHOOK_SUBSCRIPTION_EXTENSION.specification], + }), + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient: testDeveloperPlatformClient(), + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: remoteWebhookSubscriptions}, + allowDeletes: true, + }) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: { + existingFieldNames: ['webhooks'], + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + }) +}) + +describe('ensureDeployIdentifiersFromAppVersion', () => { + test('prompts with the existing UI breakdown shape and returns deploy identifiers', async () => { + const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient() + + const identifiers = await ensureDeployIdentifiersFromAppVersion({ + app: APP, + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient, + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: [REMOTE_EXTENSION_A, REMOTE_EXTENSION_DELETED, REMOTE_CONFIG_EXTENSION]}, + allowDeletes: true, + }) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith({ + extensionIdentifiersBreakdown: { + onlyRemote: [{title: 'Deleted Extension', uid: 'deleted-uid', experience: 'extension'}], + toCreate: [{title: EXTENSION_B.localIdentifier, uid: EXTENSION_B.uid, experience: 'extension'}], + toUpdate: [], + unchanged: [{title: EXTENSION_A.localIdentifier, uid: undefined, experience: 'extension'}], + }, + configExtensionIdentifiersBreakdown: { + existingFieldNames: [], + existingUpdatedFieldNames: [], + newFieldNames: ['pos'], + deletedFieldNames: [], + }, + appTitle: REMOTE_APP.title, + release: true, + allowUpdates: undefined, + allowDeletes: true, + installCount: undefined, + }) + expect(identifiers).toEqual({ + appModuleUuids: { + [EXTENSION_A.localIdentifier]: REMOTE_EXTENSION_A.registrationUuid, + [EXTENSION_B.localIdentifier]: EXTENSION_B.uid, + [CONFIG_EXTENSION.localIdentifier]: REMOTE_CONFIG_EXTENSION.registrationUuid, + }, + appModuleRegistrationIds: { + [EXTENSION_A.localIdentifier]: EXTENSION_A.uid, + [EXTENSION_B.localIdentifier]: EXTENSION_B.uid, + [CONFIG_EXTENSION.localIdentifier]: CONFIG_EXTENSION.uid, + }, + }) + }) + + test('runs extension migrations before classifying the app version', async () => { + const legacyRemoteExtension = { + uuid: 'legacy-uuid-a', + id: '', + title: EXTENSION_TO_MIGRATE.localIdentifier, + type: 'CHECKOUT_UI_EXTENSION', + } + const migratedModule = { + registrationId: EXTENSION_TO_MIGRATE.uid, + registrationUuid: legacyRemoteExtension.uuid, + registrationTitle: 'Legacy UI', + type: 'ui_extension', + config: await EXTENSION_TO_MIGRATE.deployConfig({apiKey: REMOTE_APP.apiKey, appConfiguration: APP.configuration}), + specification: { + identifier: 'ui_extension', + name: 'UI Extension', + experience: 'extension', + options: {managementExperience: 'cli'}, + }, + } as AppModuleVersion + const activeAppVersion = {appModuleVersions: [migratedModule]} + const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ + appExtensionRegistrations: () => + Promise.resolve({ + app: { + extensionRegistrations: [legacyRemoteExtension], + dashboardManagedExtensionRegistrations: [], + configurationRegistrations: [], + }, + }), + activeAppVersion: () => Promise.resolve(activeAppVersion), + }) + + await ensureDeployIdentifiersFromAppVersion({ + app: testApp({...APP, allExtensions: [EXTENSION_TO_MIGRATE]}), + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient, + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: []}, + allowUpdates: true, + }) + + expect(extensionMigrationPrompt).toHaveBeenCalledWith([ + {local: EXTENSION_TO_MIGRATE, remote: legacyRemoteExtension}, + ]) + expect(migrateExtensionsToUIExtension).toHaveBeenCalledWith({ + extensionsToMigrate: [{local: EXTENSION_TO_MIGRATE, remote: legacyRemoteExtension}], + appId: REMOTE_APP.apiKey, + remoteExtensions: [legacyRemoteExtension], + migrationClient: expect.objectContaining({clientName: 'partners'}), + }) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + extensionIdentifiersBreakdown: expect.objectContaining({ + unchanged: [{title: EXTENSION_TO_MIGRATE.localIdentifier, uid: undefined, experience: 'extension'}], + }), + }), + ) + }) + + test('aborts when extension migration is declined', async () => { + const legacyRemoteExtension = { + uuid: 'legacy-uuid-a', + id: '', + title: EXTENSION_TO_MIGRATE.localIdentifier, + type: 'CHECKOUT_UI_EXTENSION', + } + const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ + appExtensionRegistrations: () => + Promise.resolve({ + app: { + extensionRegistrations: [legacyRemoteExtension], + dashboardManagedExtensionRegistrations: [], + configurationRegistrations: [], + }, + }), + }) + vi.mocked(extensionMigrationPrompt).mockResolvedValue(false) + + await expect( + ensureDeployIdentifiersFromAppVersion({ + app: testApp({...APP, allExtensions: [EXTENSION_TO_MIGRATE]}), + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient, + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: []}, + }), + ).rejects.toThrow(AbortSilentError) + expect(deployOrReleaseConfirmationPrompt).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app/src/cli/services/context/deploy-app-version.ts b/packages/app/src/cli/services/context/deploy-app-version.ts new file mode 100644 index 00000000000..3c9bcacc315 --- /dev/null +++ b/packages/app/src/cli/services/context/deploy-app-version.ts @@ -0,0 +1,191 @@ +import { + buildDashboardBreakdownInfo, + buildConfigExtensionIdentifiersBreakdown, + buildExtensionBreakdownInfo, + ExtensionIdentifiersBreakdown, +} from './breakdown-extensions.js' +import {activeAppVersionAfterMigrations} from './deploy-app-version-migrations.js' +import {EnsureDeploymentIdsPresenceOptions} from './identifiers.js' +import {remoteAppConfigurationExtensionContent} from '../app/select-app.js' +import {AppInterface} from '../../models/app/app.js' +import {DeployIdentifiers, ExtensionUuidsByLocalIdentifier} from '../../models/app/identifiers.js' +import {MinimalOrganizationApp} from '../../models/organization.js' +import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' +import {AppModuleVersion, AppVersion} from '../../utilities/developer-platform-client.js' +import {AbortSilentError} from '@shopify/cli-kit/node/error' +import {deepMergeObjects} from '@shopify/cli-kit/common/object' + +type DeployExtensionChangeStatus = 'created' | 'updated' | 'deleted' | 'unchanged' + +interface DeployExtensionChange { + status: DeployExtensionChangeStatus + experience: 'configuration' | 'extension' | 'deprecated' + local?: ExtensionInstance + remote?: AppModuleVersion +} + +interface ClassifyDeployExtensionChangesOptions { + options: EnsureDeploymentIdsPresenceOptions + activeAppVersion?: AppVersion +} + +/** Builds deploy identifiers after migration, classification, and confirmation. */ +export async function ensureDeployIdentifiersFromAppVersion( + options: EnsureDeploymentIdsPresenceOptions, +): Promise { + const activeAppVersion = await activeAppVersionAfterMigrations(options) + const changes = await classifyDeployExtensionChanges({options, activeAppVersion}) + const extensionIdentifiersBreakdown = buildExtensionIdentifiersBreakdown(changes) + const configExtensionIdentifiersBreakdown = await buildDeployConfigExtensionIdentifiersBreakdown( + options, + activeAppVersion, + ) + + const shouldFetchInstallCount = + options.release && !options.allowDeletes && extensionIdentifiersBreakdown.onlyRemote.length > 0 + const installCount = shouldFetchInstallCount ? await fetchInstallCount(options).catch(() => undefined) : undefined + + const confirmed = await deployOrReleaseConfirmationPrompt({ + extensionIdentifiersBreakdown, + configExtensionIdentifiersBreakdown, + appTitle: options.remoteApp?.title, + release: options.release, + allowUpdates: options.allowUpdates, + allowDeletes: options.allowDeletes, + installCount, + }) + if (!confirmed) throw new AbortSilentError() + + return buildDeployIdentifiersFromChanges(changes) +} + +/** Classifies local and active-version extensions by deploy outcome. */ +export async function classifyDeployExtensionChanges({ + options, + activeAppVersion, +}: ClassifyDeployExtensionChangesOptions): Promise { + const remoteModules = activeAppVersion?.appModuleVersions ?? [] + const app = options.app + const localChanges = await Promise.all( + app.allExtensions.map(async (local): Promise => { + const experience = local.specification.experience + const remoteMatchByUID = remoteModules.find((remote) => remote.registrationId === local.uid) + if (remoteMatchByUID) { + return {experience, status: 'unchanged', local, remote: remoteMatchByUID} + } + + // Legacy match by UUID, used only if extensions are not migrated and don't have a remote UID. + const localUUID = options.envIdentifiers[local.localIdentifier] + const remoteByUUID = localUUID ? remoteModules.find((remote) => remote.registrationUuid === localUUID) : undefined + if (remoteByUUID) { + return {experience, status: 'updated', local, remote: remoteByUUID} + } + + return {experience, status: 'created', local} + }), + ) + + const matchedRemoteModules = localChanges.map((change) => change.remote) + const deletedChanges = remoteModules + .filter((remote) => !matchedRemoteModules.includes(remote)) + .map( + (remote): DeployExtensionChange => ({ + experience: remote.specification?.experience ?? 'extension', + status: 'deleted', + remote, + }), + ) + + return [...localChanges, ...deletedChanges] +} + +/** Converts config deploy changes into the existing config prompt breakdown. */ +async function buildDeployConfigExtensionIdentifiersBreakdown( + options: EnsureDeploymentIdsPresenceOptions, + activeAppVersion: AppVersion | undefined, +): Promise> { + const {app, appId} = options + const localConfig = await localAppConfigurationExtensionContent(app, appId) + const remoteConfig = remoteAppConfigurationExtensionContent( + activeAppVersion?.appModuleVersions ?? [], + app.specifications ?? [], + app.remoteFlags, + ) + + return buildConfigExtensionIdentifiersBreakdown(localConfig, remoteConfig) +} + +/** Converts deploy changes into the existing extension prompt breakdown. */ +function buildExtensionIdentifiersBreakdown(changes: DeployExtensionChange[]): ExtensionIdentifiersBreakdown { + const visibleChanges = changes.filter((change) => change.experience === 'extension') + + return { + onlyRemote: visibleChanges + .filter((change) => change.status === 'deleted') + .map((change) => buildRemoteBreakdownInfo(change.remote!)), + toCreate: visibleChanges + .filter((change) => change.status === 'created') + .map((change) => buildExtensionBreakdownInfo(change.local!.localIdentifier, change.local!.uid)), + toUpdate: visibleChanges + .filter((change) => change.status === 'updated') + .map((change) => buildExtensionBreakdownInfo(change.local!.localIdentifier, undefined)), + unchanged: visibleChanges + .filter((change) => change.status === 'unchanged') + .map((change) => buildExtensionBreakdownInfo(change.local!.localIdentifier, undefined)), + } +} + +/** Converts deploy changes into the identifiers needed by upload. */ +function buildDeployIdentifiersFromChanges(changes: DeployExtensionChange[]) { + const appModuleUuids: ExtensionUuidsByLocalIdentifier = {} + const appModuleRegistrationIds: ExtensionUuidsByLocalIdentifier = {} + + for (const change of changes) { + if (!change.local) continue + + const localUID = change.local.uid + const uuid = change.remote?.registrationUuid ?? localUID + const registrationId = change.remote?.registrationId ?? localUID + appModuleUuids[change.local.localIdentifier] = uuid + appModuleRegistrationIds[change.local.localIdentifier] = registrationId + } + + return {appModuleUuids, appModuleRegistrationIds} +} + +async function localAppConfigurationExtensionContent(app: AppInterface, apiKey: string) { + let appConfig: {[key: string]: unknown} = {} + const configExtensions = app.allExtensions.filter((extension) => extension.isAppConfigExtension) + + for (const extension of configExtensions) { + // eslint-disable-next-line no-await-in-loop + const deployConfig = await extension.deployConfig({apiKey, appConfiguration: app.configuration}) + const localConfig = + extension.specification.transformRemoteToLocal?.(deployConfig ?? {}, {flags: app.remoteFlags}) ?? + extension.configuration + appConfig = deepMergeObjects(appConfig, localConfig) + } + + return appConfig +} + +/** Builds prompt metadata for a remote-only module. */ +function buildRemoteBreakdownInfo(remote: AppModuleVersion) { + if (remote.specification?.options.managementExperience === 'dashboard') { + return buildDashboardBreakdownInfo(remote.registrationTitle) + } + return buildExtensionBreakdownInfo(remote.registrationTitle, remote.registrationId) +} + +/** Fetches install count for delete warnings. */ +async function fetchInstallCount(options: { + developerPlatformClient: EnsureDeploymentIdsPresenceOptions['developerPlatformClient'] + remoteApp: MinimalOrganizationApp +}) { + return options.developerPlatformClient.appInstallCount({ + id: options.remoteApp.id, + apiKey: options.remoteApp.apiKey, + organizationId: options.remoteApp.organizationId, + }) +} diff --git a/packages/app/src/cli/services/context/id-manual-matching.test.ts b/packages/app/src/cli/services/context/id-manual-matching.test.ts deleted file mode 100644 index f03fe7bdb14..00000000000 --- a/packages/app/src/cli/services/context/id-manual-matching.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -/* eslint-disable @shopify/prefer-module-scope-constants */ -import {manualMatchIds, ManualMatchResult} from './id-manual-matching.js' -import {RemoteSource} from './identifiers.js' -import {testUIExtension} from '../../models/app/app.test-data.js' -import {ExtensionInstance} from '../../models/extensions/extension-instance.js' -import {describe, expect, vi, test, beforeAll} from 'vitest' -import {renderAutocompletePrompt} from '@shopify/cli-kit/node/ui' - -vi.mock('@shopify/cli-kit/node/ui') - -const REGISTRATION_A: RemoteSource = { - uuid: 'UUID_A', - id: 'A', - title: 'A', - type: 'CHECKOUT_POST_PURCHASE', -} - -const REGISTRATION_A_2 = { - uuid: 'UUID_A_2', - id: 'A_2', - title: 'A_2', - type: 'CHECKOUT_POST_PURCHASE', -} - -let EXTENSION_A: ExtensionInstance -let EXTENSION_A_2: ExtensionInstance -let EXTENSION_B: ExtensionInstance - -beforeAll(async () => { - EXTENSION_A = await testUIExtension({ - directory: '/EXTENSION_A', - configuration: { - name: 'EXTENSION A', - type: 'checkout_post_purchase', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - }) - - EXTENSION_A_2 = await testUIExtension({ - directory: '/EXTENSION_A_2', - configuration: { - name: 'EXTENSION A 2', - type: 'checkout_post_purchase', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - }) - - EXTENSION_B = await testUIExtension({ - directory: '/EXTENSION_B', - configuration: { - name: 'EXTENSION B', - type: 'checkout_post_purchase', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - }) -}) - -describe('manualMatch: when all sources are matched', () => { - test('returns IDs', async () => { - // Given - vi.mocked(renderAutocompletePrompt).mockResolvedValueOnce('UUID_A') - vi.mocked(renderAutocompletePrompt).mockResolvedValueOnce('UUID_A_2') - - // When - const got = await manualMatchIds({local: [EXTENSION_A, EXTENSION_A_2], remote: [REGISTRATION_A, REGISTRATION_A_2]}) - - // Then - const expected: ManualMatchResult = { - identifiers: {'extension-a': 'UUID_A', 'extension-a-2': 'UUID_A_2'}, - toCreate: [], - onlyRemote: [], - } - expect(got).toEqual(expected) - }) -}) - -describe('manualMatch: when there are more local sources', () => { - test('returns IDs and some sources pending creation', async () => { - // Given - vi.mocked(renderAutocompletePrompt).mockResolvedValueOnce('UUID_A') - - // When - const got = await manualMatchIds({local: [EXTENSION_A, EXTENSION_A_2], remote: [REGISTRATION_A]}) - - // Then - const expected: ManualMatchResult = { - identifiers: {'extension-a': 'UUID_A'}, - toCreate: [EXTENSION_A_2], - onlyRemote: [], - } - expect(got).toEqual(expected) - }) -}) - -describe('manualMatch: when there are more local sources and user selects to create', () => { - test('returns IDs and some sources pending creation', async () => { - // Given - vi.mocked(renderAutocompletePrompt).mockResolvedValueOnce('UUID_A') - vi.mocked(renderAutocompletePrompt).mockResolvedValueOnce('create') - vi.mocked(renderAutocompletePrompt).mockResolvedValueOnce('UUID_A_2') - - // When - const got = await manualMatchIds({ - local: [EXTENSION_A, EXTENSION_A_2, EXTENSION_B], - remote: [REGISTRATION_A, REGISTRATION_A_2], - }) - - // Then - const expected: ManualMatchResult = { - identifiers: {'extension-a': 'UUID_A', 'extension-b': 'UUID_A_2'}, - toCreate: [EXTENSION_A_2], - onlyRemote: [], - } - expect(got).toEqual(expected) - }) -}) - -describe('manualMatch: when not all remote sources are matched', () => { - test('returns matched IDs and only remote sources', async () => { - // Given - vi.mocked(renderAutocompletePrompt).mockResolvedValueOnce('create') - vi.mocked(renderAutocompletePrompt).mockResolvedValueOnce('create') - vi.mocked(renderAutocompletePrompt).mockResolvedValueOnce('create') - - // When - const got = await manualMatchIds({ - local: [EXTENSION_A, EXTENSION_A_2, EXTENSION_B], - remote: [REGISTRATION_A, REGISTRATION_A_2], - }) - - // Then - const expected: ManualMatchResult = { - identifiers: {}, - toCreate: [EXTENSION_A, EXTENSION_A_2, EXTENSION_B], - onlyRemote: [REGISTRATION_A, REGISTRATION_A_2], - } - expect(got).toEqual(expected) - }) -}) diff --git a/packages/app/src/cli/services/context/id-manual-matching.ts b/packages/app/src/cli/services/context/id-manual-matching.ts deleted file mode 100644 index 7e0ca4fd59c..00000000000 --- a/packages/app/src/cli/services/context/id-manual-matching.ts +++ /dev/null @@ -1,50 +0,0 @@ -import {selectRemoteSourcePrompt} from './prompts.js' -import {LocalSource, RemoteSource} from './identifiers.js' -import {IdentifiersExtensions} from '../../models/app/identifiers.js' - -export interface ManualMatchResult { - identifiers: IdentifiersExtensions - toCreate: LocalSource[] - onlyRemote: RemoteSource[] -} - -/** - * Prompt the user to manually match each of the local sources to a remote source. - * Sources can either be extensions or functions. - * - * The user can also select to create a new remote source instead of selecting an existing one. - * Manual matching will only show sources of the same type as possible matches. - * At the end of this process, all remote sources must be matched with the local sources to succeed. - * - * @param local - The local sources to match - * @param remote - The remote sources to match - * @returns The result of the manual matching - */ -export async function manualMatchIds(options: { - local: LocalSource[] - remote: RemoteSource[] -}): Promise { - const identifiers: {[key: string]: string} = {} - let pendingRemote = options.remote - let pendingLocal = options.local - - for (const currentLocal of options.local) { - const remoteSourcesOfSameType = pendingRemote.filter( - (remoteSource) => remoteSource.type === currentLocal.graphQLType, - ) - if (remoteSourcesOfSameType.length === 0) continue - // eslint-disable-next-line no-await-in-loop - const selected = await selectRemoteSourcePrompt(currentLocal, remoteSourcesOfSameType) - if (!selected) continue - - identifiers[currentLocal.localIdentifier] = selected.uuid - pendingRemote = pendingRemote.filter((remote) => remote.uuid !== selected.uuid) - pendingLocal = pendingLocal.filter((local) => local.localIdentifier !== currentLocal.localIdentifier) - } - - return { - identifiers, - toCreate: pendingLocal, - onlyRemote: pendingRemote, - } -} diff --git a/packages/app/src/cli/services/context/id-matching.test.ts b/packages/app/src/cli/services/context/id-matching.test.ts deleted file mode 100644 index ecc126e2f3b..00000000000 --- a/packages/app/src/cli/services/context/id-matching.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -/* eslint-disable @shopify/prefer-module-scope-constants */ -import {automaticMatchmaking} from './id-matching.js' -import {RemoteSource} from './identifiers.js' -import {ExtensionInstance} from '../../models/extensions/extension-instance.js' -import {testFunctionExtension, testUIExtension} from '../../models/app/app.test-data.js' -import {describe, expect, vi, test, beforeAll} from 'vitest' -import {outputInfo} from '@shopify/cli-kit/node/output' - -vi.mock('../dev/fetch') -vi.mock('@shopify/cli-kit/node/output') - -const REGISTRATION_A: RemoteSource = { - uuid: 'UUID_A', - id: 'A', - title: 'EXTENSION_A', - type: 'checkout_post_purchase', -} - -const REGISTRATION_A_2: RemoteSource = { - uuid: 'UUID_A_2', - id: 'A_2', - title: 'EXTENSION_A_2', - type: 'checkout_post_purchase', -} - -const REGISTRATION_A_3: RemoteSource = { - uuid: 'UUID_A_3', - id: 'A_3', - title: 'EXTENSION_A_3', - type: 'checkout_post_purchase', -} - -const REGISTRATION_A_4: RemoteSource = { - uuid: 'UUID_A_4', - id: 'A_4', - title: 'EXTENSION_A_4', - type: 'checkout_post_purchase', -} - -const REGISTRATION_B: RemoteSource = { - uuid: 'UUID_B', - id: 'B', - title: 'EXTENSION_B', - type: 'subscription_management', -} - -const REGISTRATION_C: RemoteSource = { - uuid: 'UUID_C', - id: 'C', - title: 'EXTENSION_C', - type: 'theme_app_extension', -} - -const REGISTRATION_D: RemoteSource = { - uuid: 'UUID_D', - id: 'D', - title: 'EXTENSION_D', - type: 'web_pixel_extension', -} - -// Same as REGISTRATION_D but with a different type using external_identifier -const REGISTRATION_D_WITH_EXTERNAL_ID: RemoteSource = { - uuid: 'UUID_D', - id: 'D', - title: 'EXTENSION_D', - type: 'web_pixel_extension_external', -} - -const REGISTRATION_FUNCTION_A: RemoteSource = { - uuid: 'FUNCTION_UUID_A', - id: 'FUNCTION_A', - title: 'FUNCTION A', - type: 'function', - draftVersion: { - config: JSON.stringify({ - legacy_function_id: 'LEGACY_FUNCTION_ULID_A', - legacy_function_uuid: 'LEGACY_FUNCTION_UUID_A', - }), - }, -} - -let EXTENSION_A: ExtensionInstance -let EXTENSION_A_2: ExtensionInstance -let EXTENSION_B: ExtensionInstance -let EXTENSION_B_2: ExtensionInstance -let EXTENSION_C: ExtensionInstance -let EXTENSION_D: ExtensionInstance -let FUNCTION_A: ExtensionInstance - -beforeAll(async () => { - EXTENSION_A = await testUIExtension({ - directory: '/EXTENSION_A', - configuration: { - name: 'EXTENSION A', - type: 'checkout_post_purchase', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - uid: 'UUID_A', - }) - - EXTENSION_A_2 = await testUIExtension({ - directory: '/EXTENSION_A_2', - configuration: { - name: 'EXTENSION A 2', - type: 'checkout_post_purchase', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - uid: 'UIUD_A_2', - }) - - EXTENSION_B = await testUIExtension({ - directory: '/EXTENSION_B', - configuration: { - name: 'EXTENSION B', - type: 'product_subscription', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - uid: 'UUID_B', - }) - - EXTENSION_B_2 = await testUIExtension({ - directory: '/EXTENSION_B_2', - configuration: { - name: 'EXTENSION B 2', - type: 'product_subscription', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - uid: 'UUID_B_2', - }) - - EXTENSION_C = await testUIExtension({ - directory: '/EXTENSION_C', - configuration: { - name: 'EXTENSION C', - type: 'theme', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - uid: 'UUID_C', - }) - - EXTENSION_D = await testUIExtension({ - directory: '/EXTENSION_D', - configuration: { - name: 'EXTENSION D', - type: 'web_pixel_extension', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - outputPath: '', - entrySourceFilePath: '', - devUUID: 'devUUID', - uid: 'UUID_D', - }) - - FUNCTION_A = await testFunctionExtension({ - dir: '/FUNCTION_A', - config: { - name: 'FUNCTION A', - type: 'function', - description: 'Function', - build: { - command: 'make build', - path: 'dist/index.wasm', - wasm_opt: true, - }, - configuration_ui: false, - api_version: '2022-07', - }, - }) -}) - -describe('automaticMatchmaking', () => { - test('creates all local extensions when there are no remote ones', async () => { - // When - const got = await automaticMatchmaking([EXTENSION_A, EXTENSION_B], [], {}) - - // Then - const expected = { - identifiers: {}, - toConfirm: [], - toCreate: [EXTENSION_A, EXTENSION_B], - toManualMatch: {local: [], remote: []}, - } - expect(got).toEqual(expected) - }) - - test('creates the missing extension when there is a remote one', async () => { - // When - const registrationA = {...REGISTRATION_A, id: ''} - const got = await automaticMatchmaking([EXTENSION_A, EXTENSION_A_2], [registrationA], {'extension-a': 'UUID_A'}) - - // Then - const expected = { - identifiers: {'extension-a': 'UUID_A'}, - toConfirm: [], - toCreate: [EXTENSION_A_2], - toManualMatch: {local: [], remote: []}, - } - - expect(got).toEqual(expected) - }) -}) - -describe('outputAddedIDs', () => { - test('prints extension IDs when extensions are matched without UID', async () => { - // Clear any previous mock calls - vi.mocked(outputInfo).mockClear() - - // Extension B has a valid UID - // Extension C is marked as toCreate because we only try to match by UID (because it has a real one) - const registrationA = {...REGISTRATION_A, id: ''} - const registrationB = {...REGISTRATION_B, id: EXTENSION_B.uid} - const registrationC = {...REGISTRATION_C} - const registrationD = {...REGISTRATION_D, id: ''} - - // When: Extensions are matched by UUID (not by UID) - const result = await automaticMatchmaking( - [EXTENSION_A, EXTENSION_B, EXTENSION_C, EXTENSION_D], - [registrationA, registrationB, registrationC, registrationD], - { - 'extension-a': 'UUID_A', - 'extension-b': 'UUID_B', - 'extension-c': 'UUID_C', - 'extension-d': 'UUID_D', - }, - ) - - // Then: outputInfo should be called with the expected messages - expect(outputInfo).toHaveBeenCalledWith('Generating extension IDs\n') - expect(outputInfo).toHaveBeenCalledWith(expect.stringContaining('\x1B[36mextension-a\x1B[39m | Added ID: UUID_A')) - expect(outputInfo).not.toHaveBeenCalledWith(expect.stringContaining('Added ID: UUID_B')) - expect(outputInfo).not.toHaveBeenCalledWith(expect.stringContaining('Added ID: UUID_C')) - expect(outputInfo).toHaveBeenCalledWith(expect.stringContaining('\x1B[35mextension-d\x1B[39m | Added ID: UUID_D')) - expect(outputInfo).toHaveBeenCalledWith('\n') - - // Verify it was called 4 times total (header + 2 extensions + footer) - expect(outputInfo).toHaveBeenCalledTimes(4) - - expect(result.toCreate).toEqual([EXTENSION_C]) - }) -}) diff --git a/packages/app/src/cli/services/context/id-matching.ts b/packages/app/src/cli/services/context/id-matching.ts deleted file mode 100644 index 24ca7daa04d..00000000000 --- a/packages/app/src/cli/services/context/id-matching.ts +++ /dev/null @@ -1,231 +0,0 @@ -import {RemoteSource, LocalSource} from './identifiers.js' -import {IdentifiersExtensions} from '../../models/app/identifiers.js' -import {ExtensionInstance} from '../../models/extensions/extension-instance.js' -import {groupBy, partition} from '@shopify/cli-kit/common/collection' -import {uniqBy, difference} from '@shopify/cli-kit/common/array' -import {pickBy} from '@shopify/cli-kit/common/object' -import {slugify} from '@shopify/cli-kit/common/string' -import {outputInfo} from '@shopify/cli-kit/node/output' -import colors from '@shopify/cli-kit/node/colors' - -export interface LocalRemoteSource { - local: LocalSource - remote: RemoteSource -} - -interface MatchResult { - identifiers: IdentifiersExtensions - toConfirm: LocalRemoteSource[] - toCreate: LocalSource[] - toManualMatch: {local: LocalSource[]; remote: RemoteSource[]} -} - -/** - * Filter function to match a local and a remote source by type and handle - */ -const sameTypeAndName = (local: LocalSource, remote: RemoteSource) => { - const isSameType = - remote.type.toLowerCase() === local.graphQLType.toLowerCase() || - remote.type.toLowerCase() === (local as ExtensionInstance).externalType.toLowerCase() || - remote.type.toLowerCase() === (local as ExtensionInstance).type.toLowerCase() - - // In this case, remote.title represents the remote handle. - // This needs to be cleaned up in the future in the `AppModuleVersion` transformation - return isSameType && slugify(remote.title) === slugify(local.handle) -} - -/** - * Automatically match local and remote sources if they have the same type and handle, and then only by type - * - * If multiple local or remote sources have the same type and handle, they can't be matched automatically - */ -function matchByNameAndType( - local: LocalSource[], - remote: RemoteSource[], -): { - matched: IdentifiersExtensions - toCreate: LocalSource[] - toConfirm: {local: LocalSource; remote: RemoteSource}[] - toManualMatch: {local: LocalSource[]; remote: RemoteSource[]} -} { - // We try to automatically match sources if they have the same name and type, - // by considering local sources which are missing on the remote side and - // remote sources which are not synchronized locally. - const uniqueLocal = uniqBy(local, (elem) => [elem.graphQLType, elem.handle]) - const uniqueRemote = uniqBy(remote, (elem) => [elem.type, elem.title]) - - const matched: IdentifiersExtensions = {} - - uniqueLocal.forEach((localSource) => { - const possibleMatch = uniqueRemote.find((remoteSource) => sameTypeAndName(localSource, remoteSource)) - if (possibleMatch) matched[localSource.localIdentifier] = possibleMatch.uuid - }) - - const pendingLocal = local.filter((elem) => !matched[elem.localIdentifier]) - const pendingRemote = remote.filter((registration) => !Object.values(matched).includes(registration.uuid)) - - // Now we try to find a match between a local source and remote one if they have - // the same type and they are unique even if they have different names. For example: - // LOCAL_CHECKOUT_UI_NAMED_APPLE -> REMOTE_CHECKOUT_UI_NAMED_PEAR - // LOCAL_PROD_SUBSCR_NAMED_ORANGE -> REMOTE_PROD_SUBSCR_NAMED_LEMON - const {toConfirm, toCreate, toManualMatch} = matchByUniqueType(pendingLocal, pendingRemote) - - return {matched, toCreate, toConfirm, toManualMatch} -} - -/** - * Automatically match local and remote sources if they have the same UID - */ -function matchByUIDandUUID( - local: LocalSource[], - remote: RemoteSource[], - ids: IdentifiersExtensions, -): { - matched: IdentifiersExtensions - toCreate: LocalSource[] - toConfirm: {local: LocalSource; remote: RemoteSource}[] - toManualMatch: {local: LocalSource[]; remote: RemoteSource[]} -} { - const matchedByUID: IdentifiersExtensions = {} - const pendingLocal: LocalSource[] = [] - const matchedByUUID: IdentifiersExtensions = {} - - // First, try to match by UID, then by UUID. - // But only accept a UUID match if the ID for the remote source is empty, meaning is still pending migration. - local.forEach((localSource) => { - const matchByUID = remote.find((remoteSource) => remoteSource.id === localSource.uid) - const matchByUUID = remote.find((remoteSource) => remoteSource.uuid === ids[localSource.localIdentifier]) - - if (matchByUID) { - matchedByUID[localSource.localIdentifier] = matchByUID.id - } else if (matchByUUID && matchByUUID.id.length === 0) { - matchedByUUID[localSource.localIdentifier] = matchByUUID.uuid - } else { - pendingLocal.push(localSource) - } - }) - - // Remote source with a valid UID is not pending, shouldn't be tried to be matched by name/type. - const pendingRemote = remote.filter( - (remoteSource) => - !Object.values(matchedByUUID).includes(remoteSource.uuid) && - !Object.values(matchedByUID).includes(remoteSource.id) && - remoteSource.id.length === 0, - ) - - // Then, try to match by name and type as a last resort. - const {matched: matchedByName, toCreate, toConfirm, toManualMatch} = matchByNameAndType(pendingLocal, pendingRemote) - - // List of modules that were matched using anything other than the UID, meaning that they are being migrated to dev dash - const totalMatchedWithoutUID = {...matchedByUUID, ...matchedByName} - const localMatchedWithoutUID = local.filter((localSource) => totalMatchedWithoutUID[localSource.localIdentifier]) - - outputAddedIDs(localMatchedWithoutUID) - - return { - matched: {...matchedByUID, ...totalMatchedWithoutUID}, - toCreate, - toConfirm, - toManualMatch, - } -} - -function outputAddedIDs(localMatchedWithoutUID: LocalSource[]) { - if (localMatchedWithoutUID.length === 0) return - const colorList = [colors.cyan, colors.magenta, colors.blue, colors.green, colors.yellow, colors.red] - - const maxHandleLength = localMatchedWithoutUID.reduce((max, local) => Math.max(max, local.handle.length), 0) - outputInfo('Generating extension IDs\n') - localMatchedWithoutUID.forEach((local, index) => { - const color = colorList[index % colorList.length] ?? colors.white - outputInfo(`${color(local.handle.padStart(maxHandleLength))} | Added ID: ${local.uid}`) - }) - outputInfo('\n') -} - -/** - * Ask the user to confirm the relationship between a local source and a remote source if they - * the only ones of their types. - */ -function matchByUniqueType( - localSources: LocalSource[], - remoteSources: RemoteSource[], -): { - toCreate: LocalSource[] - toConfirm: {local: LocalSource; remote: RemoteSource}[] - toManualMatch: {local: LocalSource[]; remote: RemoteSource[]} -} { - const localGroups = groupBy(localSources, 'graphQLType') - const localUnique = Object.values(pickBy(localGroups, (group, _key) => group.length === 1)).flat() - - const remoteWithNormalizedType = remoteSources.map((remote) => ({...remote, type: remote.type.toLowerCase()})) - const remoteGroups = groupBy(remoteWithNormalizedType, 'type') - const remoteUniqueMap = pickBy(remoteGroups, (group, _key) => group.length === 1) - - const toConfirm: {local: LocalSource; remote: RemoteSource}[] = [] - const toCreate: LocalSource[] = [] - - // for every local source that has a unique type we either: - // - find a corresponding unique remote source and ask the user to confirm - // - create it from scratch - for (const local of localUnique) { - const remote = remoteUniqueMap[local.graphQLType.toLowerCase()] - if (remote && remote[0]) { - toConfirm.push({local, remote: remote[0]}) - } else { - toCreate.push(local) - } - } - - // now for every local source with a duplicated type we check - // if there is a remote source with the same type. if the answer is no, - // it means that we need to create them. - const localDuplicated = difference(localSources, localUnique) - const remotePending = difference( - remoteWithNormalizedType, - toConfirm.map((elem) => elem.remote), - ) - const [localPending, localToCreate] = partition(localDuplicated, (local) => - remotePending.map((remote) => remote.type.toLowerCase()).includes(local.graphQLType.toLowerCase()), - ) - toCreate.push(...localToCreate) - - return { - toCreate, - toConfirm, - toManualMatch: { - local: localPending, - remote: remotePending, - }, - } -} - -/** - * Automatically match local sources to remote sources. - * If we can't match a local source to any remote sources, we can create it. - * If we are unsure about the matching we can ask the user to confirm the relationship. - */ -export async function automaticMatchmaking( - localSources: LocalSource[], - remoteSources: RemoteSource[], - identifiers: IdentifiersExtensions, -): Promise { - const ids = getExtensionIds(localSources, identifiers) - const {matched, toCreate, toConfirm, toManualMatch} = matchByUIDandUUID(localSources, remoteSources, ids) - - return { - identifiers: {...ids, ...matched}, - toConfirm, - toCreate, - toManualMatch, - } -} - -export function getExtensionIds( - localSources: LocalSource[], - identifiers: IdentifiersExtensions, -): IdentifiersExtensions { - const localSourcesIds = localSources.map((source) => source.localIdentifier) - - return pickBy(identifiers, (_, id) => localSourcesIds.includes(id)) -} diff --git a/packages/app/src/cli/services/context/identifiers-extensions.test.ts b/packages/app/src/cli/services/context/identifiers-extensions.test.ts deleted file mode 100644 index ec5c8b3f3de..00000000000 --- a/packages/app/src/cli/services/context/identifiers-extensions.test.ts +++ /dev/null @@ -1,1099 +0,0 @@ -/* eslint-disable @shopify/prefer-module-scope-constants */ -import {automaticMatchmaking} from './id-matching.js' -import { - deployConfirmed, - ensureExtensionsIds, - ensureNonUuidManagedExtensionsIds, - groupRegistrationByUidStrategy, -} from './identifiers-extensions.js' -import {extensionMigrationPrompt, matchConfirmationPrompt} from './prompts.js' -import {manualMatchIds} from './id-manual-matching.js' -import {EnsureDeploymentIdsPresenceOptions, LocalSource, RemoteSource} from './identifiers.js' -import {AppInterface} from '../../models/app/app.js' -import { - testApp, - testAppConfigExtensions, - testFunctionExtension, - testOrganizationApp, - testUIExtension, - testPaymentsAppExtension, - testDeveloperPlatformClient, - testSingleWebhookSubscriptionExtension, -} from '../../models/app/app.test-data.js' -import {migrateExtensionsToUIExtension} from '../dev/migrate-to-ui-extension.js' -import {OrganizationApp} from '../../models/organization.js' -import {ExtensionInstance} from '../../models/extensions/extension-instance.js' -import {DeveloperPlatformClient, Flag} from '../../utilities/developer-platform-client.js' -import appPOSSpec from '../../models/extensions/specifications/app_config_point_of_sale.js' -import appWebhookSubscriptionSpec from '../../models/extensions/specifications/app_config_webhook_subscription.js' -import {getModulesToMigrate} from '../dev/migrate-app-module.js' -import {ExtensionSpecification} from '../../models/extensions/specification.js' -import {PartnersClient} from '../../utilities/developer-platform-client/partners-client.js' -import {beforeEach, describe, expect, vi, test, beforeAll} from 'vitest' -import {AbortSilentError} from '@shopify/cli-kit/node/error' -import {setPathValue} from '@shopify/cli-kit/common/object' - -const REGISTRATION_A = { - uuid: 'UUID_A', - id: 'A', - title: 'A', - type: 'CHECKOUT_POST_PURCHASE', - contextValue: '', -} - -const REGISTRATION_A_2 = { - uuid: 'UUID_A_2', - id: 'A_2', - title: 'A_2', - type: 'CHECKOUT_POST_PURCHASE', - contextValue: '', -} - -const REGISTRATION_A_3 = { - uuid: 'UUID_A_3', - id: 'A_3', - title: 'A_3', - type: 'CHECKOUT_POST_PURCHASE', - contextValue: '', -} - -const REGISTRATION_B = { - uuid: 'UUID_B', - id: 'B', - title: 'B', - type: 'SUBSCRIPTION_MANAGEMENT', - contextValue: '', -} - -const FUNCTION_REGISTRATION_A = { - uuid: 'FUNCTION_A_UUID', - id: 'FUNCTION_A', - title: 'FUNCTION_A', - type: 'FUNCTION', - contextValue: '', -} - -const PAYMENTS_REGISTRATION_A = { - uuid: 'PAYMENTS_A_UUID', - id: 'PAYMENTS_A', - title: 'PAYMENTS_A', - type: 'PAYMENTS', - contextValue: 'payments.offsite.render', -} - -let EXTENSION_A: ExtensionInstance -let EXTENSION_A_2: ExtensionInstance -let EXTENSION_B: ExtensionInstance -let FUNCTION_A: ExtensionInstance -let PAYMENTS_A: ExtensionInstance - -const LOCAL_APP = ( - uiExtensions: ExtensionInstance[], - functionExtensions: ExtensionInstance[] = [], - includeDeployConfig = false, - configExtensions: ExtensionInstance[] = [], -): AppInterface => { - return testApp({ - name: 'my-app', - directory: '/app', - configuration: { - client_id: 'test-client-id', - name: 'my-app', - application_url: 'https://example.com', - embedded: true, - access_scopes: { - scopes: 'read_products', - }, - extension_directories: ['extensions/*'], - ...(includeDeployConfig ? {build: {include_config_on_deploy: true}} : {}), - }, - allExtensions: [...uiExtensions, ...functionExtensions, ...configExtensions], - }) -} - -const options = ( - uiExtensions: ExtensionInstance[], - functionExtensions: ExtensionInstance[] = [], - options: { - identifiers?: any - remoteApp?: OrganizationApp - release?: boolean - includeDeployConfig?: boolean - configExtensions?: ExtensionInstance[] - flags?: Flag[] - developerPlatformClient?: DeveloperPlatformClient - } = {}, -): EnsureDeploymentIdsPresenceOptions => { - const { - identifiers = {}, - remoteApp = testOrganizationApp(), - release = true, - includeDeployConfig = false, - configExtensions = [], - flags = [], - developerPlatformClient = testDeveloperPlatformClient(), - } = options - const localApp = { - app: LOCAL_APP(uiExtensions, functionExtensions, includeDeployConfig, configExtensions), - developerPlatformClient, - appId: 'appId', - appName: 'appName', - envIdentifiers: {extensions: identifiers}, - force: false, - remoteApp, - release, - } - setPathValue(localApp.app, 'remoteFlags', flags) - return localApp -} - -vi.mock('@shopify/cli-kit/node/session') - -vi.mock('./prompts', async () => { - const prompts: any = await vi.importActual('./prompts') - return { - ...prompts, - matchConfirmationPrompt: vi.fn(), - deployConfirmationPrompt: vi.fn(), - extensionMigrationPrompt: vi.fn(), - } -}) -vi.mock('./id-matching') -vi.mock('./id-manual-matching') -vi.mock('../dev/migrate-to-ui-extension') -vi.mock('../dev/migrate-app-module') -beforeAll(async () => { - EXTENSION_A = await testUIExtension({ - directory: 'EXTENSION_A', - type: 'checkout_post_purchase', - configuration: { - name: 'EXTENSION A', - type: 'checkout_post_purchase', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - }) - - EXTENSION_A_2 = await testUIExtension({ - directory: 'EXTENSION_A_2', - type: 'checkout_post_purchase', - configuration: { - name: 'EXTENSION A 2', - type: 'checkout_post_purchase', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - }) - - EXTENSION_B = await testUIExtension({ - directory: 'EXTENSION_B', - type: 'checkout_post_purchase', - configuration: { - name: 'EXTENSION_B', - type: 'checkout_post_purchase', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - }) - - FUNCTION_A = await testFunctionExtension({ - dir: '/function', - config: { - name: 'FUNCTION A', - type: 'product_discounts', - description: 'Function', - build: { - command: 'make build', - path: 'dist/index.wasm', - wasm_opt: true, - }, - configuration_ui: false, - api_version: '2022-07', - }, - }) - - PAYMENTS_A = await testPaymentsAppExtension({ - dir: '/payments', - config: { - name: 'Payments Extension', - type: 'payments_extension', - description: 'Payments App Extension', - api_version: '2022-07', - payment_session_url: 'https://example.com/payment', - supported_countries: ['US'], - supported_payment_methods: ['VISA'], - test_mode_available: true, - merchant_label: 'Merchant Label', - supports_installments: false, - supports_deferred_payments: false, - supports_3ds: false, - targeting: [{target: 'payments.offsite.render'}], - }, - }) -}) - -beforeEach(() => { - vi.mocked(getModulesToMigrate).mockReturnValue([]) -}) - -describe('matchmaking returns more remote sources than local', () => { - test('ensureExtensionsIds', async () => { - // Given - vi.mocked(matchConfirmationPrompt).mockResolvedValueOnce(true) - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [REGISTRATION_B], - }, - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_A]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_B], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [], - validMatches: { - EXTENSION_A: 'UUID_A', - }, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = { - EXTENSION_A: 'UUID_A', - } - const remoteExtensions = [REGISTRATION_A, REGISTRATION_B] - - // When - const got = await deployConfirmed(options([EXTENSION_A]), remoteExtensions, [], {extensionsToCreate, validMatches}) - - // Then - expect(got).toEqual({ - extensions: { - EXTENSION_A: 'UUID_A', - }, - extensionIds: {EXTENSION_A: 'A'}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with pending manual matches', () => { - test('ensureExtensionsIds: will call manualMatch and merge automatic and manual matches and create missing extensions', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [EXTENSION_A, EXTENSION_A_2, EXTENSION_B], - remote: [REGISTRATION_A, REGISTRATION_A_2], - }, - }) - - vi.mocked(manualMatchIds).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - toCreate: [EXTENSION_B], - onlyRemote: [], - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_A, EXTENSION_A_2]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_A_2], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(manualMatchIds).toHaveBeenCalledWith({ - local: [EXTENSION_A, EXTENSION_A_2, EXTENSION_B], - remote: [REGISTRATION_A, REGISTRATION_A_2], - }) - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [EXTENSION_B], - validMatches: { - EXTENSION_A: 'UUID_A', - EXTENSION_A_2: 'UUID_A_2', - }, - didMigrateDashboardExtensions: false, - }) - }) - - test('deployConfirmed: create missing extensions', async () => { - // Given - const extensionsToCreate: LocalSource[] = [EXTENSION_B] - const validMatches = { - EXTENSION_A: 'UUID_A', - EXTENSION_A_2: 'UUID_A_2', - } - const remoteExtensions = [REGISTRATION_A, REGISTRATION_A_2] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed( - options([EXTENSION_A, EXTENSION_A_2], [], {developerPlatformClient}), - remoteExtensions, - [], - { - extensionsToCreate, - validMatches, - }, - ) - - // Then - expect(got).toEqual({ - extensions: { - EXTENSION_A: 'UUID_A', - EXTENSION_A_2: 'UUID_A_2', - 'extension-b': EXTENSION_B.uid, - }, - extensionIds: {EXTENSION_A: 'A', EXTENSION_A_2: 'A_2', 'extension-b': EXTENSION_B.uid}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with pending manual matches and manual match fails', () => { - test('ensureExtensionsIds', async () => { - // Given - vi.mocked(matchConfirmationPrompt).mockResolvedValueOnce(true) - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [EXTENSION_A, EXTENSION_A_2], - remote: [REGISTRATION_A, REGISTRATION_A_2], - }, - }) - vi.mocked(manualMatchIds).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A'}, - toCreate: [EXTENSION_A_2], - onlyRemote: [REGISTRATION_A_2], - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_A, EXTENSION_A_2]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_A_2], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [EXTENSION_A_2], - validMatches: { - EXTENSION_A: 'UUID_A', - }, - didMigrateDashboardExtensions: false, - }) - expect(manualMatchIds).toBeCalledWith({ - local: [EXTENSION_A, EXTENSION_A_2], - remote: [REGISTRATION_A, REGISTRATION_A_2], - }) - }) - test('deployConfirmed', async () => { - // Given - const extensionsToCreate: LocalSource[] = [EXTENSION_A_2] - const validMatches = { - EXTENSION_A: 'UUID_A', - } - const remoteExtensions = [REGISTRATION_A, REGISTRATION_A_2] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed( - options([EXTENSION_A, EXTENSION_A_2], [], {developerPlatformClient}), - remoteExtensions, - [], - { - extensionsToCreate, - validMatches, - }, - ) - - // Then - expect(got).toEqual({ - extensions: { - EXTENSION_A: 'UUID_A', - 'extension-a-2': EXTENSION_A_2.uid, - }, - extensionIds: {EXTENSION_A: 'A', 'extension-a-2': EXTENSION_A_2.uid}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with pending some pending to create', () => { - test('ensureExtensionsIds', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {}, - toConfirm: [], - toCreate: [EXTENSION_A, EXTENSION_A_2], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_A, EXTENSION_A_2]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_A_2], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [EXTENSION_A, EXTENSION_A_2], - validMatches: {}, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed: Create the pending extensions and succeeds', async () => { - // Given - const extensionsToCreate: LocalSource[] = [EXTENSION_A, EXTENSION_A_2] - const validMatches = {} - const remoteExtensions = [REGISTRATION_A, REGISTRATION_A_2] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed( - options([EXTENSION_A, EXTENSION_A_2], [], {developerPlatformClient}), - remoteExtensions, - [], - { - extensionsToCreate, - validMatches, - }, - ) - - // Then - expect(got).toEqual({ - extensions: {'extension-a': EXTENSION_A.uid, 'extension-a-2': EXTENSION_A_2.uid}, - extensionIds: {'extension-a': EXTENSION_A.uid, 'extension-a-2': EXTENSION_A_2.uid}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with some pending confirmation', () => { - test('ensureExtensionsIds: confirms the pending ones and succeeds', async () => { - // Given - vi.mocked(matchConfirmationPrompt).mockResolvedValueOnce(true) - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {}, - toConfirm: [{local: EXTENSION_B, remote: REGISTRATION_B}], - toCreate: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_B]), { - extensionRegistrations: [REGISTRATION_B], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [], - validMatches: { - 'extension-b': 'UUID_B', - }, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {'extension-b': 'UUID_B'} - const remoteExtensions = [REGISTRATION_B] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed(options([EXTENSION_B], [], {developerPlatformClient}), remoteExtensions, [], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {'extension-b': 'UUID_B'}, - extensionIds: {'extension-b': 'B'}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with some pending confirmation', () => { - test('ensureExtensionsIds: does not confirm the pending ones', async () => { - // Given - vi.mocked(matchConfirmationPrompt).mockResolvedValueOnce(false) - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {}, - toConfirm: [{local: EXTENSION_B, remote: REGISTRATION_B}], - toCreate: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_B]), { - extensionRegistrations: [REGISTRATION_B], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [EXTENSION_B], - validMatches: {}, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed: creates non confirmed as new extensions', async () => { - // Given - const extensionsToCreate: LocalSource[] = [EXTENSION_B] - const validMatches = {} - const remoteExtensions = [REGISTRATION_B] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed(options([EXTENSION_B], [], {developerPlatformClient}), remoteExtensions, [], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {'extension-b': EXTENSION_B.uid}, - extensionIds: {'extension-b': EXTENSION_B.uid}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with nothing pending', () => { - test('ensureExtensionsIds: succeeds and returns all identifiers', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_A, EXTENSION_A_2]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_A_2], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [], - validMatches: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed: does not create any extension', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'} - const remoteExtensions = [REGISTRATION_A, REGISTRATION_A_2] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed( - options([EXTENSION_A, EXTENSION_A_2], [], {developerPlatformClient}), - remoteExtensions, - [], - { - extensionsToCreate, - validMatches, - }, - ) - - // Then - expect(got).toEqual({ - extensions: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - extensionIds: {EXTENSION_A: 'A', EXTENSION_A_2: 'A_2'}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('includes functions', () => { - test('ensureExtensionsIds: succeeds and returns all identifiers', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', FUNCTION_A: 'FUNCTION_A_UUID'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const ensureExtensionsIdsOptions = options([EXTENSION_A], [FUNCTION_A]) - const got = await ensureExtensionsIds(ensureExtensionsIdsOptions, { - extensionRegistrations: [REGISTRATION_A, FUNCTION_REGISTRATION_A], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(automaticMatchmaking).toHaveBeenCalledWith( - [EXTENSION_A, FUNCTION_A], - [REGISTRATION_A, FUNCTION_REGISTRATION_A], - {}, - ) - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [], - validMatches: {EXTENSION_A: 'UUID_A', FUNCTION_A: 'FUNCTION_A_UUID'}, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed: does not create any extension', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {EXTENSION_A: 'UUID_A', FUNCTION_A: 'FUNCTION_A_UUID'} - const remoteExtensions = [REGISTRATION_A, FUNCTION_REGISTRATION_A] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed( - options([EXTENSION_A], [FUNCTION_A], {developerPlatformClient}), - remoteExtensions, - [], - { - extensionsToCreate, - validMatches, - }, - ) - - // Then - expect(got).toEqual({ - extensions: {EXTENSION_A: 'UUID_A', FUNCTION_A: 'FUNCTION_A_UUID'}, - extensionIds: {EXTENSION_A: 'A', FUNCTION_A: 'FUNCTION_A'}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('excludes non uuid managed extensions', () => { - test("ensureExtensionsIds: automatic matching logic doesn't receive the non uuid managed extensions", async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', FUNCTION_A: 'FUNCTION_A_UUID'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const CONFIG_A = await testAppConfigExtensions() - const ensureExtensionsIdsOptions = options([EXTENSION_A], [], {configExtensions: [CONFIG_A]}) - await ensureExtensionsIds(ensureExtensionsIdsOptions, { - extensionRegistrations: [REGISTRATION_A], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(automaticMatchmaking).toHaveBeenCalledWith([EXTENSION_A], [REGISTRATION_A], {}) - }) -}) - -describe('ensureExtensionsIds: Migrates extension', () => { - test('shows confirmation prompt', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - const extensionsToMigrate = [ - {local: EXTENSION_A, remote: REGISTRATION_A}, - {local: EXTENSION_A_2, remote: REGISTRATION_A_2}, - ] - vi.mocked(getModulesToMigrate).mockReturnValueOnce(extensionsToMigrate) - - // When / Then - await expect(() => - ensureExtensionsIds(options([EXTENSION_A, EXTENSION_A_2]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_A_2], - dashboardManagedExtensionRegistrations: [], - }), - ).rejects.toThrowError(AbortSilentError) - - expect(extensionMigrationPrompt).toBeCalledWith(extensionsToMigrate) - }) - - test('migrates extensions', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - const extensionsToMigrate = [ - {local: EXTENSION_A, remote: REGISTRATION_A}, - {local: EXTENSION_A_2, remote: REGISTRATION_A_2}, - ] - vi.mocked(getModulesToMigrate).mockReturnValueOnce(extensionsToMigrate) - vi.mocked(extensionMigrationPrompt).mockResolvedValueOnce(true) - const opts = options([EXTENSION_A, EXTENSION_A_2]) - const remoteExtensions = [REGISTRATION_A, REGISTRATION_A_2] - - // When - const result = await ensureExtensionsIds(opts, { - extensionRegistrations: remoteExtensions, - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(result).toEqual({ - didMigrateDashboardExtensions: true, - dashboardOnlyExtensions: [], - extensionsToCreate: [], - validMatches: { - EXTENSION_A: 'UUID_A', - EXTENSION_A_2: 'UUID_A_2', - }, - }) - - expect(migrateExtensionsToUIExtension).toBeCalledWith({ - extensionsToMigrate, - appId: opts.appId, - remoteExtensions, - migrationClient: expect.any(PartnersClient), - }) - }) -}) - -describe('deployConfirmed: handle non existent uuid managed extensions', () => { - test('when include config on deploy flag is enabled configuration extensions are created', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {} - const REGISTRATION_CONFIG_A = { - uuid: 'UUID_C_A', - id: 'C_A', - title: 'C_A', - type: 'POINT_OF_SALE', - } - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const CONFIG_A = await testAppConfigExtensions() - const ensureExtensionsIdsOptions = options([], [], { - includeDeployConfig: true, - configExtensions: [CONFIG_A], - developerPlatformClient, - }) - const got = await deployConfirmed(ensureExtensionsIdsOptions, [], [REGISTRATION_CONFIG_A], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {}, - extensionIds: {point_of_sale: 'C_A'}, - extensionsNonUuidManaged: {point_of_sale: 'UUID_C_A'}, - }) - }) - test('when the include config on deploy flag is disabled configuration extensions are not created', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {} - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const CONFIG_A = await testAppConfigExtensions() - // Don't pass config extensions when includeConfigOnDeploy is false - they won't be in allExtensions - const ensureExtensionsIdsOptions = options([], [], {developerPlatformClient}) - const got = await deployConfirmed(ensureExtensionsIdsOptions, [], [], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - }) - }) -}) -describe('deployConfirmed: handle existent uuid managed extensions', () => { - test('when the include config on deploy flag is enabled configuration extensions are not created but the uuids are returned', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {} - const REGISTRATION_CONFIG_A = { - uuid: 'UUID_C_A', - id: 'C_A', - title: 'C_A', - type: 'POINT_OF_SALE', - } - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const CONFIG_A = await testAppConfigExtensions() - - const ensureExtensionsIdsOptions = options([], [], { - includeDeployConfig: true, - configExtensions: [CONFIG_A], - developerPlatformClient, - }) - const got = await deployConfirmed(ensureExtensionsIdsOptions, [], [REGISTRATION_CONFIG_A], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {}, - extensionIds: {point_of_sale: 'C_A'}, - extensionsNonUuidManaged: {point_of_sale: 'UUID_C_A'}, - }) - }) -}) - -describe('deployConfirmed: extensions that should be managed in the TOML', () => { - test('are also passed to the `ensureNonUuidManagedExtensionsIds` function', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {} - const REGISTRATION_CONFIG_A = { - uuid: 'UUID_C_A', - id: 'C_A', - title: 'C_A', - type: 'POINT_OF_SALE', - } - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const CONFIG_A = await testAppConfigExtensions() - - const ensureExtensionsIdsOptions = options([], [], { - includeDeployConfig: true, - configExtensions: [CONFIG_A], - developerPlatformClient, - }) - const got = await deployConfirmed(ensureExtensionsIdsOptions, [], [REGISTRATION_CONFIG_A], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {}, - extensionIds: {point_of_sale: 'C_A'}, - extensionsNonUuidManaged: {point_of_sale: 'UUID_C_A'}, - }) - }) -}) - -describe('groupRegistrationByUidStrategy', () => { - test('extension registrations have dynamic UID strategy should be returned in the same array as configuration registrations', () => { - // Given - const dynamicUidStrategyExtension = { - uuid: 'webhook-subscription-uuid', - id: 'webhook-subscription-id', - title: 'Webhook Subscription', - type: 'WEBHOOK_SUBSCRIPTION', - } - - const extensionRegistrations = [REGISTRATION_B, dynamicUidStrategyExtension] - const configurationRegistrations = [ - { - uuid: 'UUID_C_A', - id: 'C_A', - title: 'C_A', - type: 'POINT_OF_SALE', - }, - ] - const specifications = [appPOSSpec, appWebhookSubscriptionSpec] as ExtensionSpecification[] - - const dynamicUidStrategySpec = specifications.find( - (spec) => spec.identifier === dynamicUidStrategyExtension.type.toLowerCase(), - ) - expect(dynamicUidStrategySpec?.experience).toEqual('configuration') - expect(dynamicUidStrategySpec?.uidStrategy).toEqual('dynamic') - - // When - const {uuidUidStrategyExtensions, singleAndDynamicStrategyExtensions} = groupRegistrationByUidStrategy( - extensionRegistrations, - configurationRegistrations, - specifications, - ) - - // Then - expect(uuidUidStrategyExtensions).toEqual([REGISTRATION_B]) - expect(singleAndDynamicStrategyExtensions).toEqual([configurationRegistrations[0], dynamicUidStrategyExtension]) - }) -}) - -describe('ensureNonUuidManagedExtensionsIds: for extensions managed in the TOML', () => { - test('creates the extension if no possible matches', async () => { - // Given - const remoteSources: RemoteSource[] = [] - const localSources = [ - await testSingleWebhookSubscriptionExtension(), - await testSingleWebhookSubscriptionExtension({topic: 'products/create'}), - await testSingleWebhookSubscriptionExtension({topic: 'products/update'}), - await testSingleWebhookSubscriptionExtension({topic: 'products/delete'}), - ] - const app = options(localSources, [], { - includeDeployConfig: true, - flags: [], - }).app - const developerPlatformClient = testDeveloperPlatformClient() - - // When - - const {extensionsNonUuidManaged, extensionsIdsNonUuidManaged} = await ensureNonUuidManagedExtensionsIds( - remoteSources, - app, - developerPlatformClient, - ) - - // Then - expect(Object.values(extensionsNonUuidManaged)).toEqual(localSources.map((source) => source.uid)) - expect(Object.values(extensionsIdsNonUuidManaged)).toEqual(localSources.map((source) => source.uid)) - }) - - test('if there are possible matches, creates the extension for those that dont match', async () => { - // Given - const config = { - topic: 'orders/delete', - api_version: '2024-01', - uri: '/webhooks', - include_fields: ['id'], - filter: 'id:*', - } - const sameTopicConfig = { - topic: 'orders/delete', - api_version: '2024-01', - uri: 'https://my-app.com/webhooks', - include_fields: ['id'], - filter: 'id:1234', - } - - const webhookSubscriptionExtension = { - uuid: 'webhook-subscription-uuid', - id: 'webhook-subscription-id', - title: 'Webhook Subscription', - type: 'WEBHOOK_SUBSCRIPTION', - activeVersion: { - // use absolute path here to test that it matches both absolute and relative paths from the local config - config: JSON.stringify({...config, uri: 'https://my-app.com/webhooks'}), - }, - } - - const localSources = [ - await testSingleWebhookSubscriptionExtension({config}), - await testSingleWebhookSubscriptionExtension({config: sameTopicConfig}), - await testSingleWebhookSubscriptionExtension({topic: 'products/create'}), - await testSingleWebhookSubscriptionExtension({topic: 'products/update'}), - await testSingleWebhookSubscriptionExtension({topic: 'products/delete'}), - ] - const remoteSources = [webhookSubscriptionExtension] - const app = options(localSources, [], { - includeDeployConfig: true, - flags: [], - }).app - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const {extensionsNonUuidManaged, extensionsIdsNonUuidManaged} = await ensureNonUuidManagedExtensionsIds( - remoteSources, - app, - developerPlatformClient, - ) - - // Then - expect(Object.values(extensionsNonUuidManaged)).toEqual([ - 'webhook-subscription-uuid', - ...localSources.slice(1).map((source) => source.uid), - ]) - expect(Object.values(extensionsIdsNonUuidManaged)).toEqual([ - 'webhook-subscription-id', - ...localSources.slice(1).map((source) => source.uid), - ]) - }) -}) diff --git a/packages/app/src/cli/services/context/identifiers-extensions.ts b/packages/app/src/cli/services/context/identifiers-extensions.ts deleted file mode 100644 index 6f96b48e7ab..00000000000 --- a/packages/app/src/cli/services/context/identifiers-extensions.ts +++ /dev/null @@ -1,322 +0,0 @@ -import {manualMatchIds} from './id-manual-matching.js' -import {automaticMatchmaking} from './id-matching.js' -import {EnsureDeploymentIdsPresenceOptions, LocalSource, RemoteSource} from './identifiers.js' -import {extensionMigrationPrompt, matchConfirmationPrompt} from './prompts.js' -import {IdentifiersExtensions} from '../../models/app/identifiers.js' -import {migrateExtensionsToUIExtension} from '../dev/migrate-to-ui-extension.js' -import {migrateFlowExtensions} from '../dev/migrate-flow-extension.js' -import {AppInterface} from '../../models/app/app.js' -import {ClientName, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' -import { - FlowModulesMap, - getModulesToMigrate, - MarketingModulesMap, - migrateAppModules, - PaymentModulesMap, - UIModulesMap, - SubscriptionModulesMap, - AdminLinkModulesMap, -} from '../dev/migrate-app-module.js' -import {ExtensionSpecification} from '../../models/extensions/specification.js' -import {ExtensionInstance} from '../../models/extensions/extension-instance.js' -import {SingleWebhookSubscriptionType} from '../../models/extensions/specifications/app_config_webhook_schemas/webhooks_schema.js' -import {PartnersClient} from '../../utilities/developer-platform-client/partners-client.js' -import {AbortSilentError} from '@shopify/cli-kit/node/error' -import {groupBy} from '@shopify/cli-kit/common/collection' - -interface AppWithExtensions { - extensionRegistrations: RemoteSource[] - dashboardManagedExtensionRegistrations: RemoteSource[] -} - -export async function ensureExtensionsIds( - options: EnsureDeploymentIdsPresenceOptions, - { - extensionRegistrations: initialRemoteExtensions, - dashboardManagedExtensionRegistrations: dashboardExtensions, - }: AppWithExtensions, -) { - let remoteExtensions = initialRemoteExtensions - const identifiers = options.envIdentifiers.extensions ?? {} - const localExtensions = options.app.allExtensions.filter((ext) => !ext.isAppConfigExtension) - - const uiExtensionsToMigrate = getModulesToMigrate(localExtensions, remoteExtensions, identifiers, UIModulesMap) - const flowExtensionsToMigrate = getModulesToMigrate(localExtensions, dashboardExtensions, identifiers, FlowModulesMap) - const paymentsToMigrate = getModulesToMigrate(localExtensions, dashboardExtensions, identifiers, PaymentModulesMap) - const marketingToMigrate = getModulesToMigrate(localExtensions, dashboardExtensions, identifiers, MarketingModulesMap) - const subscriptionLinksToMigrate = getModulesToMigrate( - localExtensions, - dashboardExtensions, - identifiers, - SubscriptionModulesMap, - ) - const adminLinkExtensionsToMigrate = getModulesToMigrate( - localExtensions, - dashboardExtensions, - identifiers, - AdminLinkModulesMap, - ) - - // Migration is only supported in partners client - const clientName = options.developerPlatformClient.clientName - const migrationClient = - clientName === ClientName.Partners ? options.developerPlatformClient : PartnersClient.getInstance() - - let didMigrateDashboardExtensions = false - - if (uiExtensionsToMigrate.length > 0) { - const confirmedMigration = await extensionMigrationPrompt(uiExtensionsToMigrate) - if (!confirmedMigration) throw new AbortSilentError() - remoteExtensions = await migrateExtensionsToUIExtension({ - extensionsToMigrate: uiExtensionsToMigrate, - appId: options.appId, - remoteExtensions, - migrationClient, - }) - didMigrateDashboardExtensions = true - } - - if (flowExtensionsToMigrate.length > 0) { - const confirmedMigration = await extensionMigrationPrompt(flowExtensionsToMigrate, false) - if (!confirmedMigration) throw new AbortSilentError() - const newRemoteExtensions = await migrateFlowExtensions({ - extensionsToMigrate: flowExtensionsToMigrate, - appId: options.appId, - remoteExtensions: dashboardExtensions, - migrationClient, - }) - remoteExtensions = remoteExtensions.concat(newRemoteExtensions) - didMigrateDashboardExtensions = true - } - - if (marketingToMigrate.length > 0) { - const confirmedMigration = await extensionMigrationPrompt(marketingToMigrate, false) - if (!confirmedMigration) throw new AbortSilentError() - const newRemoteExtensions = await migrateAppModules({ - extensionsToMigrate: marketingToMigrate, - appId: options.appId, - type: 'marketing_activity', - remoteExtensions: dashboardExtensions, - migrationClient, - }) - remoteExtensions = remoteExtensions.concat(newRemoteExtensions) - didMigrateDashboardExtensions = true - } - - if (paymentsToMigrate.length > 0) { - const confirmedMigration = await extensionMigrationPrompt(paymentsToMigrate, false) - if (!confirmedMigration) throw new AbortSilentError() - const newRemoteExtensions = await migrateAppModules({ - extensionsToMigrate: paymentsToMigrate, - appId: options.appId, - type: 'payments_extension', - remoteExtensions: dashboardExtensions, - migrationClient, - }) - remoteExtensions = remoteExtensions.concat(newRemoteExtensions) - didMigrateDashboardExtensions = true - } - - if (subscriptionLinksToMigrate.length > 0) { - const confirmedMigration = await extensionMigrationPrompt(subscriptionLinksToMigrate, false) - if (!confirmedMigration) throw new AbortSilentError() - const newRemoteExtensions = await migrateAppModules({ - extensionsToMigrate: subscriptionLinksToMigrate, - appId: options.appId, - type: 'subscription_link_extension', - remoteExtensions: dashboardExtensions, - migrationClient, - }) - remoteExtensions = remoteExtensions.concat(newRemoteExtensions) - didMigrateDashboardExtensions = true - } - - if (adminLinkExtensionsToMigrate.length > 0) { - const confirmedMigration = await extensionMigrationPrompt(adminLinkExtensionsToMigrate, false) - if (!confirmedMigration) throw new AbortSilentError() - const newRemoteExtensions = await migrateAppModules({ - extensionsToMigrate: adminLinkExtensionsToMigrate, - appId: options.appId, - type: 'admin_link', - remoteExtensions: dashboardExtensions, - migrationClient, - }) - remoteExtensions = remoteExtensions.concat(newRemoteExtensions) - didMigrateDashboardExtensions = true - } - - const matchExtensions = await automaticMatchmaking(localExtensions, remoteExtensions, identifiers) - - let validMatches = matchExtensions.identifiers - const extensionsToCreate = matchExtensions.toCreate ?? [] - - for (const pending of matchExtensions.toConfirm) { - // eslint-disable-next-line no-await-in-loop - const confirmed = await matchConfirmationPrompt(pending.local, pending.remote) - if (confirmed) { - validMatches[pending.local.localIdentifier] = pending.remote.uuid - } else { - extensionsToCreate.push(pending.local) - } - } - - if (matchExtensions.toManualMatch.local.length > 0) { - const matchResult = await manualMatchIds(matchExtensions.toManualMatch) - validMatches = {...validMatches, ...matchResult.identifiers} - extensionsToCreate.push(...matchResult.toCreate) - } - - return { - validMatches, - extensionsToCreate, - dashboardOnlyExtensions: dashboardExtensions, - didMigrateDashboardExtensions, - } -} - -export async function deployConfirmed( - options: EnsureDeploymentIdsPresenceOptions, - extensionRegistrations: RemoteSource[], - configurationRegistrations: RemoteSource[], - { - validMatches, - extensionsToCreate, - }: { - validMatches: IdentifiersExtensions - extensionsToCreate: LocalSource[] - }, -) { - const {uuidUidStrategyExtensions, singleAndDynamicStrategyExtensions} = groupRegistrationByUidStrategy( - extensionRegistrations, - configurationRegistrations, - options.app.specifications || [], - ) - - const {extensionsNonUuidManaged, extensionsIdsNonUuidManaged} = await ensureNonUuidManagedExtensionsIds( - singleAndDynamicStrategyExtensions, - options.app, - options.developerPlatformClient, - ) - - const validMatchesById: {[key: string]: string} = {} - if (extensionsToCreate.length > 0) { - const newIdentifiers = await createExtensions(extensionsToCreate) - for (const [localIdentifier, registration] of Object.entries(newIdentifiers)) { - validMatches[localIdentifier] = registration.uuid - validMatchesById[localIdentifier] = registration.id - } - } - - // For extensions we also need the match by ID, not only UUID (doesn't apply to functions) - for (const [localIdentifier, uuid] of Object.entries(validMatches)) { - const registration = uuidUidStrategyExtensions.find((registration) => registration.uuid === uuid) - if (registration) validMatchesById[localIdentifier] = registration.id - } - - return { - extensions: validMatches, - extensionIds: {...validMatchesById, ...extensionsIdsNonUuidManaged}, - extensionsNonUuidManaged, - } -} - -function matchWebhooks(remoteSource: RemoteSource, extension: ExtensionInstance) { - const remoteVersionConfig = remoteSource.activeVersion?.config - const remoteVersionConfigObj = remoteVersionConfig ? JSON.parse(remoteVersionConfig) : {} - const localConfig = extension.configuration as unknown as SingleWebhookSubscriptionType - const remoteUri: string = remoteVersionConfigObj.uri ?? '' - return ( - remoteVersionConfigObj.topic === localConfig.topic && - remoteUri.endsWith(localConfig.uri) && - remoteVersionConfigObj.filter === localConfig.filter - ) -} - -function loadExtensionIds( - remoteConfigurationRegistrations: RemoteSource[], - developerPlatformClient: DeveloperPlatformClient, - localExtensionRegistrations: ExtensionInstance[], - extensionsToCreate: LocalSource[], - validMatches: {[key: string]: unknown}, - validMatchesById: {[key: string]: unknown}, -) { - localExtensionRegistrations.forEach((local) => { - const possibleMatches = remoteConfigurationRegistrations.filter((remote) => { - return remote.type === developerPlatformClient.toExtensionGraphQLType(local.graphQLType) - }) - - let match: RemoteSource | undefined - if (local.isSingleStrategyExtension && possibleMatches.length === 1) { - match = possibleMatches[0] - } else if (local.isDynamicStrategyExtension) { - match = possibleMatches.find((possibleMatch) => matchWebhooks(possibleMatch, local)) - } - - if (match) { - validMatches[local.localIdentifier] = match.uuid - validMatchesById[local.localIdentifier] = match.id - } else { - extensionsToCreate.push(local) - } - }) -} - -export async function ensureNonUuidManagedExtensionsIds( - remoteConfigurationRegistrations: RemoteSource[], - app: AppInterface, - developerPlatformClient: DeveloperPlatformClient, -) { - const localExtensionRegistrations = app.allExtensions.filter((ext) => !ext.isUUIDStrategyExtension) - - const extensionsToCreate: LocalSource[] = [] - const validMatches: {[key: string]: string} = {} - const validMatchesById: {[key: string]: string} = {} - - loadExtensionIds( - remoteConfigurationRegistrations, - developerPlatformClient, - localExtensionRegistrations, - extensionsToCreate, - validMatches, - validMatchesById, - ) - - if (extensionsToCreate.length > 0) { - const newIdentifiers = await createExtensions(extensionsToCreate) - for (const [localIdentifier, registration] of Object.entries(newIdentifiers)) { - validMatches[localIdentifier] = registration.uuid - validMatchesById[localIdentifier] = registration.id - } - } - - return {extensionsNonUuidManaged: validMatches, extensionsIdsNonUuidManaged: validMatchesById} -} - -async function createExtensions(extensions: LocalSource[]) { - const result: {[localIdentifier: string]: RemoteSource} = {} - for (const extension of extensions) { - result[extension.localIdentifier] = { - id: extension.uid, - uuid: extension.uid, - type: extension.type, - title: extension.handle, - } - } - return result -} - -export function groupRegistrationByUidStrategy( - extensionRegistrations: RemoteSource[], - configurationRegistrations: RemoteSource[], - specifications: ExtensionSpecification[], -) { - const dynamicUidStrategySpecs = specifications - .filter((spec) => spec.uidStrategy === 'dynamic') - .map((spec) => spec.identifier) - - const isDynamic = (registration: RemoteSource) => dynamicUidStrategySpecs.includes(registration.type.toLowerCase()) - const groupedExtensions = groupBy(extensionRegistrations, isDynamic) - - const singleAndDynamicStrategyExtensions = configurationRegistrations.concat(groupedExtensions.true ?? []) - return {uuidUidStrategyExtensions: groupedExtensions.false ?? [], singleAndDynamicStrategyExtensions} -} diff --git a/packages/app/src/cli/services/context/identifiers.test.ts b/packages/app/src/cli/services/context/identifiers.test.ts deleted file mode 100644 index ae5dab7e9e9..00000000000 --- a/packages/app/src/cli/services/context/identifiers.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { - buildExtensionBreakdownInfo, - configExtensionsIdentifiersBreakdown, - ExtensionIdentifierBreakdownInfo, - extensionsIdentifiersDeployBreakdown, -} from './breakdown-extensions.js' -import {ensureDeploymentIdsPresence} from './identifiers.js' -import {deployConfirmed} from './identifiers-extensions.js' -import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' -import {testApp, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' -import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' -import {describe, expect, test, vi} from 'vitest' -import {AbortSilentError} from '@shopify/cli-kit/node/error' - -const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient() - -vi.mock('./breakdown-extensions.js') -vi.mock('../../prompts/deploy-release.js') -vi.mock('./identifiers-extensions.js') - -describe('ensureDeploymentIdsPresence', () => { - test('when the prompt is not confirmed an exception is thrown', async () => { - // Given - vi.mocked(extensionsIdentifiersDeployBreakdown).mockResolvedValue(buildExtensionsBreakdown()) - vi.mocked(configExtensionsIdentifiersBreakdown).mockResolvedValue(buildConfigBreakdown()) - vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(false) - - // When / Then - const params = { - app: testApp(), - developerPlatformClient, - appId: 'appId', - appName: 'appName', - remoteApp: testOrganizationApp(), - envIdentifiers: {}, - release: true, - } - await expect(ensureDeploymentIdsPresence(params)).rejects.toThrow(AbortSilentError) - }) - - test('when there are remote-only extensions and not forced, appInstallCount is called with remoteApp.id', async () => { - // Given - const breakdown = buildExtensionsBreakdown() - breakdown.extensionIdentifiersBreakdown.onlyRemote = [buildExtensionBreakdownInfo('removed', 'uuid-1')] - vi.mocked(extensionsIdentifiersDeployBreakdown).mockResolvedValue(breakdown) - vi.mocked(configExtensionsIdentifiersBreakdown).mockResolvedValue(buildConfigBreakdown()) - vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(false) - - const remoteApp = testOrganizationApp({id: 'real-app-id', apiKey: 'api-key-different'}) - const client = testDeveloperPlatformClient({ - appInstallCount: vi.fn().mockResolvedValue(42), - }) - - const params = { - app: testApp(), - developerPlatformClient: client, - appId: 'api-key-different', - appName: 'appName', - remoteApp, - envIdentifiers: {}, - release: true, - } - - // When - await expect(ensureDeploymentIdsPresence(params)).rejects.toThrow() - - // Then - expect(client.appInstallCount).toHaveBeenCalledWith({ - id: 'real-app-id', - apiKey: 'api-key-different', - organizationId: remoteApp.organizationId, - }) - }) - - test('when release is false, appInstallCount is not called even with remote-only extensions', async () => { - // Given - const breakdown = buildExtensionsBreakdown() - breakdown.extensionIdentifiersBreakdown.onlyRemote = [buildExtensionBreakdownInfo('removed', 'uuid-1')] - vi.mocked(extensionsIdentifiersDeployBreakdown).mockResolvedValue(breakdown) - vi.mocked(configExtensionsIdentifiersBreakdown).mockResolvedValue(buildConfigBreakdown()) - vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) - vi.mocked(deployConfirmed).mockResolvedValue({ - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - }) - - const client = testDeveloperPlatformClient({ - appInstallCount: vi.fn().mockResolvedValue(42), - }) - - const params = { - app: testApp(), - developerPlatformClient: client, - appId: 'appId', - appName: 'appName', - remoteApp: testOrganizationApp(), - envIdentifiers: {}, - release: false, - } - - // When - await ensureDeploymentIdsPresence(params) - - // Then - expect(client.appInstallCount).not.toHaveBeenCalled() - }) - - test('when allowUpdates and allowDeletes are both true, appInstallCount is not called', async () => { - // Given - const breakdown = buildExtensionsBreakdown() - breakdown.extensionIdentifiersBreakdown.onlyRemote = [buildExtensionBreakdownInfo('removed', 'uuid-1')] - vi.mocked(extensionsIdentifiersDeployBreakdown).mockResolvedValue(breakdown) - vi.mocked(configExtensionsIdentifiersBreakdown).mockResolvedValue(buildConfigBreakdown()) - vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) - vi.mocked(deployConfirmed).mockResolvedValue({ - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - }) - - const client = testDeveloperPlatformClient({ - appInstallCount: vi.fn().mockResolvedValue(42), - }) - - const params = { - app: testApp(), - developerPlatformClient: client, - appId: 'appId', - appName: 'appName', - remoteApp: testOrganizationApp(), - envIdentifiers: {}, - allowUpdates: true, - allowDeletes: true, - release: true, - } - - // When - await ensureDeploymentIdsPresence(params) - - // Then - expect(client.appInstallCount).not.toHaveBeenCalled() - }) - - test('when appInstallCount throws, installCount is undefined and deploy proceeds', async () => { - // Given - const breakdown = buildExtensionsBreakdown() - breakdown.extensionIdentifiersBreakdown.onlyRemote = [buildExtensionBreakdownInfo('removed', 'uuid-1')] - vi.mocked(extensionsIdentifiersDeployBreakdown).mockResolvedValue(breakdown) - vi.mocked(configExtensionsIdentifiersBreakdown).mockResolvedValue(buildConfigBreakdown()) - vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) - vi.mocked(deployConfirmed).mockResolvedValue({ - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - }) - - const client = testDeveloperPlatformClient({ - appInstallCount: vi.fn().mockRejectedValue(new Error('API error')), - }) - - const params = { - app: testApp(), - developerPlatformClient: client, - appId: 'appId', - appName: 'appName', - remoteApp: testOrganizationApp(), - envIdentifiers: {}, - release: true, - } - - // When - await ensureDeploymentIdsPresence(params) - - // Then - installCount should be undefined in the prompt call - expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( - expect.objectContaining({ - installCount: undefined, - }), - ) - }) - - test('when the prompt is confirmed post-confirmation actions as run and the result is returned', async () => { - // Given - vi.mocked(extensionsIdentifiersDeployBreakdown).mockResolvedValue(buildExtensionsBreakdown()) - vi.mocked(configExtensionsIdentifiersBreakdown).mockResolvedValue(buildConfigBreakdown()) - vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) - vi.mocked(deployConfirmed).mockResolvedValue({ - extensions: { - EXTENSION_A: 'UUID_A', - }, - extensionIds: {EXTENSION_A: 'A'}, - extensionsNonUuidManaged: {}, - }) - - // When - const params = { - app: testApp(), - developerPlatformClient, - appId: 'appId', - appName: 'appName', - remoteApp: testOrganizationApp(), - envIdentifiers: {}, - release: true, - } - const result = await ensureDeploymentIdsPresence(params) - - // Then - expect(result).toEqual({ - app: params.appId, - extensions: { - EXTENSION_A: 'UUID_A', - }, - extensionIds: {EXTENSION_A: 'A'}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -function buildExtensionsBreakdown() { - return { - extensionIdentifiersBreakdown: { - onlyRemote: [] as ExtensionIdentifierBreakdownInfo[], - toCreate: [], - toUpdate: [], - fromDashboard: [], - unchanged: [], - }, - extensionsToConfirm: { - validMatches: {}, - extensionsToCreate: [], - dashboardOnlyExtensions: [], - }, - remoteExtensionsRegistrations: { - extensionRegistrations: [], - configurationRegistrations: [], - dashboardManagedExtensionRegistrations: [], - }, - } -} - -function buildConfigBreakdown() { - return { - existingFieldNames: [], - existingUpdatedFieldNames: [], - newFieldNames: [], - deletedFieldNames: [], - } -} diff --git a/packages/app/src/cli/services/context/identifiers.ts b/packages/app/src/cli/services/context/identifiers.ts index 1f2b7eeefcf..a88a335b033 100644 --- a/packages/app/src/cli/services/context/identifiers.ts +++ b/packages/app/src/cli/services/context/identifiers.ts @@ -1,11 +1,7 @@ -import {deployConfirmed} from './identifiers-extensions.js' -import {configExtensionsIdentifiersBreakdown, extensionsIdentifiersDeployBreakdown} from './breakdown-extensions.js' import {AppInterface} from '../../models/app/app.js' -import {Identifiers} from '../../models/app/identifiers.js' +import {ExtensionUuidsByLocalIdentifier} from '../../models/app/identifiers.js' import {MinimalOrganizationApp} from '../../models/organization.js' -import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' import {AppVersion, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' -import {AbortSilentError} from '@shopify/cli-kit/node/error' export type PartnersAppForIdentifierMatching = MinimalOrganizationApp @@ -14,7 +10,7 @@ export interface EnsureDeploymentIdsPresenceOptions { developerPlatformClient: DeveloperPlatformClient appId: string appName: string - envIdentifiers: Partial + envIdentifiers: ExtensionUuidsByLocalIdentifier /** If true, allow adding and updating extensions and configuration without user confirmation */ allowUpdates?: boolean /** If true, allow removing extensions and configuration without user confirmation */ @@ -42,59 +38,3 @@ export interface LocalSource { contextValue: string configuration?: object } - -export async function ensureDeploymentIdsPresence(options: EnsureDeploymentIdsPresenceOptions) { - const {extensionIdentifiersBreakdown, extensionsToConfirm, remoteExtensionsRegistrations} = - await extensionsIdentifiersDeployBreakdown(options) - - const configExtensionIdentifiersBreakdown = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient: options.developerPlatformClient, - apiKey: options.appId, - localApp: options.app, - remoteApp: options.remoteApp, - release: options.release, - activeAppVersion: options.activeAppVersion, - }) - - const shouldWarn = options.release && !options.allowDeletes - const shouldFetchInstallCount = extensionIdentifiersBreakdown.onlyRemote.length > 0 && shouldWarn - - let installCount: number | undefined - if (shouldFetchInstallCount) { - try { - installCount = await options.developerPlatformClient.appInstallCount({ - id: options.remoteApp.id, - apiKey: options.remoteApp.apiKey, - organizationId: options.remoteApp.organizationId, - }) - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (_error) { - installCount = undefined - } - } - - const confirmed = await deployOrReleaseConfirmationPrompt({ - extensionIdentifiersBreakdown, - configExtensionIdentifiersBreakdown, - appTitle: options.remoteApp?.title, - release: options.release, - allowUpdates: options.allowUpdates, - allowDeletes: options.allowDeletes, - installCount, - }) - if (!confirmed) throw new AbortSilentError() - - const result = await deployConfirmed( - options, - remoteExtensionsRegistrations.extensionRegistrations, - remoteExtensionsRegistrations.configurationRegistrations, - extensionsToConfirm, - ) - - return { - app: options.appId, - extensions: result.extensions, - extensionIds: result.extensionIds, - extensionsNonUuidManaged: result.extensionsNonUuidManaged, - } -} diff --git a/packages/app/src/cli/services/context/prompts.ts b/packages/app/src/cli/services/context/prompts.ts index c447b257fda..1364864b787 100644 --- a/packages/app/src/cli/services/context/prompts.ts +++ b/packages/app/src/cli/services/context/prompts.ts @@ -1,35 +1,5 @@ -import {LocalRemoteSource} from './id-matching.js' -import {LocalSource, RemoteSource} from './identifiers.js' -import {renderAutocompletePrompt, renderConfirmationPrompt, renderInfo} from '@shopify/cli-kit/node/ui' - -export async function matchConfirmationPrompt( - local: LocalSource, - remote: RemoteSource, - type: 'extension' | 'function' = 'extension', -) { - return renderConfirmationPrompt({ - message: `Match ${local.handle} (local name) with ${remote.title} (name on Shopify Partners, ID: ${remote.id})?`, - confirmationMessage: `Yes, match to existing ${type}`, - cancellationMessage: `No, create as a new ${type}`, - }) -} - -export async function selectRemoteSourcePrompt( - localSource: LocalSource, - remoteSourcesOfSameType: RemoteSource[], -): Promise { - const remoteOptions = remoteSourcesOfSameType.map((remote) => ({ - label: `Match it to ${remote.title} (ID: ${remote.id} on Shopify Partners)`, - value: remote.uuid, - })) - remoteOptions.push({label: 'Create new extension', value: 'create'}) - const uuid = await renderAutocompletePrompt({ - message: `How would you like to deploy your "${localSource.handle}"?`, - choices: remoteOptions, - }) - - return remoteSourcesOfSameType.find((remote) => remote.uuid === uuid)! -} +import {LocalRemoteSource} from '../dev/migrate-app-module.js' +import {renderConfirmationPrompt, renderInfo} from '@shopify/cli-kit/node/ui' export async function extensionMigrationPrompt( toMigrate: LocalRemoteSource[], diff --git a/packages/app/src/cli/services/deploy.test.ts b/packages/app/src/cli/services/deploy.test.ts index 0af16d422c3..2a06642566e 100644 --- a/packages/app/src/cli/services/deploy.test.ts +++ b/packages/app/src/cli/services/deploy.test.ts @@ -17,7 +17,6 @@ import { testOrganization, testProject, } from '../models/app/app.test-data.js' -import {updateAppIdentifiers} from '../models/app/identifiers.js' import {AppInterface, AppLinkedInterface} from '../models/app/app.js' import {OrganizationApp} from '../models/organization.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' @@ -52,7 +51,6 @@ vi.mock('./context.js') vi.mock('./deploy/upload.js') vi.mock('./deploy/bundle.js') vi.mock('./dev/fetch.js') -vi.mock('../models/app/identifiers.js') vi.mock('@shopify/cli-kit/node/context/local') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/crypto') @@ -116,7 +114,7 @@ describe('deploy', () => { organizationId: 'org-id', appModules: [], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, }) }) @@ -186,11 +184,10 @@ describe('deploy', () => { organizationId: 'org-id', appModules: [], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, }) expect(bundleAndBuildExtensions).toHaveBeenCalledOnce() - expect(updateAppIdentifiers).toHaveBeenCalledOnce() }) test('uploads the extension bundle with 1 UI extension', async () => { @@ -234,11 +231,10 @@ describe('deploy', () => { }, ], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, }) expect(bundleAndBuildExtensions).toHaveBeenCalledOnce() - expect(updateAppIdentifiers).toHaveBeenCalledOnce() }) test('uploads the extension bundle with 1 theme extension', async () => { @@ -282,11 +278,10 @@ describe('deploy', () => { }, ], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, }) expect(bundleAndBuildExtensions).toHaveBeenCalledOnce() - expect(updateAppIdentifiers).toHaveBeenCalledOnce() }) test('uploads the extension bundle with 1 function extension', async () => { @@ -347,12 +342,11 @@ describe('deploy', () => { }, ], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, bundlePath: expect.stringMatching(/bundle.zip$/), release: true, }) expect(bundleAndBuildExtensions).toHaveBeenCalledOnce() - expect(updateAppIdentifiers).toHaveBeenCalledOnce() }) test('uploads the extension bundle with 1 UI and 1 theme extension', async () => { @@ -415,12 +409,11 @@ describe('deploy', () => { }, ], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, commitReference, }) expect(bundleAndBuildExtensions).toHaveBeenCalledOnce() - expect(updateAppIdentifiers).toHaveBeenCalledOnce() }) test('pushes the configuration extension if include config on deploy ', async () => { @@ -468,12 +461,11 @@ describe('deploy', () => { }, ], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, commitReference, }) expect(bundleAndBuildExtensions).toHaveBeenCalledOnce() - expect(updateAppIdentifiers).toHaveBeenCalledOnce() }) test('doesnt push the configuration extension if include config on deploy is disabled', async () => { @@ -512,12 +504,11 @@ describe('deploy', () => { organizationId: 'org-id', appModules: [], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, commitReference, }) expect(bundleAndBuildExtensions).toHaveBeenCalledOnce() - expect(updateAppIdentifiers).toHaveBeenCalledOnce() }) test('shows a success message', async () => { @@ -712,22 +703,13 @@ async function testDeployBundle({ didMigrateExtensionsToDevDash = false, }: TestDeployBundleInput) { // Given - const extensionsPayload: {[key: string]: string} = {} - for (const extension of app.allExtensions.filter((ext) => ext.isUUIDStrategyExtension)) { - extensionsPayload[extension.localIdentifier] = extension.localIdentifier - } - const extensionsNonUuidPayload: {[key: string]: string} = {} - for (const extension of app.allExtensions.filter((ext) => !ext.isUUIDStrategyExtension)) { - extensionsNonUuidPayload[extension.localIdentifier] = extension.localIdentifier - } - const identifiers = { - app: 'app-id', - extensions: extensionsPayload, - extensionIds: {}, - extensionsNonUuidManaged: extensionsNonUuidPayload, + const appModuleUuids: {[key: string]: string} = {} + for (const extension of app.allExtensions) { + appModuleUuids[extension.localIdentifier] = extension.localIdentifier } + const deployIdentifiers = {appModuleUuids, appModuleRegistrationIds: {}} - vi.mocked(ensureDeployContext).mockResolvedValue({identifiers, didMigrateExtensionsToDevDash}) + vi.mocked(ensureDeployContext).mockResolvedValue({deployIdentifiers, didMigrateExtensionsToDevDash}) vi.mocked(uploadExtensionsBundle).mockResolvedValue({ validationErrors: [], @@ -736,7 +718,6 @@ async function testDeployBundle({ ...(!released && {deployError: 'no release error'}), location: 'https://partners.shopify.com/0/apps/0/versions/1', }) - vi.mocked(updateAppIdentifiers).mockResolvedValue(app) await deploy({ app, diff --git a/packages/app/src/cli/services/deploy.ts b/packages/app/src/cli/services/deploy.ts index 83f9fd5734c..02bc9fb7d35 100644 --- a/packages/app/src/cli/services/deploy.ts +++ b/packages/app/src/cli/services/deploy.ts @@ -6,7 +6,6 @@ import {allExtensionTypes, filterOutImportedExtensions, importAllExtensions} fro import {getExtensions} from './fetch-extensions.js' import {AppLinkedInterface} from '../models/app/app.js' import {Project} from '../models/project/project.js' -import {updateAppIdentifiers} from '../models/app/identifiers.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' import {Organization, OrganizationApp} from '../models/organization.js' import {reloadApp} from '../models/app/loader.js' @@ -148,7 +147,7 @@ export async function deploy(options: DeployOptions) { allowDeletes, }) - const {identifiers, didMigrateExtensionsToDevDash} = await ensureDeployContext({ + const {deployIdentifiers, didMigrateExtensionsToDevDash} = await ensureDeployContext({ ...options, app, developerPlatformClient, @@ -170,89 +169,79 @@ export async function deploy(options: DeployOptions) { let uploadExtensionsBundleResult!: UploadExtensionsBundleOutput - try { - const candidateBundlePath = joinPath( - options.app.directory, - '.shopify', - `deploy-bundle.${developerPlatformClient.bundleFormat}`, - ) - await mkdir(dirname(candidateBundlePath)) + const candidateBundlePath = joinPath( + options.app.directory, + '.shopify', + `deploy-bundle.${developerPlatformClient.bundleFormat}`, + ) + await mkdir(dirname(candidateBundlePath)) - const appManifest = await app.manifest(identifiers) + const appManifest = await app.manifest(appManifestUuids(app, deployIdentifiers.appModuleUuids)) - const bundlePath = await bundleAndBuildExtensions({ - app, - appManifest, - bundlePath: candidateBundlePath, - identifiers, - skipBuild: options.skipBuild, - isDevDashboardApp: true, - }) + const bundlePath = await bundleAndBuildExtensions({ + app, + appManifest, + bundlePath: candidateBundlePath, + skipBuild: options.skipBuild, + }) - let uploadTaskTitle + let uploadTaskTitle - if (release) { - uploadTaskTitle = 'Releasing an app version' - } else { - uploadTaskTitle = 'Creating an app version' - } + if (release) { + uploadTaskTitle = 'Releasing an app version' + } else { + uploadTaskTitle = 'Creating an app version' + } - const tasks: Task[] = [ - { - title: 'Running validation', - task: async () => { - await app.preDeployValidation() - }, + const tasks: Task[] = [ + { + title: 'Running validation', + task: async () => { + await app.preDeployValidation() }, - { - title: uploadTaskTitle, - task: async () => { - const appModules = await Promise.all( - app.allExtensions.flatMap((ext) => - ext.bundleConfig({identifiers, developerPlatformClient, apiKey, appConfiguration: app.configuration}), - ), - ) - - uploadExtensionsBundleResult = await uploadExtensionsBundle({ - appManifest, - appId: remoteApp.id, - apiKey, - name: app.name, - organizationId: remoteApp.organizationId, - bundlePath, - appModules: getArrayRejectingUndefined(appModules), - release, - developerPlatformClient, - extensionIds: identifiers.extensionIds, - message: options.message, - version: options.version, - commitReference: options.commitReference, - }) - - await updateAppIdentifiers({app, identifiers, command: 'deploy'}) - }, + }, + { + title: uploadTaskTitle, + task: async () => { + const appModules = await Promise.all( + app.allExtensions.flatMap((ext) => + ext.bundleConfig({ + appModuleUuids: deployIdentifiers.appModuleUuids, + developerPlatformClient, + apiKey, + appConfiguration: app.configuration, + }), + ), + ) + + uploadExtensionsBundleResult = await uploadExtensionsBundle({ + appManifest, + appId: remoteApp.id, + apiKey, + name: app.name, + organizationId: remoteApp.organizationId, + bundlePath, + appModules: getArrayRejectingUndefined(appModules), + release, + developerPlatformClient, + appModuleRegistrationIds: deployIdentifiers.appModuleRegistrationIds, + message: options.message, + version: options.version, + commitReference: options.commitReference, + }) }, - ] - - await renderTasks(tasks) + }, + ] - await outputCompletionMessage({ - app, - project: options.project, - release, - uploadExtensionsBundleResult, - didMigrateExtensionsToDevDash, - }) + await renderTasks(tasks) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - /** - * If deployment fails when uploading we want the identifiers to be persisted - * for the next run. - */ - await updateAppIdentifiers({app, identifiers, command: 'deploy'}) - throw error - } + await outputCompletionMessage({ + app, + project: options.project, + release, + uploadExtensionsBundleResult, + didMigrateExtensionsToDevDash, + }) return {app} } @@ -346,3 +335,11 @@ async function outputCompletionMessage({ ], }) } + +function appManifestUuids(app: AppLinkedInterface, appModuleUuids: {[localIdentifier: string]: string}) { + return Object.fromEntries( + app.allExtensions + .filter((extension) => extension.isUUIDStrategyExtension) + .map((extension) => [extension.localIdentifier, appModuleUuids[extension.localIdentifier]!]), + ) +} diff --git a/packages/app/src/cli/services/deploy/bundle.test.ts b/packages/app/src/cli/services/deploy/bundle.test.ts index 941194362d8..e1253552561 100644 --- a/packages/app/src/cli/services/deploy/bundle.test.ts +++ b/packages/app/src/cli/services/deploy/bundle.test.ts @@ -33,26 +33,14 @@ describe('bundleAndBuildExtensions', () => { themeExtension.buildForBundle = extensionBundleMock app = testApp({allExtensions: [uiExtension, themeExtension], directory: tmpDir}) - const extensions: {[key: string]: string} = {} - for (const extension of app.allExtensions) { - extensions[extension.localIdentifier] = extension.localIdentifier - } - const identifiers = { - app: 'app-id', - extensions, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - appManifest = await app.manifest(identifiers) + appManifest = await app.manifest(appModuleUuidsFor(app)) // When await bundleAndBuildExtensions({ app, appManifest, - identifiers, bundlePath, skipBuild: false, - isDevDashboardApp: false, }) // Then @@ -75,26 +63,14 @@ describe('bundleAndBuildExtensions', () => { functionExtension.buildForBundle = extensionBundleMock const app = testApp({allExtensions: [functionExtension], directory: tmpDir}) - const extensions: {[key: string]: string} = {} - for (const extension of app.allExtensions) { - extensions[extension.localIdentifier] = extension.localIdentifier - } - const identifiers = { - app: 'app-id', - extensions, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - appManifest = await app.manifest(identifiers) + appManifest = await app.manifest(appModuleUuidsFor(app)) // When await bundleAndBuildExtensions({ app, appManifest, - identifiers, bundlePath, skipBuild: false, - isDevDashboardApp: false, }) // Then @@ -114,33 +90,20 @@ describe('bundleAndBuildExtensions', () => { functionExtension.buildForBundle = extensionBuildMock const app = testApp({allExtensions: [functionExtension], directory: tmpDir}) - const extensions: {[key: string]: string} = {} - for (const extension of app.allExtensions) { - extensions[extension.localIdentifier] = extension.localIdentifier - } - const identifiers = { - app: 'app-id', - extensions, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - appManifest = await app.manifest(identifiers) + appManifest = await app.manifest(appModuleUuidsFor(app)) // When await bundleAndBuildExtensions({ app, appManifest, - identifiers, bundlePath, skipBuild: true, - isDevDashboardApp: false, }) // Then expect(extensionBuildMock).toHaveBeenCalledWith( expect.objectContaining({app, environment: 'production', skipBuild: true}), joinPath(tmpDir, '.shopify', 'deploy-bundle'), - functionExtension.localIdentifier, ) await expect(file.fileExists(bundlePath)).resolves.toBeTruthy() }) @@ -159,22 +122,14 @@ describe('bundleAndBuildExtensions', () => { functionExtension.buildForBundle = extensionBuildMock const app = testApp({allExtensions: [functionExtension], directory: tmpDir}) - const identifiers = { - app: 'app-id', - extensions: {[functionExtension.localIdentifier]: functionExtension.localIdentifier}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - appManifest = await app.manifest(identifiers) + appManifest = await app.manifest(appModuleUuidsFor(app)) // When await bundleAndBuildExtensions({ app, appManifest, - identifiers, bundlePath, skipBuild: true, - isDevDashboardApp: false, }) // Then @@ -182,7 +137,6 @@ describe('bundleAndBuildExtensions', () => { expect(extensionBuildMock).toHaveBeenCalledWith( expect.objectContaining({app, environment: 'production', skipBuild: true}), joinPath(tmpDir, '.shopify', 'deploy-bundle'), - functionExtension.localIdentifier, ) }) }) @@ -200,22 +154,14 @@ describe('bundleAndBuildExtensions', () => { functionExtension.buildForBundle = extensionBuildMock const app = testApp({allExtensions: [functionExtension], directory: tmpDir}) - const identifiers = { - app: 'app-id', - extensions: {[functionExtension.localIdentifier]: functionExtension.localIdentifier}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - appManifest = await app.manifest(identifiers) + appManifest = await app.manifest(appModuleUuidsFor(app)) // When await bundleAndBuildExtensions({ app, appManifest, - identifiers, bundlePath, skipBuild: false, - isDevDashboardApp: false, }) // Then @@ -238,29 +184,20 @@ describe('bundleAndBuildExtensions', () => { const app = testApp({allExtensions: [themeExtension], directory: tmpDir}) - const identifiers = { - app: 'app-id', - extensions: {[themeExtension.localIdentifier]: themeExtension.localIdentifier}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - appManifest = await app.manifest(identifiers) + appManifest = await app.manifest(appModuleUuidsFor(app)) // When await bundleAndBuildExtensions({ app, appManifest, - identifiers, bundlePath, skipBuild: true, - isDevDashboardApp: false, }) // Then expect(extensionBuildMock).toHaveBeenCalledWith( expect.objectContaining({app, environment: 'production', skipBuild: true}), joinPath(tmpDir, '.shopify', 'deploy-bundle'), - themeExtension.localIdentifier, ) await expect(file.fileExists(bundlePath)).resolves.toBeTruthy() }) @@ -298,42 +235,27 @@ describe('bundleAndBuildExtensions', () => { directory: tmpDir, }) - const extensions: {[key: string]: string} = {} - for (const extension of app.allExtensions) { - extensions[extension.localIdentifier] = extension.localIdentifier - } - const identifiers = { - app: 'app-id', - extensions, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - appManifest = await app.manifest(identifiers) + appManifest = await app.manifest(appModuleUuidsFor(app)) // When await bundleAndBuildExtensions({ app, appManifest, - identifiers, bundlePath, skipBuild: true, - isDevDashboardApp: false, }) expect(functionBuildMock).toHaveBeenCalledWith( expect.objectContaining({app, environment: 'production', skipBuild: true}), joinPath(tmpDir, '.shopify', 'deploy-bundle'), - functionExtension.localIdentifier, ) expect(themeBuildMock).toHaveBeenCalledWith( expect.objectContaining({app, environment: 'production', skipBuild: true}), joinPath(tmpDir, '.shopify', 'deploy-bundle'), - themeExtension.localIdentifier, ) expect(uiBuildMock).toHaveBeenCalledWith( expect.objectContaining({app, environment: 'production', skipBuild: true}), joinPath(tmpDir, '.shopify', 'deploy-bundle'), - uiExtension.localIdentifier, ) await expect(file.fileExists(bundlePath)).resolves.toBeTruthy() @@ -349,22 +271,14 @@ describe('bundleAndBuildExtensions', () => { const configExtension = await testAppConfigExtensions() const app = testApp({allExtensions: [configExtension], directory: tmpDir}) - const identifiers = { - app: 'app-id', - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {[configExtension.localIdentifier]: configExtension.localIdentifier}, - } - appManifest = await app.manifest(identifiers) + appManifest = await app.manifest(appModuleUuidsFor(app)) // When const result = await bundleAndBuildExtensions({ app, appManifest, - identifiers, bundlePath, skipBuild: false, - isDevDashboardApp: false, }) // Then @@ -374,3 +288,9 @@ describe('bundleAndBuildExtensions', () => { }) }) }) + +function appModuleUuidsFor(app: AppInterface) { + return Object.fromEntries( + app.allExtensions.map((extension) => [extension.localIdentifier, extension.localIdentifier]), + ) +} diff --git a/packages/app/src/cli/services/deploy/bundle.ts b/packages/app/src/cli/services/deploy/bundle.ts index 1f8e482d952..e0a5bd83bcf 100644 --- a/packages/app/src/cli/services/deploy/bundle.ts +++ b/packages/app/src/cli/services/deploy/bundle.ts @@ -1,5 +1,4 @@ import {AppInterface, AppManifest} from '../../models/app/app.js' -import {Identifiers} from '../../models/app/identifiers.js' import {installJavy} from '../function/build.js' import {compressBundle, writeManifestToBundle} from '../bundle.js' import {AbortSignal} from '@shopify/cli-kit/node/abort' @@ -12,9 +11,7 @@ interface BundleOptions { app: AppInterface appManifest: AppManifest bundlePath: string - identifiers?: Identifiers skipBuild: boolean - isDevDashboardApp: boolean } /** @@ -39,16 +36,9 @@ export async function bundleAndBuildExtensions(options: BundleOptions): Promise< const extensionBuildProcesses = options.app.allExtensions.map((extension) => ({ prefix: extension.localIdentifier, action: async (stdout: Writable, stderr: Writable, signal: AbortSignal) => { - // This outputId is the UID for AppManagement, and UUID for Partners - // Comes from the matching logic in `ensureDeployContext` - const outputId = options.isDevDashboardApp - ? undefined - : options.identifiers?.extensions[extension.localIdentifier] - await extension.buildForBundle( {stderr, stdout, signal, app: options.app, environment: 'production', skipBuild: options.skipBuild}, bundleDirectory, - outputId, ) }, })) diff --git a/packages/app/src/cli/services/deploy/upload.test.ts b/packages/app/src/cli/services/deploy/upload.test.ts index fab9ca04a84..c01e3154c8f 100644 --- a/packages/app/src/cli/services/deploy/upload.test.ts +++ b/packages/app/src/cli/services/deploy/upload.test.ts @@ -39,7 +39,7 @@ describe('uploadExtensionsBundle', () => { {uuid: '123', config: '{}', context: '', handle: 'handle', specificationIdentifier: 'ui_extension'}, ], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, }) @@ -85,7 +85,7 @@ describe('uploadExtensionsBundle', () => { {uuid: '123', config: '{}', context: '', handle: 'handle', specificationIdentifier: 'ui_extension'}, ], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, message: 'test', version: '1.0.0', @@ -129,7 +129,7 @@ describe('uploadExtensionsBundle', () => { bundlePath: undefined, appModules: [], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, }) @@ -247,7 +247,7 @@ describe('uploadExtensionsBundle', () => { {uuid: '456', config: '{}', context: '', handle: 'handle', specificationIdentifier: 'ui_extension'}, ], developerPlatformClient, - extensionIds: { + appModuleRegistrationIds: { 'amortizable-marketplace-ext': '123', 'amortizable-marketplace-ext-2': '456', }, @@ -350,7 +350,7 @@ describe('uploadExtensionsBundle', () => { {uuid: '456', config: '{}', context: '', handle: 'handle', specificationIdentifier: 'ui_extension'}, ], developerPlatformClient, - extensionIds: { + appModuleRegistrationIds: { 'amortizable-marketplace-ext': '123', 'amortizable-marketplace-ext-2': '456', }, diff --git a/packages/app/src/cli/services/deploy/upload.ts b/packages/app/src/cli/services/deploy/upload.ts index 807d353c733..b0c22decfc2 100644 --- a/packages/app/src/cli/services/deploy/upload.ts +++ b/packages/app/src/cli/services/deploy/upload.ts @@ -1,4 +1,4 @@ -import {IdentifiersExtensions} from '../../models/app/identifiers.js' +import {ExtensionUuidsByLocalIdentifier} from '../../models/app/identifiers.js' import {AppDeploySchema, AppModuleSettings} from '../../api/graphql/app_deploy.js' import {AppDeployOptions, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' @@ -32,8 +32,7 @@ interface UploadExtensionsBundleOptions { /** App Modules extra data */ appModules: AppModuleSettings[] - /** The extensions' numeric identifiers (expressed as a string). */ - extensionIds: IdentifiersExtensions + appModuleRegistrationIds: ExtensionUuidsByLocalIdentifier /** Wether or not to release the version */ release: boolean @@ -114,7 +113,7 @@ export async function uploadExtensionsBundle( if (!result.appDeploy.appVersion) { const customSections: AlertCustomSection[] = deploymentErrorsToCustomSections( result.appDeploy.userErrors ?? [], - options.extensionIds, + options.appModuleRegistrationIds, options.appModules, { version: options.version, @@ -147,7 +146,7 @@ const GENERIC_ERRORS_TITLE = '\n' export function deploymentErrorsToCustomSections( errors: AppDeploySchema['appDeploy']['userErrors'], - extensionIds: IdentifiersExtensions, + appModuleRegistrationIds: ExtensionUuidsByLocalIdentifier, appModules: AppModuleSettings[], flags: { version?: string @@ -157,11 +156,11 @@ export function deploymentErrorsToCustomSections( return error.details?.some((detail) => detail.extension_id) ?? false } - const isCliError = (error: (typeof errors)[0], extensionIds: IdentifiersExtensions) => { + const isCliError = (error: (typeof errors)[0], appModuleRegistrationIds: ExtensionUuidsByLocalIdentifier) => { const errorExtensionId = error.details?.find((detail) => typeof detail.extension_id !== 'undefined')?.extension_id ?? '' - return Object.values(extensionIds).includes(errorExtensionId.toString()) + return Object.values(appModuleRegistrationIds).includes(errorExtensionId.toString()) } const isAppManagementValidationError = (error: (typeof errors)[0]) => { @@ -173,11 +172,11 @@ export function deploymentErrorsToCustomSections( const [extensionErrors, nonExtensionErrors] = partition(nonAppManagementErrors, (error) => isExtensionError(error)) - const [cliErrors, partnersErrors] = partition(extensionErrors, (error) => isCliError(error, extensionIds)) + const [cliErrors, partnersErrors] = partition(extensionErrors, (error) => isCliError(error, appModuleRegistrationIds)) const customSections = [ ...generalErrorsSection(nonExtensionErrors, {version: flags.version}), - ...cliErrorsSections(cliErrors, extensionIds), + ...cliErrorsSections(cliErrors, appModuleRegistrationIds), ...partnersErrorsSections(partnersErrors), ...appManagementErrorsSection(appManagementErrors, appModules), ] @@ -228,7 +227,10 @@ function generalErrorsSection( } } -function cliErrorsSections(errors: AppDeploySchema['appDeploy']['userErrors'], identifiers: IdentifiersExtensions) { +function cliErrorsSections( + errors: AppDeploySchema['appDeploy']['userErrors'], + identifiers: ExtensionUuidsByLocalIdentifier, +) { return errors.reduce((sections, error) => { const field = (error.field ?? ['unknown']).join('.').replace('extension_points', 'extensions.targeting') const errorMessage = field === 'base' ? error.message : `${field}: ${error.message}` diff --git a/packages/app/src/cli/services/dev/migrate-app-module.ts b/packages/app/src/cli/services/dev/migrate-app-module.ts index 0c05f69f8b9..c626040a0c7 100644 --- a/packages/app/src/cli/services/dev/migrate-app-module.ts +++ b/packages/app/src/cli/services/dev/migrate-app-module.ts @@ -1,6 +1,5 @@ import {LocalSource, RemoteSource} from '../context/identifiers.js' -import {IdentifiersExtensions} from '../../models/app/identifiers.js' -import {getExtensionIds, LocalRemoteSource} from '../context/id-matching.js' +import {ExtensionUuidsByLocalIdentifier} from '../../models/app/identifiers.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {MigrateAppModuleSchema, MigrateAppModuleVariables} from '../../api/graphql/extension_migrate_app_module.js' import {MAX_EXTENSION_HANDLE_LENGTH} from '../../models/extensions/schemas.js' @@ -14,16 +13,6 @@ import {slugify} from '@shopify/cli-kit/common/string' * - The key is the NEW type * - The value is an array of OLD types that can be migrated to the new type */ -export const PaymentModulesMap = { - payments_extension: [ - 'payments_app', - 'payments_app_credit_card', - 'payments_app_custom_credit_card', - 'payments_app_custom_onsite', - 'payments_app_redeemable', - ], -} - export const MarketingModulesMap = { marketing_activity: ['marketing_activity_extension'], } @@ -38,14 +27,23 @@ export const UIModulesMap = { ui_extension: ['CHECKOUT_UI_EXTENSION', 'POS_UI_EXTENSION'], } -export const SubscriptionModulesMap = { - subscription_link_extension: ['subscription_link'], -} - export const AdminLinkModulesMap = { admin_link: ['app_link', 'bulk_action'], } +export interface LocalRemoteSource { + local: LocalSource + remote: RemoteSource +} + +function getExtensionIds( + localSources: LocalSource[], + identifiers: ExtensionUuidsByLocalIdentifier, +): ExtensionUuidsByLocalIdentifier { + const localSourceIds = new Set(localSources.map((source) => source.localIdentifier)) + return Object.fromEntries(Object.entries(identifiers).filter(([identifier]) => localSourceIds.has(identifier))) +} + /** * Returns a list of local and remote extensions that need to be migrated. * @@ -58,7 +56,7 @@ export const AdminLinkModulesMap = { export function getModulesToMigrate( localSources: LocalSource[], remoteSources: RemoteSource[], - identifiers: IdentifiersExtensions, + identifiers: ExtensionUuidsByLocalIdentifier, typesMap: {[key: string]: string[]}, ) { const ids = getExtensionIds(localSources, identifiers) diff --git a/packages/app/src/cli/services/dev/migrate-flow-extension.ts b/packages/app/src/cli/services/dev/migrate-flow-extension.ts index 1b1f653ebb1..b5241e8f48f 100644 --- a/packages/app/src/cli/services/dev/migrate-flow-extension.ts +++ b/packages/app/src/cli/services/dev/migrate-flow-extension.ts @@ -1,5 +1,5 @@ +import {LocalRemoteSource} from './migrate-app-module.js' import {RemoteSource} from '../context/identifiers.js' -import {LocalRemoteSource} from '../context/id-matching.js' import { MigrateFlowExtensionSchema, MigrateFlowExtensionVariables, diff --git a/packages/app/src/cli/services/dev/migrate-to-ui-extension.ts b/packages/app/src/cli/services/dev/migrate-to-ui-extension.ts index 7c7d8c31b7a..92596967f88 100644 --- a/packages/app/src/cli/services/dev/migrate-to-ui-extension.ts +++ b/packages/app/src/cli/services/dev/migrate-to-ui-extension.ts @@ -1,9 +1,9 @@ +import {LocalRemoteSource} from './migrate-app-module.js' import { MigrateToUiExtensionSchema, MigrateToUiExtensionVariables, } from '../../api/graphql/extension_migrate_to_ui_extension.js' import {RemoteSource} from '../context/identifiers.js' -import {LocalRemoteSource} from '../context/id-matching.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {AbortError} from '@shopify/cli-kit/node/error' diff --git a/packages/app/src/cli/services/import-extensions.ts b/packages/app/src/cli/services/import-extensions.ts index a1429f81ec7..67ca900befa 100644 --- a/packages/app/src/cli/services/import-extensions.ts +++ b/packages/app/src/cli/services/import-extensions.ts @@ -1,5 +1,5 @@ import {AppLinkedInterface, CurrentAppConfiguration} from '../models/app/app.js' -import {updateAppIdentifiers, IdentifiersExtensions} from '../models/app/identifiers.js' +import {updateAppIdentifiers, ExtensionUuidsByLocalIdentifier} from '../models/app/identifiers.js' import {ExtensionRegistration} from '../api/graphql/all_app_extension_registrations.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' import {MAX_EXTENSION_HANDLE_LENGTH} from '../models/extensions/schemas.js' @@ -103,7 +103,7 @@ export async function importExtensions(options: ImportOptions) { } } - const extensionUuids: IdentifiersExtensions = {} + const extensionUuids: ExtensionUuidsByLocalIdentifier = {} const importPromises = extensionsToMigrate.map(async (ext) => { const {directory, action} = await handleExtensionDirectory({app, name: ext.title}) @@ -126,10 +126,8 @@ export async function importExtensions(options: ImportOptions) { renderSuccessMessages(generatedExtensions) await updateAppIdentifiers({ app, - identifiers: { - extensions: extensionUuids, - app: remoteApp.apiKey, - }, + appApiKey: remoteApp.apiKey, + extensionUuids, command: 'import-extensions', }) } diff --git a/packages/app/src/cli/services/release.test.ts b/packages/app/src/cli/services/release.test.ts index 3f77c0c2dee..fb00a65db6d 100644 --- a/packages/app/src/cli/services/release.test.ts +++ b/packages/app/src/cli/services/release.test.ts @@ -1,6 +1,6 @@ import {release} from './release.js' import { - configExtensionsIdentifiersBreakdown, + configExtensionsIdentifiersReleaseBreakdown, extensionsIdentifiersReleaseBreakdown, } from './context/breakdown-extensions.js' import {testAppLinked, testDeveloperPlatformClient} from '../models/app/app.test-data.js' @@ -148,7 +148,7 @@ async function testRelease( ) { // Given vi.mocked(extensionsIdentifiersReleaseBreakdown).mockResolvedValue(buildExtensionsBreakdown()) - vi.mocked(configExtensionsIdentifiersBreakdown).mockResolvedValue(buildConfigExtensionsBreakdown()) + vi.mocked(configExtensionsIdentifiersReleaseBreakdown).mockReturnValue(buildConfigExtensionsBreakdown()) await release({ app, diff --git a/packages/app/src/cli/services/release.ts b/packages/app/src/cli/services/release.ts index 4d3101fe85e..8f5c2b2b1f0 100644 --- a/packages/app/src/cli/services/release.ts +++ b/packages/app/src/cli/services/release.ts @@ -1,5 +1,5 @@ import { - configExtensionsIdentifiersBreakdown, + configExtensionsIdentifiersReleaseBreakdown, extensionsIdentifiersReleaseBreakdown, } from './context/breakdown-extensions.js' import {AppLinkedInterface} from '../models/app/app.js' @@ -41,16 +41,15 @@ export async function release(options: ReleaseOptions) { remoteApp, options.version, ) - const configExtensionIdentifiersBreakdown = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - apiKey: remoteApp.apiKey, - localApp: app, - remoteApp, - versionAppModules: versionDetails.appModuleVersions.map((appModuleVersion) => ({ - ...appModuleVersion, - })), - release: true, - }) + const configExtensionIdentifiersBreakdown = app.allExtensions.some((extension) => extension.isAppConfigExtension) + ? configExtensionsIdentifiersReleaseBreakdown({ + localApp: app, + versionAppModules: versionDetails.appModuleVersions.map((appModuleVersion) => ({ + ...appModuleVersion, + })), + activeAppVersion: await developerPlatformClient.activeAppVersion(remoteApp), + }) + : undefined const confirmed = await deployOrReleaseConfirmationPrompt({ configExtensionIdentifiersBreakdown, extensionIdentifiersBreakdown, diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index 07b32c020bd..88d7b3d462c 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -129,6 +129,7 @@ import { AppLogsSubscribeMutationVariables, } from '../../api/graphql/app-management/generated/app-logs-subscribe.js' import {SourceExtension} from '../../api/graphql/app-management/generated/types.js' +import {WebhookSubscriptionSpecIdentifier} from '../../models/extensions/specifications/app_config_webhook_subscription.js' import {fetchOrganizations} from '@shopify/organizations' import {getAppAutomationToken} from '@shopify/cli-kit/node/environment' import {ensureAuthenticatedAppManagementAndBusinessPlatform, Session} from '@shopify/cli-kit/node/session' @@ -445,7 +446,7 @@ export class AppManagementClient implements DeveloperPlatformClient { managementExperience: 'cli', registrationLimit: spec.uidStrategy.appModuleLimit, uidStrategy: uidStrategyFromTypename(spec.uidStrategy.__typename), - experience: normalizeExperience(spec.experience), + experience: normalizeExperience(spec.experience, spec.identifier), validationSchema: spec.validationSchema, }), ) @@ -687,7 +688,7 @@ export class AppManagementClient implements DeveloperPlatformClient { registrationTitle: mod.handle, specification: { identifier: mod.specification.identifier, - experience: normalizeExperience(mod.specification.experience), + experience: normalizeExperience(mod.specification.experience, mod.specification.identifier), options: { managementExperience: 'cli', }, @@ -1364,7 +1365,8 @@ type SpecificationExperience = 'extension' | 'configuration' | 'deprecated' * Normalizes the raw experience string from the API into a known union value. * Falls back to 'extension' for any unexpected/unknown values and logs a debug message. */ -function normalizeExperience(raw: string): SpecificationExperience { +function normalizeExperience(raw: string, type: string): SpecificationExperience { + if (type === WebhookSubscriptionSpecIdentifier) return 'configuration' switch (raw) { case 'extension': case 'configuration': @@ -1422,7 +1424,7 @@ function appModuleVersion(mod: ReleasedAppModuleFragment): Required { }) }) +describe('deepCompareWithOrderInsensitiveArrays', () => { + test('returns true when arrays contain the same values in a different order', () => { + const first = { + webhooks: { + subscriptions: [ + {topic: 'orders/delete', uri: 'https://example.com/orders/delete'}, + {topic: 'products/update', uri: 'https://example.com/products/update'}, + ], + }, + } + const second = { + webhooks: { + subscriptions: [ + {uri: 'https://example.com/products/update', topic: 'products/update'}, + {uri: 'https://example.com/orders/delete', topic: 'orders/delete'}, + ], + }, + } + + const result = deepCompareWithOrderInsensitiveArrays(first, second) + + expect(result).toBeTruthy() + }) + + test('returns false when arrays contain different values', () => { + const first = {items: [{id: 1}, {id: 2}]} + const second = {items: [{id: 1}, {id: 3}]} + + const result = deepCompareWithOrderInsensitiveArrays(first, second) + + expect(result).toBeFalsy() + }) +}) + describe('deepDifference', () => { test('returns the difference between two objects', () => { // Given diff --git a/packages/cli-kit/src/public/common/object.ts b/packages/cli-kit/src/public/common/object.ts index 23e6e122df8..0d6a02f2a8f 100644 --- a/packages/cli-kit/src/public/common/object.ts +++ b/packages/cli-kit/src/public/common/object.ts @@ -68,6 +68,37 @@ export function deepCompare(one: object, two: object): boolean { return lodashIsEqual(one, two) } +/** + * Deeply compares two values and treats arrays as order-insensitive. + * + * @param one - The first value to be compared. + * @param two - The second value to be compared. + * @returns True if the normalized values are equal, false otherwise. + */ +export function deepCompareWithOrderInsensitiveArrays(one: unknown, two: unknown): boolean { + return lodashIsEqual(normalizeOrderInsensitiveArrays(one), normalizeOrderInsensitiveArrays(two)) +} + +function normalizeOrderInsensitiveArrays(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(normalizeOrderInsensitiveArrays).sort(compareNormalizedValues) + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .map(([key, nestedValue]) => [key, normalizeOrderInsensitiveArrays(nestedValue)]), + ) + } + + return value ?? {} +} + +function compareNormalizedValues(left: unknown, right: unknown) { + return JSON.stringify(left).localeCompare(JSON.stringify(right)) +} + /** * Return the difference between two nested objects. *