Skip to content
Draft
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
137 changes: 136 additions & 1 deletion packages/cli-kit/src/public/node/hooks/prerun.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import {parseCommandContent, warnOnAvailableUpgrade} from './prerun.js'
import {parseCommandContent, warnOnAvailableUpgrade, interceptProcessExit, extractStoreMetadata} from './prerun.js'
import {checkForCachedNewVersion, packageManagerFromUserAgent} from '../node-package-manager.js'
import {cacheClear} from '../../../private/node/conf-store.js'
import {mockAndCaptureOutput} from '../testing/output.js'
import {reportAnalyticsEvent} from '../analytics.js'
import {postRunHookHasCompleted} from './postrun.js'
import * as metadata from '../metadata.js'

import {describe, expect, test, vi, afterEach, beforeEach} from 'vitest'

vi.mock('../node-package-manager')
vi.mock('../analytics.js')
vi.mock('./postrun.js')

beforeEach(() => {
cacheClear()
Expand Down Expand Up @@ -118,3 +123,133 @@ describe('parseCommandContent', () => {
expect(got.alias).toBe('upgradeAlias')
})
})

describe('interceptProcessExit', () => {
let originalExit: typeof process.exit

beforeEach(() => {
originalExit = process.exit
})

afterEach(() => {
process.exit = originalExit
})

test('reports analytics with ok when process.exit(0) is called and postrun has not completed', async () => {
vi.mocked(postRunHookHasCompleted).mockReturnValue(false)
vi.mocked(reportAnalyticsEvent).mockResolvedValue()

const exitSpy = vi.fn() as any
process.exit = exitSpy
const mockConfig = {} as any
interceptProcessExit(mockConfig)

;(process.exit as Function)(0)
await vi.waitFor(() => expect(exitSpy).toHaveBeenCalled())

expect(reportAnalyticsEvent).toHaveBeenCalledWith({config: mockConfig, exitMode: 'ok'})
expect(exitSpy).toHaveBeenCalledWith(0)
})

test('reports analytics with unexpected_error when process.exit(1) is called', async () => {
vi.mocked(postRunHookHasCompleted).mockReturnValue(false)
vi.mocked(reportAnalyticsEvent).mockResolvedValue()

const exitSpy = vi.fn() as any
process.exit = exitSpy
const mockConfig = {} as any
interceptProcessExit(mockConfig)

;(process.exit as Function)(1)
await vi.waitFor(() => expect(exitSpy).toHaveBeenCalled())

expect(reportAnalyticsEvent).toHaveBeenCalledWith({config: mockConfig, exitMode: 'unexpected_error'})
expect(exitSpy).toHaveBeenCalledWith(1)
})

test('skips analytics when postrun hook has already completed', async () => {
vi.mocked(postRunHookHasCompleted).mockReturnValue(true)
vi.mocked(reportAnalyticsEvent).mockResolvedValue()

const exitSpy = vi.fn() as any
process.exit = exitSpy
const mockConfig = {} as any
interceptProcessExit(mockConfig)

;(process.exit as Function)(0)

expect(reportAnalyticsEvent).not.toHaveBeenCalled()
expect(exitSpy).toHaveBeenCalledWith(0)
})
})

