From b870535b39cd87fb3aca909e700560ff0cc01c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 9 Jul 2026 19:48:54 +0200 Subject: [PATCH] Remove the legacy extension identifier-matching code Deletes the now-unused Partners-era matching subsystem (id-matching, id-manual-matching, identifiers-extensions, ensureDeploymentIdsPresence, the match/select prompts, and the deploy-path breakdown helpers) now that deploy classifies against the active app version. Relocates the still-needed `LocalRemoteSource`/`getExtensionIds` into migrate-app-module, reshapes `updateAppIdentifiers` to take an app API key and a flat UUID map, drops the dead `Identifiers` types and the unused `rewriteConfiguration` helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/cli/models/app/identifiers.test.ts | 32 +- .../app/src/cli/models/app/identifiers.ts | 36 +- .../app/write-app-configuration-file.ts | 48 - .../context/breakdown-extensions.test.ts | 1772 ++--------------- .../services/context/breakdown-extensions.ts | 362 +--- .../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.ts | 322 --- .../cli/services/context/identifiers.test.ts | 249 --- .../src/cli/services/context/identifiers.ts | 60 - .../app/src/cli/services/context/prompts.ts | 34 +- .../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 +- 17 files changed, 187 insertions(+), 3558 deletions(-) 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.ts delete mode 100644 packages/app/src/cli/services/context/identifiers.test.ts diff --git a/packages/app/src/cli/models/app/identifiers.test.ts b/packages/app/src/cli/models/app/identifiers.test.ts index 106c325f834..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', }, diff --git a/packages/app/src/cli/models/app/identifiers.ts b/packages/app/src/cli/models/app/identifiers.ts index 82411a9a3cd..ab92c8d8c39 100644 --- a/packages/app/src/cli/models/app/identifiers.ts +++ b/packages/app/src/cli/models/app/identifiers.ts @@ -6,30 +6,6 @@ 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 { - [localIdentifier: string]: string -} - -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 ExtensionUuidsByLocalIdentifier { [localIdentifier: string]: string } @@ -39,11 +15,11 @@ export interface DeployIdentifiers { appModuleRegistrationIds: ExtensionUuidsByLocalIdentifier } -type UuidOnlyIdentifiers = Omit type UpdateAppIdentifiersCommand = 'dev' | 'deploy' | 'release' | 'import-extensions' interface UpdateAppIdentifiersOptions { app: AppInterface - identifiers: UuidOnlyIdentifiers + appApiKey: string + extensionUuids: ExtensionUuidsByLocalIdentifier command: UpdateAppIdentifiersCommand } @@ -53,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 @@ -64,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]! } }) diff --git a/packages/app/src/cli/services/app/write-app-configuration-file.ts b/packages/app/src/cli/services/app/write-app-configuration-file.ts index 1bfce4f9769..4c70eb5f3bc 100644 --- a/packages/app/src/cli/services/app/write-app-configuration-file.ts +++ b/packages/app/src/cli/services/app/write-app-configuration-file.ts @@ -3,7 +3,6 @@ import {reduceWebhooks} from '../../models/extensions/specifications/transform/a import {removeTrailingSlash} from '../../models/extensions/specifications/validation/common.js' import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' import {JsonMapType} from '@shopify/cli-kit/node/toml' -import {zod} from '@shopify/cli-kit/node/schema' import {outputDebug} from '@shopify/cli-kit/node/output' export async function writeAppConfigurationFile(configuration: CurrentAppConfiguration, configPath: string) { @@ -46,53 +45,6 @@ export function stripEmptyObjects(obj: unknown): unknown { return obj } -/** - * Rewrite a configuration object to match the structure of a Zod schema. - * - * Used by breakdown-extensions.ts to normalize configs before diffing. - * Not used by writeAppConfigurationFile — that function uses stripEmptyObjects instead. - */ -export const rewriteConfiguration = (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/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 fb97cd52a18..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, deepCompareWithOrderInsensitiveArrays, 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,307 +64,6 @@ export async function extensionsIdentifiersReleaseBreakdown( return {extensionIdentifiersBreakdown, versionDetails} } -export async function configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - remoteApp, - localApp, - versionAppModules, - release, - activeAppVersion, -}: { - developerPlatformClient: DeveloperPlatformClient - apiKey: string - remoteApp: MinimalOrganizationApp - localApp: AppInterface - versionAppModules?: AppModuleVersion[] - release?: boolean - activeAppVersion?: AppVersion -}) { - if (localApp.allExtensions.filter((extension) => extension.isAppConfigExtension).length === 0) return - if (!release) return loadLocalConfigExtensionIdentifiersBreakdown(localApp) - - return resolveRemoteConfigExtensionIdentifiersBreakdown( - developerPlatformClient, - remoteApp, - localApp, - 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}, - ) - - 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 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], - } -} - -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) - }) - - // 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 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})) - - 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)), - } -} - -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, - } -} - export function configExtensionsIdentifiersReleaseBreakdown({ localApp, versionAppModules, 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.ts b/packages/app/src/cli/services/context/identifiers-extensions.ts deleted file mode 100644 index 2f1034ec7ea..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 ?? {} - 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) - } - }) -} - -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 -} - -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 bbd44c30f62..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 {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 @@ -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/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', }) }