@@ -810,6 +810,153 @@ describe('file-watcher events', () => {
810810 } )
811811 } )
812812
813+ describe ( 'runtime file discovery' , ( ) => {
814+ test ( 'files added at runtime inside an existing extension trigger file_created' , async ( ) => {
815+ // Given: extension knows about index.js but NOT runtime-added.js
816+ mockExtensionWatchedFiles ( extension1 , [ '/extensions/ui_extension_1/index.js' ] )
817+ mockExtensionWatchedFiles ( extension1B , [ '/extensions/ui_extension_1/index.js' ] )
818+ mockExtensionWatchedFiles ( extension2 , [ ] )
819+ mockExtensionWatchedFiles ( functionExtension , [ ] )
820+ mockExtensionWatchedFiles ( posExtension , [ ] )
821+ mockExtensionWatchedFiles ( appAccessExtension , [ ] )
822+
823+ const testApp = {
824+ ...defaultApp ,
825+ allExtensions : defaultApp . allExtensions ,
826+ nonConfigExtensions : defaultApp . allExtensions . filter ( ( ext ) => ! ext . isAppConfigExtension ) ,
827+ realExtensions : defaultApp . allExtensions ,
828+ }
829+
830+ let eventHandler : any
831+ const mockWatcher = {
832+ on : vi . fn ( ( event : string , listener : any ) => {
833+ if ( event === 'all' ) eventHandler = listener
834+ return mockWatcher
835+ } ) ,
836+ close : vi . fn ( ( ) => Promise . resolve ( ) ) ,
837+ }
838+ vi . spyOn ( chokidar , 'watch' ) . mockReturnValue ( mockWatcher as any )
839+ vi . mocked ( fileExistsSync ) . mockReturnValue ( false )
840+
841+ const fileWatcher = new FileWatcher ( testApp , outputOptions , 50 )
842+ const onChange = vi . fn ( )
843+ fileWatcher . onChange ( onChange )
844+ await fileWatcher . start ( )
845+ await flushPromises ( )
846+
847+ // When: a file the extension didn't pre-register is created on disk
848+ await eventHandler ( 'add' , '/extensions/ui_extension_1/runtime-added.js' , undefined )
849+ await sleep ( 0.15 )
850+
851+ // Then: it's attributed to the owning extensions and emitted
852+ await vi . waitFor (
853+ ( ) => {
854+ const events = onChange . mock . calls . find ( ( call ) => call [ 0 ] . length > 0 ) ?. [ 0 ]
855+ if ( ! events ) throw new Error ( 'no events emitted' )
856+ expect ( events ) . toHaveLength ( 2 )
857+ for ( const event of events ) {
858+ expect ( event . type ) . toBe ( 'file_created' )
859+ expect ( event . path ) . toBe ( '/extensions/ui_extension_1/runtime-added.js' )
860+ expect ( event . extensionPath ) . toBe ( '/extensions/ui_extension_1' )
861+ }
862+ const handles = events . map ( ( event : WatcherEvent ) => event . extensionHandle ) . sort ( )
863+ expect ( handles ) . toEqual ( [ 'h1' , 'h2' ] )
864+ } ,
865+ { timeout : 1000 , interval : 50 } ,
866+ )
867+ } )
868+
869+ test ( 'files added at runtime outside any extension are ignored' , async ( ) => {
870+ mockExtensionWatchedFiles ( extension1 , [ ] )
871+ mockExtensionWatchedFiles ( extension1B , [ ] )
872+ mockExtensionWatchedFiles ( extension2 , [ ] )
873+ mockExtensionWatchedFiles ( functionExtension , [ ] )
874+ mockExtensionWatchedFiles ( posExtension , [ ] )
875+ mockExtensionWatchedFiles ( appAccessExtension , [ ] )
876+
877+ const testApp = {
878+ ...defaultApp ,
879+ allExtensions : defaultApp . allExtensions ,
880+ nonConfigExtensions : defaultApp . allExtensions . filter ( ( ext ) => ! ext . isAppConfigExtension ) ,
881+ realExtensions : defaultApp . allExtensions ,
882+ }
883+
884+ let eventHandler : any
885+ const mockWatcher = {
886+ on : vi . fn ( ( event : string , listener : any ) => {
887+ if ( event === 'all' ) eventHandler = listener
888+ return mockWatcher
889+ } ) ,
890+ close : vi . fn ( ( ) => Promise . resolve ( ) ) ,
891+ }
892+ vi . spyOn ( chokidar , 'watch' ) . mockReturnValue ( mockWatcher as any )
893+ vi . mocked ( fileExistsSync ) . mockReturnValue ( false )
894+
895+ const fileWatcher = new FileWatcher ( testApp , outputOptions , 50 )
896+ const onChange = vi . fn ( )
897+ fileWatcher . onChange ( onChange )
898+ await fileWatcher . start ( )
899+ await flushPromises ( )
900+
901+ await eventHandler ( 'add' , '/some/random/path/file.js' , undefined )
902+ await sleep ( 0.15 )
903+
904+ const hasNonEmptyCall = onChange . mock . calls . some ( ( call ) => call [ 0 ] . length > 0 )
905+ expect ( hasNonEmptyCall ) . toBe ( false )
906+ } )
907+
908+ test ( 'subsequent change/unlink on a runtime-discovered file are not dropped' , async ( ) => {
909+ mockExtensionWatchedFiles ( extension1 , [ '/extensions/ui_extension_1/index.js' ] )
910+ mockExtensionWatchedFiles ( extension1B , [ '/extensions/ui_extension_1/index.js' ] )
911+ mockExtensionWatchedFiles ( extension2 , [ ] )
912+ mockExtensionWatchedFiles ( functionExtension , [ ] )
913+ mockExtensionWatchedFiles ( posExtension , [ ] )
914+ mockExtensionWatchedFiles ( appAccessExtension , [ ] )
915+
916+ const testApp = {
917+ ...defaultApp ,
918+ allExtensions : defaultApp . allExtensions ,
919+ nonConfigExtensions : defaultApp . allExtensions . filter ( ( ext ) => ! ext . isAppConfigExtension ) ,
920+ realExtensions : defaultApp . allExtensions ,
921+ }
922+
923+ let eventHandler : any
924+ const mockWatcher = {
925+ on : vi . fn ( ( event : string , listener : any ) => {
926+ if ( event === 'all' ) eventHandler = listener
927+ return mockWatcher
928+ } ) ,
929+ close : vi . fn ( ( ) => Promise . resolve ( ) ) ,
930+ }
931+ vi . spyOn ( chokidar , 'watch' ) . mockReturnValue ( mockWatcher as any )
932+ vi . mocked ( fileExistsSync ) . mockReturnValue ( false )
933+
934+ const fileWatcher = new FileWatcher ( testApp , outputOptions , 50 )
935+ const onChange = vi . fn ( )
936+ fileWatcher . onChange ( onChange )
937+ await fileWatcher . start ( )
938+ await flushPromises ( )
939+
940+ // Discover the file via 'add'
941+ await eventHandler ( 'add' , '/extensions/ui_extension_1/runtime-added.js' , undefined )
942+ await sleep ( 0.1 )
943+
944+ // Now fire a 'change' on the same path; should produce a file_updated event
945+ onChange . mockClear ( )
946+ await eventHandler ( 'change' , '/extensions/ui_extension_1/runtime-added.js' , undefined )
947+ await sleep ( 0.1 )
948+
949+ await vi . waitFor (
950+ ( ) => {
951+ const events = onChange . mock . calls . find ( ( call ) => call [ 0 ] . length > 0 ) ?. [ 0 ]
952+ if ( ! events ) throw new Error ( 'no change events emitted' )
953+ expect ( events . some ( ( event : WatcherEvent ) => event . type === 'file_updated' ) ) . toBe ( true )
954+ } ,
955+ { timeout : 1000 , interval : 50 } ,
956+ )
957+ } )
958+ } )
959+
813960 describe ( 'refreshWatchedFiles' , ( ) => {
814961 test ( 'closes and recreates the watcher with updated paths' , async ( ) => {
815962 // Given
0 commit comments