describe('extractStoreMetadata', () => {
afterEach(() => {
vi.restoreAllMocks()
})

test('extracts store from --shop flag', async () => {
const addPublicSpy = vi.spyOn(metadata, 'addPublicMetadata')
const addSensitiveSpy = vi.spyOn(metadata, 'addSensitiveMetadata')

await extractStoreMetadata(['--shop', 'my-store.myshopify.com'])

expect(addPublicSpy).toHaveBeenCalled()
const publicResult = await addPublicSpy.mock.calls[0]![0]()
expect(publicResult).toHaveProperty('store_fqdn_hash')

expect(addSensitiveSpy).toHaveBeenCalled()
const sensitiveResult = await addSensitiveSpy.mock.calls[0]![0]()
expect(sensitiveResult).toEqual({store_fqdn: 'my-store.myshopify.com'})
})

test('extracts store from -s flag', async () => {
const addSensitiveSpy = vi.spyOn(metadata, 'addSensitiveMetadata')

await extractStoreMetadata(['-s', 'my-store.myshopify.com'])

expect(addSensitiveSpy).toHaveBeenCalled()
const sensitiveResult = await addSensitiveSpy.mock.calls[0]![0]()
expect(sensitiveResult).toEqual({store_fqdn: 'my-store.myshopify.com'})
})

test('extracts store from --shop= syntax', async () => {
const addSensitiveSpy = vi.spyOn(metadata, 'addSensitiveMetadata')

await extractStoreMetadata(['--shop=my-store.myshopify.com'])

expect(addSensitiveSpy).toHaveBeenCalled()
const sensitiveResult = await addSensitiveSpy.mock.calls[0]![0]()
expect(sensitiveResult).toEqual({store_fqdn: 'my-store.myshopify.com'})
})

test('extracts store from SHOPIFY_SHOP env var', async () => {
const originalEnv = process.env.SHOPIFY_SHOP
process.env.SHOPIFY_SHOP = 'env-store.myshopify.com'

const addSensitiveSpy = vi.spyOn(metadata, 'addSensitiveMetadata')

await extractStoreMetadata([])

expect(addSensitiveSpy).toHaveBeenCalled()
const sensitiveResult = await addSensitiveSpy.mock.calls[0]![0]()
expect(sensitiveResult).toEqual({store_fqdn: 'env-store.myshopify.com'})

process.env.SHOPIFY_SHOP = originalEnv
})

test('does nothing when no store is provided', async () => {
const originalEnv = process.env.SHOPIFY_SHOP
delete process.env.SHOPIFY_SHOP

const addPublicSpy = vi.spyOn(metadata, 'addPublicMetadata')
const addSensitiveSpy = vi.spyOn(metadata, 'addSensitiveMetadata')

await extractStoreMetadata([])

expect(addPublicSpy).not.toHaveBeenCalled()
expect(addSensitiveSpy).not.toHaveBeenCalled()

process.env.SHOPIFY_SHOP = originalEnv
})
})
55 changes: 51 additions & 4 deletions packages/cli-kit/src/public/node/hooks/prerun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import Command from '../base-command.js'
import {runAtMinimumInterval} from '../../../private/node/conf-store.js'
import {fetchNotificationsInBackground} from '../notifications-system.js'
import {isPreReleaseVersion} from '../version.js'
import {Hook} from '@oclif/core'
import {reportAnalyticsEvent} from '../analytics.js'
import {postRunHookHasCompleted} from './postrun.js'
import {normalizeStoreFqdn} from '../context/fqdn.js'
import {hashString} from '../crypto.js'
import * as metadata from '../metadata.js'
import {Hook, Interfaces} from '@oclif/core'

export declare interface CommandContent {
command: string
Expand All @@ -25,6 +30,8 @@ export const hook: Hook.Prerun = async (options) => {
await warnOnAvailableUpgrade()
outputDebug(`Running command ${commandContent.command}`)
await startAnalytics({commandContent, args, commandClass: options.Command as unknown as typeof Command})
await extractStoreMetadata(options.argv)
interceptProcessExit(options.config)
fetchNotificationsInBackground(options.Command.id)
}

Expand Down Expand Up @@ -88,9 +95,49 @@ function findAlias(aliases: string[]) {
}
}

/**
* Warns the user if there is a new version of the CLI available
*/
export function interceptProcessExit(config: Interfaces.Config): void {
const originalExit = process.exit.bind(process) as (code?: number) => never
// @ts-expect-error - overriding process.exit signature
process.exit = (code?: number) => {
process.exit = originalExit
if (!postRunHookHasCompleted()) {
process.exitCode = code ?? 0
reportAnalyticsEvent({config, exitMode: code === 0 ? 'ok' : 'unexpected_error'}).finally(() => {
originalExit(code)
})
return
}
originalExit(code)
}
}

export async function extractStoreMetadata(argv: string[]): Promise<void> {
let store: string | undefined
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!
if ((arg === '--shop' || arg === '-s') && argv[i + 1]) {
store = argv[i + 1]
break
}
if (arg.startsWith('--shop=')) {
store = arg.slice('--shop='.length)
break
}
}
if (!store) {
store = process.env.SHOPIFY_SHOP
}
if (!store) return

try {
const storeFqdn = normalizeStoreFqdn(store)
await metadata.addPublicMetadata(() => ({store_fqdn_hash: hashString(storeFqdn)}))
await metadata.addSensitiveMetadata(() => ({store_fqdn: storeFqdn}))
} catch {
// noop - store normalization may fail for invalid values
}
}

export async function warnOnAvailableUpgrade(): Promise<void> {
const cliDependency = '@shopify/cli'
const currentVersion = CLI_KIT_VERSION
Expand Down
Loading