1- import { openURLSafely , renderLinks , createKeypressHandler } from './dev.js'
2- import { describe , expect , test , vi , beforeEach , afterEach } from 'vitest'
1+ import { dev , openURLSafely , renderLinks , createKeypressHandler , reportDevAnalytics } from './dev.js'
2+ import { setupDevServer } from '../utilities/theme-environment/theme-environment.js'
3+ import { hasRequiredThemeDirectories } from '../utilities/theme-fs.js'
4+ import { isStorefrontPasswordProtected } from '../utilities/theme-environment/storefront-session.js'
5+ import { initializeDevServerSession } from '../utilities/theme-environment/dev-server-session.js'
6+ import { describe , expect , test , vi , beforeEach , afterEach , type MockInstance } from 'vitest'
37import { buildTheme } from '@shopify/cli-kit/node/themes/factories'
48import { DEVELOPMENT_THEME_ROLE } from '@shopify/cli-kit/node/themes/utils'
59import { renderSuccess , renderWarning } from '@shopify/cli-kit/node/ui'
610import { openURL } from '@shopify/cli-kit/node/system'
11+ import { reportAnalyticsEvent } from '@shopify/cli-kit/node/analytics'
12+ import { addPublicMetadata , addSensitiveMetadata } from '@shopify/cli-kit/node/metadata'
13+ import { getAvailableTCPPort , checkPortAvailability } from '@shopify/cli-kit/node/tcp'
14+ import { Config } from '@oclif/core'
715
816vi . mock ( '@shopify/cli-kit/node/ui' )
917vi . mock ( '@shopify/cli-kit/node/colors' , ( ) => ( {
@@ -16,6 +24,36 @@ vi.mock('@shopify/cli-kit/node/colors', () => ({
1624vi . mock ( '@shopify/cli-kit/node/system' , ( ) => ( {
1725 openURL : vi . fn ( ) ,
1826} ) )
27+ vi . mock ( '@shopify/cli-kit/node/analytics' , ( ) => ( {
28+ reportAnalyticsEvent : vi . fn ( ) ,
29+ } ) )
30+ vi . mock ( '@shopify/cli-kit/node/metadata' , ( ) => ( {
31+ addPublicMetadata : vi . fn ( ) ,
32+ addSensitiveMetadata : vi . fn ( ) ,
33+ } ) )
34+ vi . mock ( '@shopify/cli-kit/node/tcp' , ( ) => ( {
35+ getAvailableTCPPort : vi . fn ( ) ,
36+ checkPortAvailability : vi . fn ( ) ,
37+ } ) )
38+ vi . mock ( '../utilities/theme-environment/theme-environment.js' , ( ) => ( {
39+ setupDevServer : vi . fn ( ) ,
40+ } ) )
41+ vi . mock ( '../utilities/theme-fs.js' , ( ) => ( {
42+ hasRequiredThemeDirectories : vi . fn ( ) ,
43+ mountThemeFileSystem : vi . fn ( ) . mockReturnValue ( { } ) ,
44+ } ) )
45+ vi . mock ( '../utilities/theme-fs-empty.js' , ( ) => ( {
46+ emptyThemeExtFileSystem : vi . fn ( ) . mockReturnValue ( { } ) ,
47+ } ) )
48+ vi . mock ( '../utilities/theme-environment/storefront-session.js' , ( ) => ( {
49+ isStorefrontPasswordProtected : vi . fn ( ) ,
50+ } ) )
51+ vi . mock ( '../utilities/theme-environment/storefront-password-prompt.js' , ( ) => ( {
52+ ensureValidPassword : vi . fn ( ) ,
53+ } ) )
54+ vi . mock ( '../utilities/theme-environment/dev-server-session.js' , ( ) => ( {
55+ initializeDevServerSession : vi . fn ( ) ,
56+ } ) )
1957
2058const store = 'my-store.myshopify.com'
2159const theme = buildTheme ( { id : 123 , name : 'My Theme' , role : DEVELOPMENT_THEME_ROLE } ) !
@@ -124,7 +162,7 @@ describe('createKeypressHandler', () => {
124162
125163 test ( 'opens localhost when "t" is pressed' , ( ) => {
126164 // Given
127- const handler = createKeypressHandler ( urls , ctx )
165+ const handler = createKeypressHandler ( urls , ctx , vi . fn ( ) )
128166
129167 // When
130168 handler ( 't' , { name : 't' } )
@@ -135,7 +173,7 @@ describe('createKeypressHandler', () => {
135173
136174 test ( 'opens theme preview when "p" is pressed' , ( ) => {
137175 // Given
138- const handler = createKeypressHandler ( urls , ctx )
176+ const handler = createKeypressHandler ( urls , ctx , vi . fn ( ) )
139177
140178 // When
141179 handler ( 'p' , { name : 'p' } )
@@ -146,7 +184,7 @@ describe('createKeypressHandler', () => {
146184
147185 test ( 'opens theme editor when "e" is pressed' , ( ) => {
148186 // Given
149- const handler = createKeypressHandler ( urls , ctx )
187+ const handler = createKeypressHandler ( urls , ctx , vi . fn ( ) )
150188
151189 // When
152190 handler ( 'e' , { name : 'e' } )
@@ -157,7 +195,7 @@ describe('createKeypressHandler', () => {
157195
158196 test ( 'opens gift card preview when "g" is pressed' , ( ) => {
159197 // Given
160- const handler = createKeypressHandler ( urls , ctx )
198+ const handler = createKeypressHandler ( urls , ctx , vi . fn ( ) )
161199
162200 // When
163201 handler ( 'g' , { name : 'g' } )
@@ -169,7 +207,7 @@ describe('createKeypressHandler', () => {
169207 test ( 'appends preview path to theme editor URL when lastRequestedPath is not "/"' , ( ) => {
170208 // Given
171209 const ctxWithPath = { lastRequestedPath : '/products/test-product' }
172- const handler = createKeypressHandler ( urls , ctxWithPath )
210+ const handler = createKeypressHandler ( urls , ctxWithPath , vi . fn ( ) )
173211
174212 // When
175213 handler ( 'e' , { name : 'e' } )
@@ -182,7 +220,7 @@ describe('createKeypressHandler', () => {
182220
183221 test ( 'debounces rapid keypresses - only opens URL once during debounce window' , ( ) => {
184222 // Given
185- const handler = createKeypressHandler ( urls , ctx )
223+ const handler = createKeypressHandler ( urls , ctx , vi . fn ( ) )
186224
187225 // When
188226 handler ( 't' , { name : 't' } )
@@ -197,7 +235,7 @@ describe('createKeypressHandler', () => {
197235
198236 test ( 'allows keypresses after debounce period expires' , ( ) => {
199237 // Given
200- const handler = createKeypressHandler ( urls , ctx )
238+ const handler = createKeypressHandler ( urls , ctx , vi . fn ( ) )
201239
202240 // When
203241 handler ( 't' , { name : 't' } )
@@ -220,7 +258,7 @@ describe('createKeypressHandler', () => {
220258
221259 test ( 'debounces different keys during the same debounce window' , ( ) => {
222260 // Given
223- const handler = createKeypressHandler ( urls , ctx )
261+ const handler = createKeypressHandler ( urls , ctx , vi . fn ( ) )
224262
225263 // When
226264 handler ( 't' , { name : 't' } )
@@ -232,4 +270,105 @@ describe('createKeypressHandler', () => {
232270 expect ( openURL ) . toHaveBeenCalledTimes ( 1 )
233271 expect ( openURL ) . toHaveBeenCalledWith ( urls . local )
234272 } )
273+
274+ } )
275+
276+ describe ( 'dev() Ctrl-C analytics' , ( ) => {
277+ const mockConfig = { } as unknown as Config
278+ const adminSession = { storeFqdn : 'test.myshopify.com' , token : 'x' }
279+
280+ let exitSpy : MockInstance
281+ let resolveBackgroundJob : ( ) => void
282+
283+ const baseOptions = {
284+ adminSession,
285+ commandConfig : mockConfig ,
286+ directory : '/tmp/theme' ,
287+ store : 'test.myshopify.com' ,
288+ open : false ,
289+ theme,
290+ force : false ,
291+ 'theme-editor-sync' : false ,
292+ 'live-reload' : 'hot-reload' as const ,
293+ 'error-overlay' : 'default' as const ,
294+ noDelete : false ,
295+ ignore : [ ] ,
296+ only : [ ] ,
297+ }
298+
299+ beforeEach ( ( ) => {
300+ vi . mocked ( hasRequiredThemeDirectories ) . mockResolvedValue ( true )
301+ vi . mocked ( isStorefrontPasswordProtected ) . mockResolvedValue ( false )
302+ vi . mocked ( initializeDevServerSession ) . mockResolvedValue ( {
303+ storeFqdn : adminSession . storeFqdn ,
304+ token : adminSession . token ,
305+ } as any )
306+ vi . mocked ( getAvailableTCPPort ) . mockResolvedValue ( 9292 )
307+ vi . mocked ( checkPortAvailability ) . mockResolvedValue ( true )
308+
309+ const backgroundJobPromise = new Promise < void > ( ( resolve ) => {
310+ resolveBackgroundJob = resolve
311+ } )
312+ vi . mocked ( setupDevServer ) . mockReturnValue ( {
313+ serverStart : vi . fn ( ) . mockResolvedValue ( undefined ) ,
314+ renderDevSetupProgress : vi . fn ( ) . mockResolvedValue ( undefined ) ,
315+ backgroundJobPromise,
316+ resolveBackgroundJob : resolveBackgroundJob ! ,
317+ dispatchEvent : vi . fn ( ) ,
318+ } as any )
319+
320+ exitSpy = vi . spyOn ( process , 'exit' ) . mockImplementation ( ( ) => undefined as never )
321+ } )
322+
323+ afterEach ( ( ) => {
324+ vi . clearAllMocks ( )
325+ exitSpy . mockRestore ( )
326+ } )
327+
328+ test ( 'reports analytics event exactly once with exitMode=ok and store_fqdn_hash metadata on Ctrl-C' , async ( ) => {
329+ // Given / When
330+ const devPromise = dev ( baseOptions )
331+
332+ // Flush microtasks so the Promise.all is awaiting backgroundJobPromise.
333+ await new Promise ( ( resolve ) => setImmediate ( resolve ) )
334+
335+ // Simulate Ctrl-C by resolving the background job.
336+ resolveBackgroundJob ( )
337+ await devPromise
338+
339+ // Then
340+ expect ( reportAnalyticsEvent ) . toHaveBeenCalledTimes ( 1 )
341+ expect ( reportAnalyticsEvent ) . toHaveBeenCalledWith ( { config : mockConfig , exitMode : 'ok' } )
342+
343+ expect ( addPublicMetadata ) . toHaveBeenCalledTimes ( 1 )
344+ const publicMetadataFn = vi . mocked ( addPublicMetadata ) . mock . calls [ 0 ] ! [ 0 ] as ( ) => Record < string , unknown >
345+ expect ( publicMetadataFn ( ) ) . toEqual ( { store_fqdn_hash : expect . any ( String ) } )
346+
347+ expect ( addSensitiveMetadata ) . toHaveBeenCalledTimes ( 1 )
348+ const sensitiveMetadataFn = vi . mocked ( addSensitiveMetadata ) . mock . calls [ 0 ] ! [ 0 ] as ( ) => Record < string , unknown >
349+ expect ( sensitiveMetadataFn ( ) ) . toEqual ( { store_fqdn : adminSession . storeFqdn } )
350+
351+ expect ( exitSpy ) . toHaveBeenCalledWith ( 0 )
352+
353+ // Order: analytics before exit.
354+ const reportOrder = vi . mocked ( reportAnalyticsEvent ) . mock . invocationCallOrder [ 0 ] !
355+ const exitOrder = exitSpy . mock . invocationCallOrder [ 0 ] !
356+ expect ( reportOrder ) . toBeLessThan ( exitOrder )
357+ } )
358+
359+ test ( 'double-emit guard: reportDevAnalytics called twice only emits once' , async ( ) => {
360+ // Given: run dev() once to set the flag
361+ const devPromise = dev ( baseOptions )
362+ await new Promise ( ( resolve ) => setImmediate ( resolve ) )
363+ resolveBackgroundJob ( )
364+ await devPromise
365+
366+ expect ( reportAnalyticsEvent ) . toHaveBeenCalledTimes ( 1 )
367+
368+ // When: re-invoke reportDevAnalytics directly (simulates a second exit path)
369+ await reportDevAnalytics ( mockConfig , adminSession as any )
370+
371+ // Then: still only one call
372+ expect ( reportAnalyticsEvent ) . toHaveBeenCalledTimes ( 1 )
373+ } )
235374} )
0 commit comments