@@ -559,3 +559,204 @@ const deleteDefaultEnvironment = async (tmpDir: string): Promise<void> => {
559559 delete clone . environments . default
560560 await writeFile ( joinPath ( tmpDir , 'shopify.environments.toml' ) , encodeTOML ( { environments : clone } as any ) )
561561}
562+
563+ describe ( 'removeDuplicatedPlugins' , ( ) => {
564+ let capturedPlugins : Map < string , any > | undefined
565+ let outputMock : ReturnType < typeof mockAndCaptureOutput >
566+
567+ class PluginTestCommand extends MockCommand {
568+ async init ( ) {
569+ // Set up test plugins before calling super.init()
570+ const initialPlugins = capturedPlugins
571+ if ( initialPlugins ) {
572+ this . config . plugins = new Map ( initialPlugins )
573+ }
574+
575+ const result = await super . init ( )
576+
577+ // Capture the plugins after init (which calls removeDuplicatedPlugins)
578+ // eslint-disable-next-line require-atomic-updates
579+ capturedPlugins = new Map ( this . config . plugins )
580+
581+ return result
582+ }
583+ }
584+
585+ beforeEach ( ( ) => {
586+ capturedPlugins = undefined
587+ outputMock = mockAndCaptureOutput ( )
588+ outputMock . clear ( )
589+ } )
590+
591+ test ( 'removes @shopify/app plugin when present' , async ( ) => {
592+ await inTemporaryDirectory ( async ( tmpDir ) => {
593+ // Given - set up plugins to be injected
594+ const mockPlugin1 = { name : '@shopify/app' , version : '1.0.0' } as any
595+ const mockPlugin2 = { name : '@shopify/plugin-ngrok' , version : '1.0.0' } as any
596+ const mockPlugin3 = { name : '@shopify/plugin-did-you-mean' , version : '1.0.0' } as any
597+
598+ capturedPlugins = new Map ( [
599+ [ '@shopify/app' , mockPlugin1 ] ,
600+ [ '@shopify/plugin-ngrok' , mockPlugin2 ] ,
601+ [ '@shopify/plugin-did-you-mean' , mockPlugin3 ] ,
602+ ] )
603+
604+ // When
605+ await PluginTestCommand . run ( [ '--path' , tmpDir ] )
606+
607+ // Then - verify @shopify/app was removed but others remain
608+ expect ( capturedPlugins . has ( '@shopify/app' ) ) . toBe ( false )
609+ expect ( capturedPlugins . has ( '@shopify/plugin-ngrok' ) ) . toBe ( true )
610+ expect ( capturedPlugins . has ( '@shopify/plugin-did-you-mean' ) ) . toBe ( true )
611+ expect ( capturedPlugins . size ) . toBe ( 2 )
612+
613+ // Verify warning was shown
614+ expect ( outputMock . output ( ) ) . toMatch ( / U n s u p p o r t e d p l u g i n s d e t e c t e d .* @ s h o p i f y \/ a p p / s)
615+ expect ( outputMock . output ( ) ) . toMatch ( / s h o p i f y p l u g i n s r e m o v e @ s h o p i f y \/ a p p / )
616+ expect ( outputMock . output ( ) ) . not . toMatch ( / s h o p i f y p l u g i n s r e m o v e @ s h o p i f y \/ p l u g i n - c l o u d f l a r e / )
617+ } )
618+ } )
619+
620+ test ( 'removes @shopify/plugin-cloudflare plugin when present' , async ( ) => {
621+ await inTemporaryDirectory ( async ( tmpDir ) => {
622+ // Given - set up plugins to be injected
623+ const mockPlugin1 = { name : '@shopify/plugin-cloudflare' , version : '1.0.0' } as any
624+ const mockPlugin2 = { name : '@shopify/plugin-ngrok' , version : '1.0.0' } as any
625+ const mockPlugin3 = { name : '@shopify/plugin-did-you-mean' , version : '1.0.0' } as any
626+
627+ capturedPlugins = new Map ( [
628+ [ '@shopify/plugin-cloudflare' , mockPlugin1 ] ,
629+ [ '@shopify/plugin-ngrok' , mockPlugin2 ] ,
630+ [ '@shopify/plugin-did-you-mean' , mockPlugin3 ] ,
631+ ] )
632+
633+ // When
634+ await PluginTestCommand . run ( [ '--path' , tmpDir ] )
635+
636+ // Then - verify @shopify/plugin-cloudflare was removed but others remain
637+ expect ( capturedPlugins . has ( '@shopify/plugin-cloudflare' ) ) . toBe ( false )
638+ expect ( capturedPlugins . has ( '@shopify/plugin-ngrok' ) ) . toBe ( true )
639+ expect ( capturedPlugins . has ( '@shopify/plugin-did-you-mean' ) ) . toBe ( true )
640+ expect ( capturedPlugins . size ) . toBe ( 2 )
641+
642+ // Verify warning was shown
643+ expect ( outputMock . output ( ) ) . toMatch ( / U n s u p p o r t e d p l u g i n s d e t e c t e d .* @ s h o p i f y \/ p l u g i n - c l o u d f l a r e / s)
644+ expect ( outputMock . output ( ) ) . toMatch ( / s h o p i f y p l u g i n s r e m o v e @ s h o p i f y \/ p l u g i n - c l o u d f l a r e / )
645+ expect ( outputMock . output ( ) ) . not . toMatch ( / s h o p i f y p l u g i n s r e m o v e @ s h o p i f y \/ a p p / )
646+ } )
647+ } )
648+
649+ test ( 'removes both @shopify/app and @shopify/plugin-cloudflare plugins when present' , async ( ) => {
650+ await inTemporaryDirectory ( async ( tmpDir ) => {
651+ // Given - set up plugins to be injected
652+ const mockPlugin1 = { name : '@shopify/app' , version : '1.0.0' } as any
653+ const mockPlugin2 = { name : '@shopify/plugin-cloudflare' , version : '1.0.0' } as any
654+ const mockPlugin3 = { name : '@shopify/plugin-ngrok' , version : '1.0.0' } as any
655+ const mockPlugin4 = { name : '@shopify/plugin-did-you-mean' , version : '1.0.0' } as any
656+
657+ capturedPlugins = new Map ( [
658+ [ '@shopify/app' , mockPlugin1 ] ,
659+ [ '@shopify/plugin-cloudflare' , mockPlugin2 ] ,
660+ [ '@shopify/plugin-ngrok' , mockPlugin3 ] ,
661+ [ '@shopify/plugin-did-you-mean' , mockPlugin4 ] ,
662+ ] )
663+
664+ // When
665+ await PluginTestCommand . run ( [ '--path' , tmpDir ] )
666+
667+ // Then - verify both bundled plugins were removed but others remain
668+ expect ( capturedPlugins . has ( '@shopify/app' ) ) . toBe ( false )
669+ expect ( capturedPlugins . has ( '@shopify/plugin-cloudflare' ) ) . toBe ( false )
670+ expect ( capturedPlugins . has ( '@shopify/plugin-ngrok' ) ) . toBe ( true )
671+ expect ( capturedPlugins . has ( '@shopify/plugin-did-you-mean' ) ) . toBe ( true )
672+ expect ( capturedPlugins . size ) . toBe ( 2 )
673+
674+ // Verify warning was shown with both plugins
675+ expect ( outputMock . output ( ) ) . toMatch ( / U n s u p p o r t e d p l u g i n s d e t e c t e d .* @ s h o p i f y \/ a p p .* @ s h o p i f y \/ p l u g i n - c l o u d f l a r e / s)
676+ expect ( outputMock . output ( ) ) . toMatch ( / s h o p i f y p l u g i n s r e m o v e @ s h o p i f y \/ a p p / )
677+ expect ( outputMock . output ( ) ) . toMatch ( / s h o p i f y p l u g i n s r e m o v e @ s h o p i f y \/ p l u g i n - c l o u d f l a r e / )
678+ } )
679+ } )
680+
681+ test ( 'does not remove any plugins when bundled plugins are not present' , async ( ) => {
682+ await inTemporaryDirectory ( async ( tmpDir ) => {
683+ // Given - set up plugins (none are bundled plugins)
684+ const mockPlugin1 = { name : '@shopify/plugin-ngrok' , version : '1.0.0' } as any
685+ const mockPlugin2 = { name : '@shopify/plugin-did-you-mean' , version : '1.0.0' } as any
686+ const mockPlugin3 = { name : 'some-other-plugin' , version : '1.0.0' } as any
687+
688+ capturedPlugins = new Map ( [
689+ [ '@shopify/plugin-ngrok' , mockPlugin1 ] ,
690+ [ '@shopify/plugin-did-you-mean' , mockPlugin2 ] ,
691+ [ 'some-other-plugin' , mockPlugin3 ] ,
692+ ] )
693+
694+ // When
695+ await PluginTestCommand . run ( [ '--path' , tmpDir ] )
696+
697+ // Then - verify no plugins were removed
698+ expect ( capturedPlugins . size ) . toBe ( 3 )
699+ expect ( capturedPlugins . has ( '@shopify/plugin-ngrok' ) ) . toBe ( true )
700+ expect ( capturedPlugins . has ( '@shopify/plugin-did-you-mean' ) ) . toBe ( true )
701+ expect ( capturedPlugins . has ( 'some-other-plugin' ) ) . toBe ( true )
702+
703+ // Verify no warning was shown
704+ expect ( outputMock . output ( ) ) . toBe ( '' )
705+ } )
706+ } )
707+
708+ test ( 'handles empty plugin map' , async ( ) => {
709+ await inTemporaryDirectory ( async ( tmpDir ) => {
710+ // Given - empty plugins map
711+ capturedPlugins = new Map ( )
712+
713+ // When
714+ await PluginTestCommand . run ( [ '--path' , tmpDir ] )
715+
716+ // Then - verify map is still empty
717+ expect ( capturedPlugins . size ) . toBe ( 0 )
718+
719+ // Verify no warning was shown
720+ expect ( outputMock . output ( ) ) . toBe ( '' )
721+ } )
722+ } )
723+
724+ test ( 'preserves plugin metadata when removing bundled plugins' , async ( ) => {
725+ await inTemporaryDirectory ( async ( tmpDir ) => {
726+ // Given - set up plugins with more complete metadata
727+ const mockPluginApp = {
728+ name : '@shopify/app' ,
729+ version : '1.0.0' ,
730+ type : 'core' ,
731+ root : '/path/to/app' ,
732+ } as any
733+ const mockPluginTheme = {
734+ name : '@shopify/plugin-ngrok' ,
735+ version : '2.0.0' ,
736+ type : 'user' ,
737+ root : '/path/to/theme' ,
738+ } as any
739+
740+ capturedPlugins = new Map ( [
741+ [ '@shopify/app' , mockPluginApp ] ,
742+ [ '@shopify/plugin-ngrok' , mockPluginTheme ] ,
743+ ] )
744+
745+ // When
746+ await PluginTestCommand . run ( [ '--path' , tmpDir ] )
747+
748+ // Then - verify @shopify/app was removed but theme plugin remains with all its metadata
749+ expect ( capturedPlugins . has ( '@shopify/app' ) ) . toBe ( false )
750+ expect ( capturedPlugins . has ( '@shopify/plugin-ngrok' ) ) . toBe ( true )
751+ const remainingPlugin = capturedPlugins . get ( '@shopify/plugin-ngrok' )
752+ expect ( remainingPlugin ) . toEqual ( mockPluginTheme )
753+ expect ( remainingPlugin . version ) . toBe ( '2.0.0' )
754+ expect ( remainingPlugin . type ) . toBe ( 'user' )
755+ expect ( remainingPlugin . root ) . toBe ( '/path/to/theme' )
756+
757+ // Verify warning was shown
758+ expect ( outputMock . output ( ) ) . toMatch ( / U n s u p p o r t e d p l u g i n s d e t e c t e d .* @ s h o p i f y \/ a p p / s)
759+ expect ( outputMock . output ( ) ) . toMatch ( / s h o p i f y p l u g i n s r e m o v e @ s h o p i f y \/ a p p / )
760+ } )
761+ } )
762+ } )
0 commit comments