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,33 @@ 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/dev-server-session.js' , ( ) => ( {
52+ initializeDevServerSession : vi . fn ( ) ,
53+ } ) )
1954
2055const store = 'my-store.myshopify.com'
2156const theme = buildTheme ( { id : 123 , name : 'My Theme' , role : DEVELOPMENT_THEME_ROLE } ) !
@@ -124,7 +159,7 @@ describe('createKeypressHandler', () => {
124159
125160 test ( 'opens localhost when "t" is pressed' , ( ) => {
126161 // Given
127- const handler = createKeypressHandler ( urls , ctx )
162+ const handler = createKeypressHandler ( urls , ctx , vi . fn ( ) )
128163
129164 // When
130165 handler ( 't' , { name : 't' } )
@@ -135,7 +170,7 @@ describe('createKeypressHandler', () => {
135170
136171 test ( 'opens theme preview when "p" is pressed' , ( ) => {
137172 // Given
138- const handler = createKeypressHandler ( urls , ctx )
173+ const handler = createKeypressHandler ( urls , ctx , vi . fn ( ) )
139174
140175 // When
141176 handler ( 'p' , { name : 'p' } )
@@ -146,7 +181,7 @@ describe('createKeypressHandler', () => {
146181
147182 test ( 'opens theme editor when "e" is pressed' , ( ) => {
148183 // Given
149- const handler = createKeypressHandler ( urls , ctx )
184+ const handler = createKeypressHandler ( urls , ctx , vi . fn ( ) )
150185
151186 // When
152187 handler ( 'e' , { name : 'e' } )
@@ -157,7 +192,7 @@ describe('createKeypressHandler', () => {
157192
158193 test ( 'opens gift card preview when "g" is pressed' , ( ) => {
159194 // Given
160- const handler = createKeypressHandler ( urls , ctx )
195+ const handler = createKeypressHandler ( urls , ctx , vi . fn ( ) )
161196
162197 // When
163198 handler ( 'g' , { name : 'g' } )
@@ -169,7 +204,7 @@ describe('createKeypressHandler', () => {
169204 test ( 'appends preview path to theme editor URL when lastRequestedPath is not "/"' , ( ) => {
170205 // Given
171206 const ctxWithPath = { lastRequestedPath : '/products/test-product' }
172- const handler = createKeypressHandler ( urls , ctxWithPath )
207+ const handler = createKeypressHandler ( urls , ctxWithPath , vi . fn ( ) )
173208
174209 // When
175210 handler ( 'e' , { name : 'e' } )
@@ -182,7 +217,7 @@ describe('createKeypressHandler', () => {
182217
183218 test ( 'debounces rapid keypresses - only opens URL once during debounce window' , ( ) => {
184219 // Given
185- const handler = createKeypressHandler ( urls , ctx )
220+ const handler = createKeypressHandler ( urls , ctx , vi . fn ( ) )
186221
187222 // When
188223 handler ( 't' , { name : 't' } )
@@ -197,7 +232,7 @@ describe('createKeypressHandler', () => {
197232
198233 test ( 'allows keypresses after debounce period expires' , ( ) => {
199234 // Given
200- const handler = createKeypressHandler ( urls , ctx )
235+ const handler = createKeypressHandler ( urls , ctx , vi . fn ( ) )
201236
202237 // When
203238 handler ( 't' , { name : 't' } )
@@ -220,7 +255,7 @@ describe('createKeypressHandler', () => {
220255
221256 test ( 'debounces different keys during the same debounce window' , ( ) => {
222257 // Given
223- const handler = createKeypressHandler ( urls , ctx )
258+ const handler = createKeypressHandler ( urls , ctx , vi . fn ( ) )
224259
225260 // When
226261 handler ( 't' , { name : 't' } )
@@ -232,4 +267,90 @@ describe('createKeypressHandler', () => {
232267 expect ( openURL ) . toHaveBeenCalledTimes ( 1 )
233268 expect ( openURL ) . toHaveBeenCalledWith ( urls . local )
234269 } )
270+
271+ } )
272+
273+ describe ( 'dev() Ctrl-C analytics' , ( ) => {
274+ const mockConfig = { } as unknown as Config
275+ const adminSession = { storeFqdn : 'test.myshopify.com' , token : 'x' }
276+
277+ let exitSpy : MockInstance
278+ let resolveBackgroundJob : ( ) => void
279+
280+ const baseOptions = {
281+ adminSession,
282+ commandConfig : mockConfig ,
283+ directory : '/tmp/theme' ,
284+ store : 'test.myshopify.com' ,
285+ open : false ,
286+ theme,
287+ force : false ,
288+ 'theme-editor-sync' : false ,
289+ 'live-reload' : 'hot-reload' as const ,
290+ 'error-overlay' : 'default' as const ,
291+ noDelete : false ,
292+ ignore : [ ] ,
293+ only : [ ] ,
294+ }
295+
296+ beforeEach ( ( ) => {
297+ vi . mocked ( hasRequiredThemeDirectories ) . mockResolvedValue ( true )
298+ vi . mocked ( isStorefrontPasswordProtected ) . mockResolvedValue ( false )
299+ vi . mocked ( initializeDevServerSession ) . mockResolvedValue ( {
300+ storeFqdn : adminSession . storeFqdn ,
301+ token : adminSession . token ,
302+ } as any )
303+ vi . mocked ( getAvailableTCPPort ) . mockResolvedValue ( 9292 )
304+ vi . mocked ( checkPortAvailability ) . mockResolvedValue ( true )
305+
306+ const backgroundJobPromise = new Promise < void > ( ( resolve ) => {
307+ resolveBackgroundJob = resolve
308+ } )
309+ vi . mocked ( setupDevServer ) . mockReturnValue ( {
310+ serverStart : vi . fn ( ) . mockResolvedValue ( undefined ) ,
311+ renderDevSetupProgress : vi . fn ( ) . mockResolvedValue ( undefined ) ,
312+ backgroundJobPromise,
313+ resolveBackgroundJob : resolveBackgroundJob ! ,
314+ dispatchEvent : vi . fn ( ) ,
315+ } as any )
316+
317+ exitSpy = vi . spyOn ( process , 'exit' ) . mockImplementation ( ( ) => undefined as never )
318+ } )
319+
320+ afterEach ( ( ) => {
321+ vi . clearAllMocks ( )
322+ exitSpy . mockRestore ( )
323+ } )
324+
325+ test ( 'Ctrl-C path: reports analytics once, even if reportDevAnalytics is invoked again' , async ( ) => {
326+ const devPromise = dev ( baseOptions )
327+
328+ // Flush microtasks so the Promise.all is awaiting backgroundJobPromise.
329+ await new Promise ( ( resolve ) => setImmediate ( resolve ) )
330+
331+ // Simulate Ctrl-C by resolving the background job.
332+ resolveBackgroundJob ( )
333+ await devPromise
334+
335+ expect ( reportAnalyticsEvent ) . toHaveBeenCalledTimes ( 1 )
336+ expect ( reportAnalyticsEvent ) . toHaveBeenCalledWith ( { config : mockConfig , exitMode : 'ok' } )
337+
338+ expect ( addPublicMetadata ) . toHaveBeenCalledTimes ( 1 )
339+ const publicMetadataFn = vi . mocked ( addPublicMetadata ) . mock . calls [ 0 ] ! [ 0 ] as ( ) => Record < string , unknown >
340+ expect ( publicMetadataFn ( ) ) . toEqual ( { store_fqdn_hash : expect . any ( String ) } )
341+
342+ expect ( addSensitiveMetadata ) . toHaveBeenCalledTimes ( 1 )
343+ const sensitiveMetadataFn = vi . mocked ( addSensitiveMetadata ) . mock . calls [ 0 ] ! [ 0 ] as ( ) => Record < string , unknown >
344+ expect ( sensitiveMetadataFn ( ) ) . toEqual ( { store_fqdn : adminSession . storeFqdn } )
345+
346+ expect ( exitSpy ) . toHaveBeenCalledWith ( 0 )
347+
348+ const reportOrder = vi . mocked ( reportAnalyticsEvent ) . mock . invocationCallOrder [ 0 ] !
349+ const exitOrder = exitSpy . mock . invocationCallOrder [ 0 ] !
350+ expect ( reportOrder ) . toBeLessThan ( exitOrder )
351+
352+ await reportDevAnalytics ( mockConfig , adminSession as any )
353+
354+ expect ( reportAnalyticsEvent ) . toHaveBeenCalledTimes ( 1 )
355+ } )
235356} )
0 commit comments