@@ -844,4 +844,159 @@ describe('file-watcher events', () => {
844844 expect ( watchedPaths [ 1 ] ) . toEqual ( watchedPaths [ 0 ] )
845845 } )
846846 } )
847+
848+ describe ( 'new file directory containment fallback' , ( ) => {
849+ test ( 'new file under an existing extension dir triggers file_created with the correct extensionHandle' , async ( ) => {
850+ // Given: extension started with no files in assets/ — the new path is not in the snapshot
851+ const ext = await testUIExtension ( {
852+ type : 'ui_extension' ,
853+ handle : 'asset_ext' ,
854+ directory : '/extensions/asset_ext' ,
855+ } )
856+ const newFilePath = '/extensions/asset_ext/assets/dog.jpg'
857+ // At start(), no files are known. After the new file is added, watchedFiles() reflects it
858+ // (representing the live globSync result picking up the new file on disk).
859+ let watchedFilesResult : string [ ] = [ ]
860+ vi . spyOn ( ext , 'watchedFiles' ) . mockImplementation ( ( ) => watchedFilesResult )
861+
862+ const testApp = testAppLinked ( {
863+ allExtensions : [ ext ] ,
864+ directory : '/' ,
865+ configPath : '/shopify.app.toml' ,
866+ configuration : {
867+ ...DEFAULT_CONFIG ,
868+ extension_directories : [ '/extensions' ] ,
869+ } as any ,
870+ } )
871+
872+ let eventHandler : any
873+ const mockWatcher = {
874+ on : vi . fn ( ( event : string , listener : any ) => {
875+ if ( event === 'all' ) eventHandler = listener
876+ return mockWatcher
877+ } ) ,
878+ close : vi . fn ( ) . mockResolvedValue ( undefined ) ,
879+ }
880+ vi . spyOn ( chokidar , 'watch' ) . mockReturnValue ( mockWatcher as any )
881+ vi . mocked ( fileExistsSync ) . mockReturnValue ( false )
882+
883+ const fileWatcher = new FileWatcher ( testApp , outputOptions , 50 )
884+ const onChange = vi . fn ( )
885+ fileWatcher . onChange ( onChange )
886+ await fileWatcher . start ( )
887+ await flushPromises ( )
888+
889+ // Now simulate the new file being on disk and chokidar firing `add`
890+ watchedFilesResult = [ newFilePath ]
891+ await eventHandler ( 'add' , newFilePath , undefined )
892+ await sleep ( 0.15 )
893+
894+ // Then
895+ await vi . waitFor (
896+ ( ) => {
897+ expect ( onChange ) . toHaveBeenCalled ( )
898+ const calls = onChange . mock . calls
899+ const actualEvents = calls . find ( ( call ) => call [ 0 ] . length > 0 ) ?. [ 0 ]
900+ expect ( actualEvents ) . toBeDefined ( )
901+ expect ( actualEvents ) . toHaveLength ( 1 )
902+ expect ( actualEvents [ 0 ] . type ) . toBe ( 'file_created' )
903+ expect ( actualEvents [ 0 ] . path ) . toBe ( normalizePath ( newFilePath ) )
904+ expect ( actualEvents [ 0 ] . extensionHandle ) . toBe ( 'asset_ext' )
905+ expect ( actualEvents [ 0 ] . extensionPath ) . toBe ( normalizePath ( '/extensions/asset_ext' ) )
906+ } ,
907+ { timeout : 1000 , interval : 50 } ,
908+ )
909+ } )
910+
911+ test ( 'new file outside any extension directory is still ignored' , async ( ) => {
912+ // Given
913+ const ext = await testUIExtension ( {
914+ type : 'ui_extension' ,
915+ handle : 'asset_ext' ,
916+ directory : '/extensions/asset_ext' ,
917+ } )
918+ vi . spyOn ( ext , 'watchedFiles' ) . mockReturnValue ( [ ] )
919+
920+ const testApp = testAppLinked ( {
921+ allExtensions : [ ext ] ,
922+ directory : '/' ,
923+ configPath : '/shopify.app.toml' ,
924+ configuration : {
925+ ...DEFAULT_CONFIG ,
926+ extension_directories : [ '/extensions' ] ,
927+ } as any ,
928+ } )
929+
930+ let eventHandler : any
931+ const mockWatcher = {
932+ on : vi . fn ( ( event : string , listener : any ) => {
933+ if ( event === 'all' ) eventHandler = listener
934+ return mockWatcher
935+ } ) ,
936+ close : vi . fn ( ) . mockResolvedValue ( undefined ) ,
937+ }
938+ vi . spyOn ( chokidar , 'watch' ) . mockReturnValue ( mockWatcher as any )
939+ vi . mocked ( fileExistsSync ) . mockReturnValue ( false )
940+
941+ const fileWatcher = new FileWatcher ( testApp , outputOptions , 50 )
942+ const onChange = vi . fn ( )
943+ fileWatcher . onChange ( onChange )
944+ await fileWatcher . start ( )
945+ await flushPromises ( )
946+
947+ // When: a new file is added outside any extension directory
948+ await eventHandler ( 'add' , '/some/unrelated/dir/foo.txt' , undefined )
949+ await sleep ( 0.15 )
950+
951+ // Then: no event should be emitted
952+ const hasNonEmptyCall = onChange . mock . calls . some ( ( call ) => call [ 0 ] . length > 0 )
953+ expect ( hasNonEmptyCall ) . toBe ( false )
954+ } )
955+
956+ test ( 'files under an extension dir but excluded by watchedFiles patterns remain filtered' , async ( ) => {
957+ // Given: the extension's live watchedFiles() never includes the new path
958+ // (representing a path excluded by devSessionWatchConfig.ignore or .gitignore).
959+ const ext = await testUIExtension ( {
960+ type : 'ui_extension' ,
961+ handle : 'asset_ext' ,
962+ directory : '/extensions/asset_ext' ,
963+ } )
964+ vi . spyOn ( ext , 'watchedFiles' ) . mockReturnValue ( [ ] )
965+
966+ const testApp = testAppLinked ( {
967+ allExtensions : [ ext ] ,
968+ directory : '/' ,
969+ configPath : '/shopify.app.toml' ,
970+ configuration : {
971+ ...DEFAULT_CONFIG ,
972+ extension_directories : [ '/extensions' ] ,
973+ } as any ,
974+ } )
975+
976+ let eventHandler : any
977+ const mockWatcher = {
978+ on : vi . fn ( ( event : string , listener : any ) => {
979+ if ( event === 'all' ) eventHandler = listener
980+ return mockWatcher
981+ } ) ,
982+ close : vi . fn ( ) . mockResolvedValue ( undefined ) ,
983+ }
984+ vi . spyOn ( chokidar , 'watch' ) . mockReturnValue ( mockWatcher as any )
985+ vi . mocked ( fileExistsSync ) . mockReturnValue ( false )
986+
987+ const fileWatcher = new FileWatcher ( testApp , outputOptions , 50 )
988+ const onChange = vi . fn ( )
989+ fileWatcher . onChange ( onChange )
990+ await fileWatcher . start ( )
991+ await flushPromises ( )
992+
993+ // When: a new file under the extension dir but not in watchedFiles() is added
994+ await eventHandler ( 'add' , '/extensions/asset_ext/dist/built.js' , undefined )
995+ await sleep ( 0.15 )
996+
997+ // Then: shouldIgnoreEvent must still drop it
998+ const hasNonEmptyCall = onChange . mock . calls . some ( ( call ) => call [ 0 ] . length > 0 )
999+ expect ( hasNonEmptyCall ) . toBe ( false )
1000+ } )
1001+ } )
8471002} )
0 commit comments