From 528a122eec28b7ba704b5db3e67e88872be14c6d Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Mon, 5 Oct 2026 14:44:21 -0400 Subject: [PATCH] Record created dev store identity on command events --- .../src/cli/services/dev/select-store.test.ts | 185 +++++++++++++- .../cli-kit/src/public/node/analytics.test.ts | 34 ++- .../generated/create_app_development_store.ts | 2 + .../create_app_development_store.graphql | 1 + .../cli/services/dev/create-dev-store.test.ts | 235 +++++++++++++++++- .../src/cli/services/dev/create-dev-store.ts | 10 + 6 files changed, 463 insertions(+), 4 deletions(-) diff --git a/packages/app/src/cli/services/dev/select-store.test.ts b/packages/app/src/cli/services/dev/select-store.test.ts index 19d07566270..1f4d3cdf133 100644 --- a/packages/app/src/cli/services/dev/select-store.test.ts +++ b/packages/app/src/cli/services/dev/select-store.test.ts @@ -1,4 +1,4 @@ -import {selectStore} from './select-store.js' +import {selectStore, StoreCreationMode} from './select-store.js' import {devStoreCapReached} from './cap.js' import {fetchStore, StoreNotFoundError} from './fetch.js' import {Organization, OrganizationSource, OrganizationStore} from '../../models/organization.js' @@ -12,7 +12,11 @@ import { import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js' import {ClientName} from '../../utilities/developer-platform-client.js' import {sleep} from '@shopify/cli-kit/node/system' -import {isTTY, renderInfo, renderSuccess, renderTasks, Task} from '@shopify/cli-kit/node/ui' +import {isTTY, renderInfo, renderSuccess, renderSingleTask, renderTasks, Task} from '@shopify/cli-kit/node/ui' +import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {addPublicMetadata, getAllPublicMetadata, getAllSensitiveMetadata} from '@shopify/cli-kit/node/metadata' +import {outputResult} from '@shopify/cli-kit/node/output' import {AbortError, CancelExecution} from '@shopify/cli-kit/node/error' import {createDevStore} from '@shopify/organizations' import {beforeEach, describe, expect, vi, test} from 'vitest' @@ -26,6 +30,16 @@ vi.mock('./fetch', async (importOriginal) => ({ vi.mock('@shopify/organizations') vi.mock('@shopify/cli-kit/node/system') vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/cli-kit/node/api/business-platform') +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => ({ + ...(await importOriginal()), + outputResult: vi.fn(), +})) + +beforeEach(async () => { + await addPublicMetadata(() => ({store_id: undefined})) +}) const ORG1: Organization = { id: '1', @@ -64,6 +78,173 @@ const STORE3: OrganizationStore = { const defaultShowDomainOnPrompt = false +describe('inline creation identity with the real Organizations creator', () => { + const createdStore: OrganizationStore = { + ...STORE1, + shopId: '987654321', + shopName: 'created-store', + shopDomain: 'created-store.myshopify.com', + } + + beforeEach(async () => { + const actual = await vi.importActual('@shopify/organizations') + vi.mocked(createDevStore).mockImplementation(actual.createDevStore) + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('test-token') + vi.mocked(isTTY).mockReturnValue(true) + vi.mocked(devStoreCapReached).mockResolvedValue(false) + vi.mocked(devStoreNamePrompt).mockResolvedValue('created-store') + vi.mocked(devStorePlanPrompt).mockResolvedValue('grow') + vi.mocked(devStoreDemoDataPrompt).mockResolvedValue(false) + vi.mocked(sleep).mockResolvedValue(undefined) + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(() => {})) + vi.mocked(renderTasks).mockImplementation(async (tasks: Task[]) => { + for (const task of tasks) { + // eslint-disable-next-line no-await-in-loop + await task.task({}, task) + } + return {} + }) + vi.mocked(selectStorePrompt).mockImplementation(async ({onCreateStoreWhenEmpty, onCreateStore}) => { + return (onCreateStoreWhenEmpty ?? onCreateStore)!() + }) + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce({ + createAppDevelopmentStore: { + shopDomain: createdStore.shopDomain, + shopAdminUrl: null, + shopifyShopId: 'gid://shopify/Shop/987654321', + userErrors: [], + }, + }) + .mockImplementationOnce(async () => { + expect(getAllPublicMetadata().store_id).toBe(987654321) + return {organization: {storeCreation: {status: 'COMPLETE'}}} + }) + vi.mocked(fetchStore).mockResolvedValue(createdStore) + }) + + test.each(['when-empty', 'selection-option'])( + 'records the shop identity through the real %s creation route', + async (mode) => { + const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) + const stores = mode === 'when-empty' ? [] : [STORE1] + + await expect(selectStore({stores, hasMorePages: false}, ORG1, developerPlatformClient, mode)).resolves.toEqual( + createdStore, + ) + + expect(createDevStore).toHaveBeenCalledExactlyOnceWith({ + name: 'created-store', + plan: 'grow', + withDemoData: false, + organization: ORG1, + json: false, + summary: false, + }) + expect(getAllPublicMetadata().store_id).toBe(987654321) + expect(getAllPublicMetadata()).not.toHaveProperty('store_creation') + expect(getAllSensitiveMetadata()).not.toHaveProperty('store_creation') + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledTimes(2) + expect(fetchStore).toHaveBeenCalledExactlyOnceWith(ORG1, createdStore.shopDomain, developerPlatformClient) + expect(renderSuccess).toHaveBeenCalledExactlyOnceWith({ + headline: 'Dev store "created-store" created successfully.', + }) + if (mode === 'when-empty') { + expect(renderInfo).toHaveBeenCalledExactlyOnceWith({ + body: "You don't have any dev stores associated with org1's Dev Dashboard. Let's create one.", + }) + } else { + expect(renderInfo).not.toHaveBeenCalled() + } + expect(outputResult).not.toHaveBeenCalled() + }, + ) + + test.each([ + ['when-empty', new StoreNotFoundError('Still provisioning'), 10], + ['selection-option', new StoreNotFoundError('Still provisioning'), 10], + ['when-empty', new AbortError('Refetch failed'), 1], + ['selection-option', new AbortError('Refetch failed'), 1], + ] as const)('retains identity after a %s refetch error %s', async (mode, error, expectedFetches) => { + vi.mocked(fetchStore).mockRejectedValue(error) + const stores = mode === 'when-empty' ? [] : [STORE1] + const expectedMessage = + expectedFetches === 10 + ? 'The newly created development store (created-store.myshopify.com) is not available yet.' + : 'Refetch failed' + + await expect( + selectStore( + {stores, hasMorePages: false}, + ORG1, + testDeveloperPlatformClient({clientName: ClientName.AppManagement}), + mode, + ), + ).rejects.toThrow(expectedMessage) + + expect(getAllPublicMetadata().store_id).toBe(987654321) + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledTimes(2) + expect(fetchStore).toHaveBeenCalledTimes(expectedFetches) + expect(renderSuccess).not.toHaveBeenCalled() + expect(outputResult).not.toHaveBeenCalled() + }) + + test.each(['disabled', 'when-empty', 'selection-option'])( + 'does not record a creation identity when an existing store is selected in %s mode', + async (mode) => { + vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) + + await expect( + selectStore( + {stores: [STORE1], hasMorePages: false}, + ORG1, + testDeveloperPlatformClient({clientName: ClientName.AppManagement}), + mode, + ), + ).resolves.toEqual(STORE1) + + expect(getAllPublicMetadata().store_id).toBeUndefined() + expect(getAllPublicMetadata()).not.toHaveProperty('store_creation') + expect(getAllSensitiveMetadata()).not.toHaveProperty('store_creation') + expect(createDevStore).not.toHaveBeenCalled() + expect(businessPlatformOrganizationsRequestDoc).not.toHaveBeenCalled() + expect(fetchStore).not.toHaveBeenCalled() + }, + ) + + test('does not record identity when inline creation is cancelled', async () => { + vi.mocked(selectStorePrompt).mockResolvedValueOnce(undefined) + + await expect( + selectStore( + {stores: [], hasMorePages: false}, + ORG1, + testDeveloperPlatformClient({clientName: ClientName.AppManagement}), + 'when-empty', + ), + ).rejects.toBeInstanceOf(CancelExecution) + expect(getAllPublicMetadata().store_id).toBeUndefined() + expect(createDevStore).not.toHaveBeenCalled() + expect(businessPlatformOrganizationsRequestDoc).not.toHaveBeenCalled() + }) + + test('does not record identity when the store cap stops inline creation', async () => { + vi.mocked(devStoreCapReached).mockResolvedValueOnce(true) + + await expect( + selectStore( + {stores: [], hasMorePages: false}, + ORG1, + testDeveloperPlatformClient({clientName: ClientName.AppManagement}), + 'when-empty', + ), + ).rejects.toThrow('Your organization has reached its development store limit.') + expect(getAllPublicMetadata().store_id).toBeUndefined() + expect(createDevStore).not.toHaveBeenCalled() + expect(businessPlatformOrganizationsRequestDoc).not.toHaveBeenCalled() + }) +}) + describe('selectStore', async () => { beforeEach(() => { vi.mocked(isTTY).mockReturnValue(true) diff --git a/packages/cli-kit/src/public/node/analytics.test.ts b/packages/cli-kit/src/public/node/analytics.test.ts index ea764c23fcc..2ad5bcc132b 100644 --- a/packages/cli-kit/src/public/node/analytics.test.ts +++ b/packages/cli-kit/src/public/node/analytics.test.ts @@ -55,7 +55,7 @@ describe('event tracking', () => { let publishEventMock: MockedFunction let execMock: MockedFunction - beforeEach(() => { + beforeEach(async () => { vi.setSystemTime(currentDate) vi.mocked(isShopify).mockResolvedValue(false) vi.mocked(isDevelopment).mockReturnValue(false) @@ -69,6 +69,7 @@ describe('event tracking', () => { vi.mocked(isInsideContainer).mockReturnValue(false) publishEventMock = vi.mocked(publishMonorailEvent).mockReturnValue(Promise.resolve({type: 'ok'})) execMock = vi.mocked(exec).mockResolvedValue(undefined) + await addPublicMetadata(() => ({store_id: undefined})) }) afterEach(() => { @@ -306,6 +307,37 @@ describe('event tracking', () => { }) }) + test.each([ + ['store create dev', 'store', 123456789], + ['app dev', 'app', 123456789], + ['store create dev', 'store', undefined], + ['app dev', 'app', undefined], + ])('serializes native store_id %s %s %s on the existing command event', async (command, topic, storeId) => { + await inProjectWithFile('package.json', async (args) => { + await startAnalytics({commandContent: {command, topic}, args, currentTime: currentDate.getTime() - 100}) + await addPublicMetadata(() => ({store_id: storeId})) + const config = {runHook: vi.fn().mockResolvedValue({successes: [], failures: []}), plugins: []} as any + + await reportAnalyticsEvent({config, exitMode: 'ok'}) + await sendReportedAnalyticsPayload() + + expect(publishEventMock).toHaveBeenCalledOnce() + const [, publicPayload, sensitivePayload] = publishEventMock.mock.calls[0]! + expect(publicPayload).toMatchObject({command, cmd_all_topic: topic}) + if (storeId === undefined) { + expect(publicPayload).not.toHaveProperty('store_id') + } else { + expect(publicPayload.store_id).toBe(storeId) + expect(typeof publicPayload.store_id).toBe('number') + } + expect(publicPayload).not.toHaveProperty('store_creation') + expect(sensitivePayload).not.toHaveProperty('store_creation') + expect(sensitivePayload).not.toHaveProperty('store_id') + if (typeof sensitivePayload.metadata !== 'string') throw new Error('Expected serialized sensitive metadata') + expect(JSON.parse(sensitivePayload.metadata)).not.toHaveProperty('store_creation') + }) + }) + test('uses a recorded command end time when reporting after postrun work', async () => { await inProjectWithFile('package.json', async (args) => { // Given diff --git a/packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/create_app_development_store.ts b/packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/create_app_development_store.ts index dc279d48a68..4cfc3366a30 100644 --- a/packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/create_app_development_store.ts +++ b/packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/create_app_development_store.ts @@ -15,6 +15,7 @@ export type CreateAppDevelopmentStoreMutation = { createAppDevelopmentStore: { shopAdminUrl?: string | null shopDomain?: string | null + shopifyShopId?: string | null userErrors?: {code?: string | null; field: string[]; message: string}[] | null } } @@ -91,6 +92,7 @@ export const CreateAppDevelopmentStore = { selections: [ {kind: 'Field', name: {kind: 'Name', value: 'shopAdminUrl'}}, {kind: 'Field', name: {kind: 'Name', value: 'shopDomain'}}, + {kind: 'Field', name: {kind: 'Name', value: 'shopifyShopId'}}, { kind: 'Field', name: {kind: 'Name', value: 'userErrors'}, diff --git a/packages/organizations/src/cli/api/graphql/business-platform-organizations/mutations/create_app_development_store.graphql b/packages/organizations/src/cli/api/graphql/business-platform-organizations/mutations/create_app_development_store.graphql index b6da18eb81e..d73d811f876 100644 --- a/packages/organizations/src/cli/api/graphql/business-platform-organizations/mutations/create_app_development_store.graphql +++ b/packages/organizations/src/cli/api/graphql/business-platform-organizations/mutations/create_app_development_store.graphql @@ -8,6 +8,7 @@ mutation CreateAppDevelopmentStore($shopName: String!, $priceLookupKey: String!, ) { shopAdminUrl shopDomain + shopifyShopId userErrors { code field diff --git a/packages/organizations/src/cli/services/dev/create-dev-store.test.ts b/packages/organizations/src/cli/services/dev/create-dev-store.test.ts index 23945f4d40c..efce25c7a78 100644 --- a/packages/organizations/src/cli/services/dev/create-dev-store.test.ts +++ b/packages/organizations/src/cli/services/dev/create-dev-store.test.ts @@ -7,6 +7,7 @@ import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session import {renderSingleTask, renderSuccess} from '@shopify/cli-kit/node/ui' import {outputResult} from '@shopify/cli-kit/node/output' import {sleep} from '@shopify/cli-kit/node/system' +import {addPublicMetadata, getAllPublicMetadata, getAllSensitiveMetadata} from '@shopify/cli-kit/node/metadata' vi.mock('@shopify/cli-kit/node/api/business-platform', () => ({ businessPlatformOrganizationsRequestDoc: vi.fn(), @@ -38,11 +39,13 @@ const defaultMutationResult = { createAppDevelopmentStore: { shopAdminUrl: 'https://test-store.myshopify.com/admin', shopDomain: 'test-store.myshopify.com', + shopifyShopId: '123456789', userErrors: [], }, } -beforeEach(() => { +beforeEach(async () => { + await addPublicMetadata(() => ({store_id: undefined})) vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('test-token') vi.mocked(renderSingleTask).mockImplementation(async ({task}) => { return task(() => {}) @@ -67,6 +70,9 @@ describe('createDevStore', () => { }) expect(domain).toBe('test-store.myshopify.com') + expect(getAllPublicMetadata().store_id).toBe(123456789) + expect(getAllPublicMetadata()).not.toHaveProperty('store_creation') + expect(getAllSensitiveMetadata()).not.toHaveProperty('store_creation') expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledTimes(2) expect(businessPlatformOrganizationsRequestDoc).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -76,6 +82,230 @@ describe('createDevStore', () => { expect(renderSuccess).not.toHaveBeenCalled() expect(outputResult).not.toHaveBeenCalled() }) + + test.each([ + ['123456789', 123456789], + ['000123456789', 123456789], + ['gid://shopify/Shop/123456789', 123456789], + ['gid://shopify/Shop/000123456789', 123456789], + ['1', 1], + ['9007199254740991', Number.MAX_SAFE_INTEGER], + ['gid://shopify/Shop/9007199254740991', Number.MAX_SAFE_INTEGER], + [123456789, 123456789], + [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], + ])('records the native shop ID %s before polling', async (shopifyShopId, expectedId) => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce({ + createAppDevelopmentStore: {...defaultMutationResult.createAppDevelopmentStore, shopifyShopId}, + }) + .mockImplementationOnce(async () => { + expect(getAllPublicMetadata().store_id).toBe(expectedId) + return {organization: {storeCreation: {status: 'COMPLETE'}}} + }) + + const domain = await createDevStore({name: 'test-store', organization: defaultOrg, plan: 'plus', summary: false}) + + expect(domain).toBe('test-store.myshopify.com') + expect(getAllPublicMetadata().store_id).toBe(expectedId) + expect(getAllPublicMetadata()).not.toHaveProperty('store_creation') + expect(getAllSensitiveMetadata()).not.toHaveProperty('store_creation') + expect(getAllPublicMetadata()).not.toHaveProperty('store_domain') + expect(getAllPublicMetadata()).not.toHaveProperty('store_fqdn_hash') + expect(getAllSensitiveMetadata()).not.toHaveProperty('store_fqdn') + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledTimes(2) + }) + + test.each([ + undefined, + null, + '', + '0', + 0, + -1, + '-1', + '+123', + '123abc', + ' 123', + '123 ', + '123\n', + '1.5', + 1.5, + '1e3', + '0x123', + 'NaN', + Number.NaN, + Number.POSITIVE_INFINITY, + '9007199254740992', + Number.MAX_SAFE_INTEGER + 1, + 'gid://shopify/Shop/0', + 'gid://shopify/Shop/9007199254740992', + 'gid://shopify/Product/123', + 'gid://organization/Shop/123', + 'gid://organization/Organization/123', + 'gid://shopify/Shop/extra/123', + 'gid://shopify/Shop/123?x=1', + 'gid://shopify/Shop/123\n', + 'Z2lkOi8vc2hvcGlmeS9TaG9wLzEyMw==', + true, + {}, + ['123'], + ])('omits an absent or invalid shop ID %j without changing creation', async (shopifyShopId) => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce({ + createAppDevelopmentStore: {...defaultMutationResult.createAppDevelopmentStore, shopifyShopId}, + }) + .mockResolvedValueOnce({organization: {storeCreation: {status: 'COMPLETE'}}}) + + await expect( + createDevStore({name: 'test-store', organization: defaultOrg, plan: 'plus', summary: false}), + ).resolves.toBe('test-store.myshopify.com') + expect(getAllPublicMetadata().store_id).toBeUndefined() + expect(getAllPublicMetadata()).not.toHaveProperty('store_creation') + expect(getAllSensitiveMetadata()).not.toHaveProperty('store_creation') + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledTimes(2) + }) + + test.each([ + [null, 'Store creation failed: unexpected empty response.'], + [ + { + ...defaultMutationResult.createAppDevelopmentStore, + userErrors: [{code: 'INVALID', field: ['shopName'], message: 'Name is taken'}], + }, + 'Failed to create dev store: Name is taken', + ], + [ + {...defaultMutationResult.createAppDevelopmentStore, shopDomain: null}, + 'Store creation succeeded but no shop domain was returned.', + ], + ])('does not record identity for an early creation error %j', async (response, message) => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce({createAppDevelopmentStore: response}) + + await expect( + createDevStore({name: 'test-store', organization: defaultOrg, plan: 'plus', json: true}), + ).rejects.toThrow(message) + expect(getAllPublicMetadata().store_id).toBeUndefined() + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledTimes(1) + expect(renderSingleTask).not.toHaveBeenCalled() + expect(renderSuccess).not.toHaveBeenCalled() + expect(outputResult).not.toHaveBeenCalled() + }) + + test.each(['FAILED', 'TIMED_OUT', 'USER_ERROR', undefined])( + 'retains the creation identity when polling returns %s', + async (status) => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockImplementationOnce(async () => { + expect(getAllPublicMetadata().store_id).toBe(123456789) + return {organization: {storeCreation: {status}}} + }) + + await expect( + createDevStore({name: 'test-store', organization: defaultOrg, plan: 'plus', json: true}), + ).rejects.toThrow( + status ? `Store creation failed with status: ${status}` : 'Unable to determine store creation status.', + ) + expect(getAllPublicMetadata().store_id).toBe(123456789) + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledTimes(2) + expect(renderSuccess).not.toHaveBeenCalled() + expect(outputResult).not.toHaveBeenCalled() + }, + ) + + test('retains the creation identity when polling throws', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockRejectedValueOnce(new Error('Polling request failed')) + + await expect( + createDevStore({name: 'test-store', organization: defaultOrg, plan: 'plus', json: true}), + ).rejects.toThrow('Polling request failed') + expect(getAllPublicMetadata().store_id).toBe(123456789) + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledTimes(2) + expect(outputResult).not.toHaveBeenCalled() + }) + + test('retains the creation identity when the readiness wait exceeds five minutes', async () => { + const dateNow = vi + .spyOn(Date, 'now') + .mockReturnValueOnce(0) + .mockReturnValue(6 * 60 * 1000) + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce(defaultMutationResult) + + try { + await expect( + createDevStore({name: 'test-store', organization: defaultOrg, plan: 'plus', json: true}), + ).rejects.toThrow('Store creation timed out after 5 minutes.') + expect(getAllPublicMetadata().store_id).toBe(123456789) + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledTimes(1) + expect(outputResult).not.toHaveBeenCalled() + } finally { + dateNow.mockRestore() + } + }) + + test('preserves readiness polling and progress updates', async () => { + const updateStatus = vi.fn() + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => { + expect(getAllPublicMetadata().store_id).toBe(123456789) + return task(updateStatus) + }) + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce(defaultMutationResult) + .mockResolvedValueOnce({organization: {storeCreation: {status: 'CALLING_CORE'}}}) + .mockResolvedValueOnce({organization: {storeCreation: {status: 'COMPLETE'}}}) + + await createDevStore({name: 'test-store', organization: defaultOrg, plan: 'plus', summary: false}) + + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledTimes(3) + expect(sleep).toHaveBeenCalledExactlyOnceWith(2) + expect(updateStatus).toHaveBeenCalledOnce() + expect(updateStatus.mock.calls[0]![0].value).toBe('Initiating store creation') + expect(renderSingleTask).toHaveBeenCalledWith(expect.objectContaining({renderOptions: {stdout: process.stderr}})) + }) + + test.each([null, 'https://test-store.myshopify.com/admin'])( + 'preserves the human summary with admin URL %s', + async (shopAdminUrl) => { + vi.mocked(businessPlatformOrganizationsRequestDoc) + .mockResolvedValueOnce({ + createAppDevelopmentStore: {...defaultMutationResult.createAppDevelopmentStore, shopAdminUrl}, + }) + .mockResolvedValueOnce({organization: {storeCreation: {status: 'COMPLETE'}}}) + + await createDevStore({ + name: 'test-store', + organization: defaultOrg, + plan: 'plus', + featurePreview: 'extended_variants', + country: 'CA', + withDemoData: true, + }) + + expect(renderSuccess).toHaveBeenCalledExactlyOnceWith({ + headline: 'Dev store "test-store" created successfully.', + customSections: [ + { + body: { + tabularData: [ + ['Domain', 'test-store.myshopify.com'], + ['Admin', shopAdminUrl ? {link: {label: shopAdminUrl, url: shopAdminUrl}} : 'N/A'], + ['Plan', 'plus'], + ['Feature preview', 'extended_variants'], + ['Country', 'CA'], + ['Demo data', 'enabled'], + ], + firstColumnSubdued: true, + }, + }, + ], + }) + expect(getAllPublicMetadata().store_id).toBe(123456789) + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledTimes(2) + expect(outputResult).not.toHaveBeenCalled() + }, + ) }) test.each([undefined, null, 'https://admin.shopify.com/store/test-store'])( @@ -111,6 +341,9 @@ test.each([undefined, null, 'https://admin.shopify.com/store/test-store'])( organization: {id: '123', name: 'Test Org'}, }) expect(createDevStoreJsonOutputSchema.validate(result)).toEqual(result) + expect(outputResult).toHaveBeenCalledExactlyOnceWith(JSON.stringify(result, null, 2)) + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledTimes(2) + expect(getAllPublicMetadata().store_id).toBe(123456789) expect(renderSuccess).not.toHaveBeenCalled() }, ) diff --git a/packages/organizations/src/cli/services/dev/create-dev-store.ts b/packages/organizations/src/cli/services/dev/create-dev-store.ts index 192586a9c43..c519673b9c4 100644 --- a/packages/organizations/src/cli/services/dev/create-dev-store.ts +++ b/packages/organizations/src/cli/services/dev/create-dev-store.ts @@ -7,7 +7,9 @@ import { } from '../../api/graphql/business-platform-organizations/generated/poll_store_creation.js' import {Organization} from '../../models/organization.js' import {businessPlatformTokenRefreshHandler} from '../business-platform.js' +import {numericIdFromGid} from '@shopify/cli-kit/common/gid' import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent, outputResult} from '@shopify/cli-kit/node/output' import {sleep} from '@shopify/cli-kit/node/system' @@ -89,6 +91,14 @@ export async function createDevStore(options: CreateDevStoreOptions): Promise 0) { + await addPublicMetadata(() => ({store_id: storeId})) + } + await renderSingleTask({ title: outputContent`Waiting for store to be ready`, task: async (updateStatus) => {