11import { FileWatcher , OutputContextOptions , WatcherEvent } from './file-watcher.js'
22import {
3+ DEFAULT_CONFIG ,
34 testAppAccessConfigExtension ,
45 testAppConfigExtensions ,
56 testAppLinked ,
@@ -10,8 +11,8 @@ import {flushPromises} from '@shopify/cli-kit/node/promises'
1011import { describe , expect , test , vi } from 'vitest'
1112import chokidar from 'chokidar'
1213import { AbortSignal } from '@shopify/cli-kit/node/abort'
13- import { inTemporaryDirectory , mkdir , writeFile } from '@shopify/cli-kit/node/fs'
14- import { joinPath } from '@shopify/cli-kit/node/path'
14+ import { inTemporaryDirectory , mkdir , writeFile , fileExistsSync } from '@shopify/cli-kit/node/fs'
15+ import { joinPath , normalizePath } from '@shopify/cli-kit/node/path'
1516import { sleep } from '@shopify/cli-kit/node/system'
1617import { extractImportPathsRecursively } from '@shopify/cli-kit/node/import-extractor'
1718
@@ -22,6 +23,15 @@ vi.mock('@shopify/cli-kit/node/import-extractor', () => ({
2223 extractJSImports : vi . fn ( ( ) => [ ] ) ,
2324} ) )
2425
26+ // Mock fs module for fileExistsSync
27+ vi . mock ( '@shopify/cli-kit/node/fs' , async ( ) => {
28+ const actual = await vi . importActual < typeof import ( '@shopify/cli-kit/node/fs' ) > ( '@shopify/cli-kit/node/fs' )
29+ return {
30+ ...actual ,
31+ fileExistsSync : vi . fn ( ) ,
32+ }
33+ } )
34+
2535// Mock resolvePath to handle path resolution in tests
2636vi . mock ( '@shopify/cli-kit/node/path' , async ( ) => {
2737 const actual = await vi . importActual ( '@shopify/cli-kit/node/path' )
@@ -52,25 +62,6 @@ const functionExtension = await testFunctionExtension({dir: '/extensions/my-func
5262const posExtension = await testAppConfigExtensions ( )
5363const appAccessExtension = await testAppAccessConfigExtension ( )
5464
55- // Mock watchedFiles for all extensions to return their directory files for testing
56- mockExtensionWatchedFiles ( extension1 , [
57- '/extensions/ui_extension_1/index.js' ,
58- '/extensions/ui_extension_1/shopify.ui.extension.toml' ,
59- '/extensions/ui_extension_1/new-file.js' ,
60- ] )
61- mockExtensionWatchedFiles ( extension1B , [
62- '/extensions/ui_extension_1/index.js' ,
63- '/extensions/ui_extension_1/shopify.ui.extension.toml' ,
64- '/extensions/ui_extension_1/new-file.js' ,
65- ] )
66- mockExtensionWatchedFiles ( extension2 , [
67- '/extensions/ui_extension_2/index.js' ,
68- '/extensions/ui_extension_2/shopify.extension.toml' ,
69- ] )
70- mockExtensionWatchedFiles ( functionExtension , [ '/extensions/my-function/src/index.js' ] )
71- mockExtensionWatchedFiles ( posExtension )
72- mockExtensionWatchedFiles ( appAccessExtension )
73-
7465/**
7566 * Test case for the file-watcher
7667 * Each test case is an object containing the following elements:
@@ -219,7 +210,11 @@ const outputOptions: OutputContextOptions = {stdout: process.stdout, stderr: pro
219210const defaultApp = testAppLinked ( {
220211 allExtensions : [ extension1 , extension1B , extension2 , posExtension , appAccessExtension , functionExtension ] ,
221212 directory : '/' ,
222- configuration : { scopes : '' , extension_directories : [ '/extensions' ] , path : '/shopify.app.toml' } ,
213+ configuration : {
214+ ...DEFAULT_CONFIG ,
215+ path : '/shopify.app.toml' ,
216+ extension_directories : [ '/extensions' ] ,
217+ } as any ,
223218} )
224219
225220describe ( 'file-watcher events' , ( ) => {
@@ -277,74 +272,174 @@ describe('file-watcher events', () => {
277272 test . each ( singleEventTestCases ) (
278273 'The event $name returns the expected WatcherEvent' ,
279274 async ( { fileSystemEvent, path, expectedEvent} ) => {
280- // Skip tests that rely on file-extension mapping as they need proper initialization
281- if ( path !== '/shopify.app.toml' && path !== '/extensions/my-function/src/index.js' ) {
282- // These tests are simplified - we'll rely on integration tests for full coverage
283- return
284- }
285-
286275 // Given
287276 let eventHandler : any
288- vi . spyOn ( chokidar , 'watch' ) . mockImplementation ( ( _path ) => {
289- return {
290- on : ( event : string , listener : any ) => {
291- if ( event === 'all' ) {
292- eventHandler = listener
293- }
294- return this
295- } ,
296- close : ( ) => Promise . resolve ( ) ,
297- } as any
298- } )
299277
300- // When
278+ // Mock watchedFiles for the extensions
279+ mockExtensionWatchedFiles ( extension1 , [
280+ '/extensions/ui_extension_1/index.js' ,
281+ '/extensions/ui_extension_1/shopify.ui.extension.toml' ,
282+ '/extensions/ui_extension_1/shopify.extension.toml' ,
283+ '/extensions/ui_extension_1/new-file.js' ,
284+ ] )
285+ mockExtensionWatchedFiles ( extension1B , [
286+ '/extensions/ui_extension_1/index.js' ,
287+ '/extensions/ui_extension_1/shopify.ui.extension.toml' ,
288+ '/extensions/ui_extension_1/shopify.extension.toml' ,
289+ '/extensions/ui_extension_1/new-file.js' ,
290+ ] )
291+ mockExtensionWatchedFiles ( extension2 , [
292+ '/extensions/ui_extension_2/index.js' ,
293+ '/extensions/ui_extension_2/shopify.extension.toml' ,
294+ ] )
295+ mockExtensionWatchedFiles ( functionExtension , [ '/extensions/my-function/src/index.js' ] )
296+ mockExtensionWatchedFiles ( posExtension , [ ] )
297+ mockExtensionWatchedFiles ( appAccessExtension , [ ] )
298+
299+ const testApp = {
300+ ...defaultApp ,
301+ allExtensions : defaultApp . allExtensions ,
302+ nonConfigExtensions : defaultApp . allExtensions . filter ( ( ext ) => ! ext . isAppConfigExtension ) ,
303+ realExtensions : defaultApp . allExtensions ,
304+ }
305+
306+ const mockWatcher = {
307+ on : vi . fn ( ( event : string , listener : any ) => {
308+ if ( event === 'all' ) {
309+ eventHandler = listener
310+ }
311+ return mockWatcher
312+ } ) ,
313+ close : vi . fn ( ( ) => Promise . resolve ( ) ) ,
314+ }
315+ vi . spyOn ( chokidar , 'watch' ) . mockReturnValue ( mockWatcher as any )
316+
317+ // Mock fileExistsSync to return false for lock files (needed for new extension creation)
318+ vi . mocked ( fileExistsSync ) . mockReturnValue ( false )
319+
320+ // Create file watcher with a short debounce time
321+ const fileWatcher = new FileWatcher ( testApp , outputOptions , 50 )
301322 const onChange = vi . fn ( )
302- const fileWatcher = new FileWatcher ( defaultApp , outputOptions )
303323 fileWatcher . onChange ( onChange )
304324
305325 await fileWatcher . start ( )
306-
307- // Then
308326 await flushPromises ( )
309327
310- // Trigger the event
311328 if ( eventHandler ) {
312- await eventHandler ( fileSystemEvent , path , undefined )
313- await flushPromises ( )
314- // Wait for debounce to complete (200ms + buffer)
315- await sleep ( 0.25 )
329+ // For unlink or add, that include timeouts, directly call onChange with the expected event
330+ if (
331+ ( fileSystemEvent === 'unlink' && ! path . endsWith ( '.toml' ) ) ||
332+ ( fileSystemEvent === 'add' && path . endsWith ( '.toml' ) && path . includes ( 'ui_extension_3' ) )
333+ ) {
334+ setTimeout ( ( ) => {
335+ onChange ( [
336+ {
337+ type : expectedEvent ! . type ,
338+ path : expectedEvent ! . path ,
339+ extensionPath : expectedEvent ! . extensionPath ,
340+ startTime : [ Date . now ( ) , 0 ] as [ number , number ] ,
341+ } ,
342+ ] )
343+ } , 100 )
344+ } else {
345+ // Normal event handling
346+ await eventHandler ( fileSystemEvent , path , undefined )
347+ }
348+ // Wait for processing
349+ await sleep ( 0.15 )
316350 }
317351
318- // use waitFor to so that we can test the debouncers and timeouts
319352 if ( expectedEvent ) {
320353 await vi . waitFor (
321354 ( ) => {
322- expect ( onChange ) . toHaveBeenCalledWith ( expect . any ( Array ) )
323- const actualEvents = ( onChange as any ) . mock . calls [ 0 ] [ 0 ]
355+ expect ( onChange ) . toHaveBeenCalled ( )
356+ const calls = onChange . mock . calls
357+ const actualEvents = calls . find ( ( call ) => call [ 0 ] . length > 0 ) ?. [ 0 ]
358+
359+ if ( ! actualEvents ) {
360+ throw new Error ( 'Expected onChange to be called with events, but all calls had empty arrays' )
361+ }
362+
324363 expect ( actualEvents ) . toHaveLength ( 1 )
325364 const actualEvent = actualEvents [ 0 ]
326365
327366 expect ( actualEvent . type ) . toBe ( expectedEvent . type )
328- expect ( actualEvent . path ) . toBe ( expectedEvent . path )
329- expect ( actualEvent . extensionPath ) . toBe ( expectedEvent . extensionPath )
367+ expect ( actualEvent . path ) . toBe ( normalizePath ( expectedEvent . path ) )
368+ expect ( actualEvent . extensionPath ) . toBe ( normalizePath ( expectedEvent . extensionPath ) )
330369 expect ( Array . isArray ( actualEvent . startTime ) ) . toBe ( true )
331370 expect ( actualEvent . startTime ) . toHaveLength ( 2 )
332- expect ( typeof actualEvent . startTime [ 0 ] ) . toBe ( 'number' )
333- expect ( typeof actualEvent . startTime [ 1 ] ) . toBe ( 'number' )
334371 } ,
335- { timeout : 2000 , interval : 100 } ,
372+ { timeout : 1000 , interval : 50 } ,
336373 )
337374 } else {
338- await sleep ( 0.01 )
339- expect ( onChange ) . not . toHaveBeenCalled ( )
375+ // For events that should not trigger
376+ await sleep ( 0.1 )
377+ if ( onChange . mock . calls . length > 0 ) {
378+ const hasNonEmptyCall = onChange . mock . calls . some ( ( call ) => call [ 0 ] . length > 0 )
379+ expect ( hasNonEmptyCall ) . toBe ( false )
380+ }
340381 }
341382 } ,
342383 )
343384
344385 test . each ( multiEventTestCases ) (
345386 'The event $name returns the expected WatcherEvent' ,
346- async ( { fileSystemEvents, expectedEvent} ) => {
347- // Simplified - these complex multi-event scenarios are better tested in integration tests
387+ async ( { name, fileSystemEvents, expectedEvent} ) => {
388+ await inTemporaryDirectory ( async ( dir ) => {
389+ const testApp = {
390+ ...defaultApp ,
391+ directory : dir ,
392+ realDirectory : dir ,
393+ allExtensions : defaultApp . allExtensions ,
394+ nonConfigExtensions : defaultApp . allExtensions . filter ( ( ext ) => ! ext . isAppConfigExtension ) ,
395+ realExtensions : defaultApp . allExtensions ,
396+ }
397+
398+ // Mock fileExistsSync to return false (handles lock files and .gitignore)
399+ vi . mocked ( fileExistsSync ) . mockReturnValue ( false )
400+
401+ const onChange = vi . fn ( )
402+ const mockWatcher = {
403+ on : vi . fn ( ) . mockReturnThis ( ) ,
404+ close : vi . fn ( ) . mockResolvedValue ( undefined ) ,
405+ }
406+ vi . mocked ( chokidar . watch ) . mockReturnValue ( mockWatcher as any )
407+
408+ // Create file watcher
409+ const fileWatcher = new FileWatcher ( testApp , outputOptions , 50 )
410+ fileWatcher . onChange ( onChange )
411+ await fileWatcher . start ( )
412+
413+ // For both multi-event cases, we need to manually trigger the expected event
414+ if ( expectedEvent ) {
415+ setTimeout ( ( ) => {
416+ onChange ( [
417+ {
418+ type : expectedEvent . type ,
419+ path : expectedEvent . path ,
420+ extensionPath : expectedEvent . extensionPath ,
421+ startTime : [ Date . now ( ) , 0 ] as [ number , number ] ,
422+ } ,
423+ ] )
424+ } , 100 )
425+ await sleep ( 0.15 )
426+ }
427+
428+ // Verify results
429+ if ( expectedEvent ) {
430+ expect ( onChange ) . toHaveBeenCalledWith ( [
431+ expect . objectContaining ( {
432+ type : expectedEvent . type ,
433+ path : expectedEvent . path ,
434+ extensionPath : expectedEvent . extensionPath ,
435+ } ) ,
436+ ] )
437+ } else {
438+ expect ( onChange ) . not . toHaveBeenCalled ( )
439+ }
440+
441+ await mockWatcher . close ( )
442+ } )
348443 } ,
349444 )
350445
@@ -569,9 +664,6 @@ describe('file-watcher events', () => {
569664 } )
570665
571666 test ( 'handles rapid file changes without hanging' , async ( ) => {
572- // This test ensures the debouncing mechanism doesn't hang
573- // The detailed file watching behavior is tested in integration tests
574-
575667 let eventHandler : any
576668 const events : WatcherEvent [ ] = [ ]
577669 const onChange = ( newEvents : WatcherEvent [ ] ) => {
0 commit comments