diff --git a/packages/app/src/cli/services/dev.test.ts b/packages/app/src/cli/services/dev.test.ts index c1a7888f892..2806e279347 100644 --- a/packages/app/src/cli/services/dev.test.ts +++ b/packages/app/src/cli/services/dev.test.ts @@ -1,17 +1,92 @@ -import {warnIfScopesDifferBeforeDev, blockIfMigrationIncomplete} from './dev.js' +import {dev, warnIfScopesDifferBeforeDev, blockIfMigrationIncomplete} from './dev.js' +import {setupDevProcesses} from './dev/processes/setup-dev-processes.js' +import {renderDev} from './dev/ui.js' +import {fetchAppRemoteConfiguration} from './app/select-app.js' import { testAppLinked, testDeveloperPlatformClient, + testOrganization, testOrganizationApp, + testOrganizationStore, testProject, } from '../models/app/app.test-data.js' +import metadata from '../metadata.js' import {describe, expect, test, vi} from 'vitest' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {hashString} from '@shopify/cli-kit/node/crypto' +import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' +import {checkPortAvailability, getAvailableTCPPort} from '@shopify/cli-kit/node/tcp' vi.mock('./dev/fetch.js') +vi.mock('./dev/processes/setup-dev-processes.js') +vi.mock('./dev/ui.js') +vi.mock('./app/select-app.js') +vi.mock('@shopify/cli-kit/node/analytics') vi.mock('@shopify/cli-kit/node/tcp') vi.mock('../utilities/mkcert.js') +describe('dev', () => { + test('logs store domain metadata when launching dev', async () => { + const store = testOrganizationStore({shopDomain: 'dev-store.myshopify.com'}) + const app = testAppLinked() + let publicMetadata: Record | undefined + let sensitiveMetadata: Record | undefined + + vi.mocked(fetchAppRemoteConfiguration).mockResolvedValue({name: 'Remote app', application_url: '', embedded: true}) + vi.mocked(getAvailableTCPPort).mockResolvedValue(3456) + vi.mocked(checkPortAvailability).mockResolvedValue(true) + vi.mocked(setupDevProcesses).mockResolvedValue({ + processes: [], + previewUrl: 'https://dev-store.myshopify.com/admin/apps/api-key', + graphiqlUrl: undefined, + devSessionStatusManager: {} as any, + }) + vi.mocked(renderDev).mockResolvedValue(undefined) + + const addPublicMetadata = vi.spyOn(metadata, 'addPublicMetadata').mockImplementation(async (getMetadata) => { + publicMetadata = getMetadata() as Record + }) + const addSensitiveMetadata = vi.spyOn(metadata, 'addSensitiveMetadata').mockImplementation(async (getMetadata) => { + sensitiveMetadata = getMetadata() as Record + }) + + await dev({ + app, + project: testProject({usesWorkspaces: true}), + remoteApp: testOrganizationApp({apiKey: 'api-key'}), + organization: testOrganization(), + specifications: [], + developerPlatformClient: testDeveloperPlatformClient({supportsDevSessions: false}), + store, + directory: app.directory, + update: false, + commandConfig: {} as any, + skipDependenciesInstallation: true, + tunnel: {mode: 'custom', url: 'https://localhost:3456'}, + }) + + expect(publicMetadata).toEqual( + expect.objectContaining({ + cmd_dev_tunnel_type: 'localhost', + cmd_dev_urls_updated: false, + store_fqdn_hash: hashString(store.shopDomain), + store_domain: store.shopDomain, + cmd_app_dependency_installation_skipped: true, + }), + ) + expect(sensitiveMetadata).toEqual( + expect.objectContaining({ + store_fqdn: store.shopDomain, + cmd_dev_tunnel_custom: undefined, + }), + ) + expect(reportAnalyticsEvent).toHaveBeenCalledWith({config: {}, exitMode: 'ok'}) + + addPublicMetadata.mockRestore() + addSensitiveMetadata.mockRestore() + }) +}) + describe('warnIfScopesDifferBeforeDev', () => { const appsWithScopes = (local: string, remote: string) => { const localApp = testAppLinked({}) diff --git a/packages/app/src/cli/services/dev.ts b/packages/app/src/cli/services/dev.ts index 2798403311b..0d30481a791 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -476,6 +476,7 @@ async function logMetadataForDev(options: { cmd_dev_tunnel_custom_hash: tunnelType === 'custom' ? hashString(options.tunnelUrl) : undefined, cmd_dev_urls_updated: options.shouldUpdateURLs, store_fqdn_hash: hashString(options.storeFqdn), + store_domain: options.storeFqdn, cmd_app_dependency_installation_skipped: options.devOptions.skipDependenciesInstallation, })) diff --git a/packages/app/src/cli/services/store-context.test.ts b/packages/app/src/cli/services/store-context.test.ts index 6b3bd1e0306..bb83f8538da 100644 --- a/packages/app/src/cli/services/store-context.test.ts +++ b/packages/app/src/cli/services/store-context.test.ts @@ -174,6 +174,7 @@ describe('storeContext', () => { expect(meta).toEqual( expect.objectContaining({ store_fqdn_hash: hashString(mockStore.shopDomain), + store_domain: mockStore.shopDomain, }), ) @@ -186,6 +187,31 @@ describe('storeContext', () => { }) }) + test('normalizes the selected store before logging metadata', async () => { + await inTemporaryDirectory(async (dir) => { + const unnormalizedStore = testOrganizationStore({shopId: 'store1', shopDomain: 'test-store'}) + vi.mocked(fetchStore).mockResolvedValue(unnormalizedStore) + await prepareAppFolder(mockApp, dir) + + await storeContext({appContextResult, forceReselectStore: false}) + + const meta = metadata.getAllPublicMetadata() + expect(meta).toEqual( + expect.objectContaining({ + store_fqdn_hash: hashString('test-store.myshopify.com'), + store_domain: 'test-store.myshopify.com', + }), + ) + + const sensitiveMeta = metadata.getAllSensitiveMetadata() + expect(sensitiveMeta).toEqual( + expect.objectContaining({ + store_fqdn: 'test-store.myshopify.com', + }), + ) + }) + }) + test('adds hidden config to gitignore if needed', async () => { await inTemporaryDirectory(async (dir) => { await prepareAppFolder(mockApp, dir) diff --git a/packages/app/src/cli/services/store-context.ts b/packages/app/src/cli/services/store-context.ts index a0df8b837eb..42827d58bb1 100644 --- a/packages/app/src/cli/services/store-context.ts +++ b/packages/app/src/cli/services/store-context.ts @@ -64,8 +64,8 @@ export async function storeContext({ selectedStore = await selectStore(allStores, organization, developerPlatformClient) } - await logMetadata(selectedStore, forceReselectStore) selectedStore.shopDomain = normalizeStoreFqdn(selectedStore.shopDomain) + await logMetadata(selectedStore, forceReselectStore) // Save the selected store in the hidden config file if (selectedStore.shopDomain !== cachedStoreURL || !devStoreUrlFromHiddenConfig) { @@ -82,6 +82,7 @@ async function logMetadata(selectedStore: OrganizationStore, resetUsed: boolean) await metadata.addPublicMetadata(() => ({ cmd_app_reset_used: resetUsed, store_fqdn_hash: hashString(selectedStore.shopDomain), + store_domain: selectedStore.shopDomain, })) await metadata.addSensitiveMetadata(() => ({ diff --git a/packages/cli-kit/src/public/node/monorail.test.ts b/packages/cli-kit/src/public/node/monorail.test.ts index 1a4df1ed46a..72b7e89bb62 100644 --- a/packages/cli-kit/src/public/node/monorail.test.ts +++ b/packages/cli-kit/src/public/node/monorail.test.ts @@ -70,6 +70,7 @@ describe('monorail', () => { user_id: '42', store_fqdn_hash: 'hashed-store', store_fqdn_validated: true, + store_domain: 'shop.myshopify.com', }, { args: '--store shop.myshopify.com', @@ -98,6 +99,7 @@ describe('monorail', () => { user_id: '42', store_fqdn_hash: 'hashed-store', store_fqdn_validated: true, + store_domain: 'shop.myshopify.com', args: '--store shop.myshopify.com', store_fqdn: 'shop.myshopify.com', }, diff --git a/packages/cli-kit/src/public/node/monorail.ts b/packages/cli-kit/src/public/node/monorail.ts index 66b9ce1e9db..bb8d1e9dcd2 100644 --- a/packages/cli-kit/src/public/node/monorail.ts +++ b/packages/cli-kit/src/public/node/monorail.ts @@ -10,7 +10,7 @@ const url = 'https://monorail-edge.shopifysvc.com/v1/produce' type Optional = T | null // This is the topic name of the main event we log to Monorail, the command tracker -export const MONORAIL_COMMAND_TOPIC = 'app_cli3_command/1.24' +export const MONORAIL_COMMAND_TOPIC = 'app_cli3_command/1.26' export interface Schemas { [MONORAIL_COMMAND_TOPIC]: { @@ -46,6 +46,7 @@ export interface Schemas { is_employee: boolean store_fqdn_hash?: Optional store_fqdn_validated?: Optional + store_domain?: Optional user_id: string // Any and all commands diff --git a/packages/store/src/cli/services/store/attribution.test.ts b/packages/store/src/cli/services/store/attribution.test.ts index 799a5971061..905341e940e 100644 --- a/packages/store/src/cli/services/store/attribution.test.ts +++ b/packages/store/src/cli/services/store/attribution.test.ts @@ -12,7 +12,7 @@ describe('store command attribution', () => { vi.mocked(hashString).mockReturnValue('hashed-store') }) - test('records the sensitive, hashed, and validation state for a store fqdn', async () => { + test('records the sensitive, hashed, validation, and public store domain for a store fqdn', async () => { await recordStoreFqdnMetadata('shop.myshopify.com', true) expect(addSensitiveMetadata).toHaveBeenCalledWith(expect.any(Function)) @@ -21,7 +21,9 @@ describe('store command attribution', () => { expect(vi.mocked(addPublicMetadata).mock.calls[0]![0]()).toEqual({ store_fqdn_hash: 'hashed-store', store_fqdn_validated: true, + store_domain: 'shop.myshopify.com', }) expect(hashString).toHaveBeenCalledWith('shop.myshopify.com') }) + }) diff --git a/packages/store/src/cli/services/store/attribution.ts b/packages/store/src/cli/services/store/attribution.ts index 7479bc7891e..20e2b67ac07 100644 --- a/packages/store/src/cli/services/store/attribution.ts +++ b/packages/store/src/cli/services/store/attribution.ts @@ -3,5 +3,9 @@ import {addPublicMetadata, addSensitiveMetadata} from '@shopify/cli-kit/node/met export async function recordStoreFqdnMetadata(storeFqdn: string, validated: boolean): Promise { await addSensitiveMetadata(() => ({store_fqdn: storeFqdn})) - await addPublicMetadata(() => ({store_fqdn_hash: hashString(storeFqdn), store_fqdn_validated: validated})) + await addPublicMetadata(() => ({ + store_fqdn_hash: hashString(storeFqdn), + store_fqdn_validated: validated, + store_domain: storeFqdn, + })) } diff --git a/packages/theme/src/cli/utilities/theme-command.test.ts b/packages/theme/src/cli/utilities/theme-command.test.ts index 564878c6c82..674a232850a 100644 --- a/packages/theme/src/cli/utilities/theme-command.test.ts +++ b/packages/theme/src/cli/utilities/theme-command.test.ts @@ -8,12 +8,18 @@ import {fileExistsSync} from '@shopify/cli-kit/node/fs' import {AbortError} from '@shopify/cli-kit/node/error' import {resolvePath} from '@shopify/cli-kit/node/path' import {renderConcurrent, renderConfirmationPrompt, renderError, renderWarning} from '@shopify/cli-kit/node/ui' +import {addPublicMetadata, addSensitiveMetadata} from '@shopify/cli-kit/node/metadata' +import {hashString} from '@shopify/cli-kit/node/crypto' import type {Writable} from 'stream' vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/environments') vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/cli-kit/node/metadata', () => ({ + addPublicMetadata: vi.fn(), + addSensitiveMetadata: vi.fn(), +})) vi.mock('./theme-store.js') vi.mock('@shopify/cli-kit/node/fs') @@ -168,6 +174,8 @@ describe('ThemeCommand', () => { let mockSession: AdminSession beforeEach(() => { + vi.clearAllMocks() + mockSession = { token: 'test-token', storeFqdn: 'test-store.myshopify.com', @@ -227,6 +235,15 @@ describe('ThemeCommand', () => { args: {}, context: undefined, }) + const publicMetadata = vi.mocked(addPublicMetadata).mock.calls.map(([getMetadata]) => getMetadata()) + expect(publicMetadata).toContainEqual( + expect.objectContaining({ + store_fqdn_hash: hashString(mockSession.storeFqdn), + store_domain: mockSession.storeFqdn, + }), + ) + const sensitiveMetadata = vi.mocked(addSensitiveMetadata).mock.calls.map(([getMetadata]) => getMetadata()) + expect(sensitiveMetadata).toContainEqual({store_fqdn: mockSession.storeFqdn}) }) test('single environment provided but not found in TOML - throws AbortError', async () => { @@ -319,6 +336,49 @@ describe('ThemeCommand', () => { ) }) + test('multiple environments provided - logs metadata for each authenticated session', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', development: true}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: 'staging'}) + vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store) => ({ + token: 'test-token', + storeFqdn: store as string, + })) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestThemeCommand(['--environment', 'development', '--environment', 'staging'], CommandConfig) + + // When + await command.run() + + // Then + const publicMetadata = vi.mocked(addPublicMetadata).mock.calls.map(([getMetadata]) => getMetadata()) + expect(publicMetadata).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + store_fqdn_hash: hashString('store1.myshopify.com'), + store_domain: 'store1.myshopify.com', + }), + expect.objectContaining({ + store_fqdn_hash: hashString('store2.myshopify.com'), + store_domain: 'store2.myshopify.com', + }), + ]), + ) + const sensitiveMetadata = vi.mocked(addSensitiveMetadata).mock.calls.map(([getMetadata]) => getMetadata()) + expect(sensitiveMetadata).toEqual( + expect.arrayContaining([{store_fqdn: 'store1.myshopify.com'}, {store_fqdn: 'store2.myshopify.com'}]), + ) + }) + test("throws an AbortError if the path doesn't exist", async () => { await CommandConfig.load() const command = new TestThemeCommand([], CommandConfig) diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index 1c63e8b9970..e80f625972e 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -387,6 +387,7 @@ export default abstract class ThemeCommand extends Command { const data = compileData() await addPublicMetadata(() => ({ store_fqdn_hash: hashString(session.storeFqdn), + store_domain: session.storeFqdn, cmd_theme_timings: JSON.stringify(data.timings), cmd_theme_errors: JSON.stringify(data.errors),