Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
import * as Types from './types.js'

import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core'

export type AppInstallCountQueryVariables = Types.Exact<{
appId: Types.Scalars['ID']['input']
}>

export type AppInstallCountQuery = {app: {installCount?: number | null}}

export const AppInstallCount = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: {kind: 'Name', value: 'AppInstallCount'},
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {kind: 'Variable', name: {kind: 'Name', value: 'appId'}},
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'ID'}}},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {kind: 'Name', value: 'app'},
arguments: [
{
kind: 'Argument',
name: {kind: 'Name', value: 'id'},
value: {kind: 'Variable', name: {kind: 'Name', value: 'appId'}},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'installCount'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
],
},
},
],
} as unknown as DocumentNode<AppInstallCountQuery, AppInstallCountQueryVariables>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
query AppInstallCount($appId: ID!) {
app(id: $appId) {
installCount
}
}
1 change: 1 addition & 0 deletions packages/app/src/cli/models/app/app.test-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1376,6 +1376,7 @@ export function testDeveloperPlatformClient(stubs: Partial<DeveloperPlatformClie
orgFromId: (_organizationId: string) => Promise.resolve(testOrganization()),
appsForOrg: (_organizationId: string) => Promise.resolve({apps: [testOrganizationApp()], hasMorePages: false}),
specifications: (_app: MinimalAppIdentifiers) => Promise.resolve(testRemoteSpecifications),
appInstallCount: (_app: MinimalAppIdentifiers) => Promise.resolve(0),
templateSpecifications: (_app: MinimalAppIdentifiers) =>
Promise.resolve({templates: testRemoteExtensionTemplates, groupOrder: []}),
orgAndApps: (_orgId: string) =>
Expand Down
54 changes: 54 additions & 0 deletions packages/app/src/cli/prompts/deploy-release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,60 @@ describe('deployOrReleaseConfirmationPrompt', () => {
expect(result).toBe(true)
})

test('and no force with deleted extensions and installCount should pass installCount to danger prompt', async () => {
// Given
const breakdownInfo = buildCompleteBreakdownInfo()
const renderDangerousConfirmationPromptSpyOn = vi
.spyOn(ui, 'renderDangerousConfirmationPrompt')
.mockResolvedValue(true)
vi.spyOn(metadata, 'addPublicMetadata').mockImplementation(async () => {})
const appTitle = 'app title'

// When
const result = await deployOrReleaseConfirmationPrompt({
...breakdownInfo,
appTitle,
release: true,
force: false,
installCount: 1243,
})

// Then
expect(renderDangerousConfirmationPromptSpyOn).toHaveBeenCalledWith(
expect.objectContaining({
warningItem: expect.arrayContaining([{error: '1243'}]),
}),
)
expect(result).toBe(true)
})

test('and no force with deleted extensions but installCount 0 should not pass warningItem to danger prompt', async () => {
// Given
const breakdownInfo = buildCompleteBreakdownInfo()
const renderDangerousConfirmationPromptSpyOn = vi
.spyOn(ui, 'renderDangerousConfirmationPrompt')
.mockResolvedValue(true)
vi.spyOn(metadata, 'addPublicMetadata').mockImplementation(async () => {})
const appTitle = 'app title'

// When
const result = await deployOrReleaseConfirmationPrompt({
...breakdownInfo,
appTitle,
release: true,
force: false,
installCount: 0,
})

// Then
expect(renderDangerousConfirmationPromptSpyOn).toHaveBeenCalledWith(
expect.not.objectContaining({
warningItem: expect.anything(),
}),
)
expect(result).toBe(true)
})

test('and no force with deleted extensions but without app title should display the complete confirmation prompt', async () => {
// Given
const breakdownInfo = buildCompleteBreakdownInfo()
Expand Down
15 changes: 15 additions & 0 deletions packages/app/src/cli/prompts/deploy-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface DeployOrReleaseConfirmationPromptOptions {
/** If true, allow removing extensions and configuration without user confirmation */
allowDeletes?: boolean
showConfig?: boolean
installCount?: number
}

interface DeployConfirmationPromptOptions {
Expand All @@ -36,6 +37,7 @@ interface DeployConfirmationPromptOptions {
configInfoTable: InfoTableSection
}
release: boolean
installCount?: number
}

/**
Expand Down Expand Up @@ -97,6 +99,7 @@ export async function deployOrReleaseConfirmationPrompt({
configExtensionIdentifiersBreakdown,
appTitle,
release,
installCount,
}: DeployOrReleaseConfirmationPromptOptions): Promise<boolean> {
await metadata.addPublicMetadata(() => buildConfigurationBreakdownMetadata(configExtensionIdentifiersBreakdown))

Expand All @@ -117,6 +120,7 @@ export async function deployOrReleaseConfirmationPrompt({
extensionsContentPrompt,
configContentPrompt,
release,
installCount,
})
}

Expand All @@ -125,6 +129,7 @@ async function deployConfirmationPrompt({
extensionsContentPrompt: {extensionsInfoTable, hasDeletedExtensions},
configContentPrompt,
release,
installCount,
}: DeployConfirmationPromptOptions): Promise<boolean> {
const timeBeforeConfirmationMs = new Date().valueOf()
let confirmationResponse = true
Expand All @@ -149,11 +154,21 @@ async function deployConfirmationPrompt({
}

const question = `${release ? 'Release' : 'Create'} a new version${appTitle ? ` of ${appTitle}` : ''}?`
const showInstallCountWarning = hasDeletedExtensions && installCount !== undefined && installCount > 0
if (isDangerous) {
confirmationResponse = await renderDangerousConfirmationPrompt({
message: question,
infoTable,
confirmation: appTitle,
...(showInstallCountWarning
? {
warningItem: [
'This release removes extensions and related data from',
{error: installCount.toString()},
'app installations.\nUse caution as this may include production data on live stores.',
],
}
: {}),
})
} else {
confirmationResponse = await renderConfirmationPrompt({
Expand Down
155 changes: 153 additions & 2 deletions packages/app/src/cli/services/context/identifiers.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import {configExtensionsIdentifiersBreakdown, extensionsIdentifiersDeployBreakdown} from './breakdown-extensions.js'
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'
Expand Down Expand Up @@ -34,6 +39,152 @@ describe('ensureDeploymentIdsPresence', () => {
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: {},
force: false,
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 force is true, 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: {},
force: true,
release: true,
}

// 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: {},
force: false,
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: {},
force: false,
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())
Expand Down Expand Up @@ -75,7 +226,7 @@ describe('ensureDeploymentIdsPresence', () => {
function buildExtensionsBreakdown() {
return {
extensionIdentifiersBreakdown: {
onlyRemote: [],
onlyRemote: [] as ExtensionIdentifierBreakdownInfo[],
toCreate: [],
toUpdate: [],
fromDashboard: [],
Expand Down
20 changes: 20 additions & 0 deletions packages/app/src/cli/services/context/identifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,25 @@ export async function ensureDeploymentIdsPresence(options: EnsureDeploymentIdsPr
activeAppVersion: options.activeAppVersion,
})

const shouldFetchInstallCount =
extensionIdentifiersBreakdown.onlyRemote.length > 0 &&
!options.force &&
!(options.allowUpdates && options.allowDeletes)

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,
Expand All @@ -66,6 +85,7 @@ export async function ensureDeploymentIdsPresence(options: EnsureDeploymentIdsPr
force: options.force,
allowUpdates: options.allowUpdates,
allowDeletes: options.allowDeletes,
installCount,
})
if (!confirmed) throw new AbortSilentError()

Expand Down
Loading
Loading