diff --git a/src/components/delete-site.tsx b/src/components/delete-site.tsx index cc6576fda..8f5aded94 100644 --- a/src/components/delete-site.tsx +++ b/src/components/delete-site.tsx @@ -1,11 +1,8 @@ -import * as Sentry from '@sentry/electron/renderer'; import { MenuItem } from '@wordpress/components'; -import { __, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; +import { useDeleteSite } from 'src/hooks/use-delete-site'; import { useSiteDetails } from 'src/hooks/use-site-details'; -import { getIpcApi } from 'src/lib/get-ipc-api'; - -const MAX_LENGTH_SITE_TITLE = 35; type DeleteSiteProps = { onClose: () => void; @@ -13,51 +10,8 @@ type DeleteSiteProps = { const DeleteSite = ( { onClose }: DeleteSiteProps ) => { const { __ } = useI18n(); - const { selectedSite, deleteSite, isDeleting } = useSiteDetails(); - - const handleDeleteSite = async () => { - if ( ! selectedSite ) { - return; - } - - const DELETE_BUTTON_INDEX = 0; - const CANCEL_BUTTON_INDEX = 1; - - const trimmedSiteTitle = getTrimmedSiteTitle( selectedSite.name ); - - const { response, checkboxChecked } = await getIpcApi().showMessageBox( { - type: 'warning', - message: sprintf( __( 'Delete %s' ), trimmedSiteTitle ), - detail: __( - 'The site’s database will be lost. Including all posts, pages, comments, and media.' - ), - buttons: [ __( 'Delete site' ), __( 'Cancel' ) ], - cancelId: CANCEL_BUTTON_INDEX, - checkboxLabel: __( 'Delete site files from my computer' ), - checkboxChecked: true, - } ); - - if ( response === DELETE_BUTTON_INDEX ) { - try { - await deleteSite( selectedSite.id, checkboxChecked ); - } catch ( error ) { - getIpcApi().showErrorMessageBox( { - title: __( 'Deletion failed' ), - message: sprintf( - __( "We couldn't delete the site '%s'. Please try again" ), - trimmedSiteTitle - ), - error, - } ); - Sentry.captureException( error ); - } - } - }; - - const getTrimmedSiteTitle = ( name: string ) => - name.length > MAX_LENGTH_SITE_TITLE - ? `${ name.substring( 0, MAX_LENGTH_SITE_TITLE - 3 ) }…` - : name; + const { selectedSite, isDeleting } = useSiteDetails(); + const { handleDeleteSite } = useDeleteSite(); const isSiteDeletionDisabled = ! selectedSite || isDeleting; @@ -65,11 +19,11 @@ const DeleteSite = ( { onClose }: DeleteSiteProps ) => { { - if ( isSiteDeletionDisabled ) { + if ( isSiteDeletionDisabled || ! selectedSite ) { return; } onClose(); - void handleDeleteSite(); + void handleDeleteSite( selectedSite.id, selectedSite.name ); } } isDestructive disabled={ isSiteDeletionDisabled } diff --git a/src/components/site-menu.tsx b/src/components/site-menu.tsx index fef34e0b9..33b60679f 100644 --- a/src/components/site-menu.tsx +++ b/src/components/site-menu.tsx @@ -1,13 +1,20 @@ +import * as Sentry from '@sentry/electron/renderer'; import { speak } from '@wordpress/a11y'; import { Spinner } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { useEffect } from 'react'; import { Tooltip } from 'src/components/tooltip'; import { useSyncSites } from 'src/hooks/sync-sites'; +import { useContentTabs } from 'src/hooks/use-content-tabs'; +import { useDeleteSite } from 'src/hooks/use-delete-site'; import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; -import { isMac } from 'src/lib/app-globals'; +import { isMac, isWindows } from 'src/lib/app-globals'; import { cx } from 'src/lib/cx'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { supportedEditorConfig } from 'src/modules/user-settings/lib/editor'; +import { getTerminalName } from 'src/modules/user-settings/lib/terminal'; +import { useGetUserEditorQuery, useGetUserTerminalQuery } from 'src/stores/installed-apps-api'; interface SiteMenuProps { className?: string; @@ -108,10 +115,21 @@ function ButtonToRun( { running, id, name }: Pick< SiteDetails, 'running' | 'id' ); } function SiteItem( { site }: { site: SiteDetails } ) { - const { selectedSite, setSelectedSiteId } = useSiteDetails(); + const { + selectedSite, + setSelectedSiteId, + startServer, + stopServer, + loadingServer, + setIsEditModalOpen, + } = useSiteDetails(); + const { setSelectedTab } = useContentTabs(); + const { handleDeleteSite } = useDeleteSite(); const isSelected = site === selectedSite; const { isSiteImporting, isSiteExporting } = useImportExport(); const { isSiteIdPulling } = useSyncSites(); + const { data: editor } = useGetUserEditorQuery(); + const { data: terminal } = useGetUserTerminalQuery(); const isImporting = isSiteImporting( site.id ); const isExporting = isSiteExporting( site.id ); const isPulling = isSiteIdPulling( site.id ); @@ -128,6 +146,103 @@ function SiteItem( { site }: { site: SiteDetails } ) { tooltipText = __( 'Loading' ); } + const handleContextMenu = ( e: React.MouseEvent ) => { + e.preventDefault(); + const ipcApi = getIpcApi(); + const isLoading = loadingServer[ site.id ] || false; + const isAddingSite = site.isAddingSite || false; + const finderLabel = isWindows() ? __( 'File Explorer' ) : __( 'Finder' ); + const editorLabel = + editor && supportedEditorConfig[ editor ] ? supportedEditorConfig[ editor ].label : null; + const terminalLabel = getTerminalName( terminal ); + + ipcApi.showSiteContextMenu( { + siteId: site.id, + isRunning: site.running, + isLoading, + isAddingSite, + finderLabel, + editorLabel, + terminalLabel, + } ); + }; + + useEffect( () => { + const unsubscribe = window.ipcListener.subscribe( + 'site-context-menu-action', + async ( _, data: { action: string; siteId: string } ) => { + if ( data.siteId === site.id ) { + const ipcApi = getIpcApi(); + switch ( data.action ) { + case 'start': + void startServer( site.id ); + break; + case 'stop': + void stopServer( site.id ); + break; + case 'open-site': + if ( ! site.running ) { + await startServer( site.id ); + } + ipcApi.openSiteURL( site.id, '', { autoLogin: false } ); + break; + case 'open-admin': + if ( ! site.running ) { + await startServer( site.id ); + } + ipcApi.openSiteURL( site.id, '/wp-admin/' ); + break; + case 'open-finder': + ipcApi.openLocalPath( site.path ); + break; + case 'open-editor': + if ( editor ) { + void ipcApi.openAppAtPath( editor, site.path ); + } + break; + case 'open-terminal': + void ( async () => { + try { + await ipcApi.openTerminalAtPath( site.path ); + } catch ( error ) { + Sentry.captureException( error ); + alert( __( 'Could not open the terminal.' ) ); + } + } )(); + break; + case 'edit-site': + if ( site.id !== selectedSite?.id ) { + setSelectedSiteId( site.id ); + } + setSelectedTab( 'settings' ); + setIsEditModalOpen( true ); + break; + case 'delete': + await handleDeleteSite( site.id, site.name ); + break; + } + } + } + ); + + return () => { + unsubscribe?.(); + }; + }, [ + site.id, + site.name, + site.path, + site.running, + startServer, + stopServer, + editor, + selectedSite?.id, + setSelectedTab, + setIsEditModalOpen, + setSelectedSiteId, + handleDeleteSite, + ] ); + return (
  • ); -} +}; +EditSiteDetails.displayName = 'EditSiteDetails'; + +export default EditSiteDetails; diff --git a/src/modules/site-settings/tests/edit-site-details.test.tsx b/src/modules/site-settings/tests/edit-site-details.test.tsx index 47203b194..ec804f5d4 100644 --- a/src/modules/site-settings/tests/edit-site-details.test.tsx +++ b/src/modules/site-settings/tests/edit-site-details.test.tsx @@ -2,6 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { useOffline } from 'src/hooks/use-offline'; +import { useSiteDetails } from 'src/hooks/use-site-details'; import { createTestStore } from 'src/lib/test-utils'; import EditSiteDetails from 'src/modules/site-settings/edit-site-details'; import { useGetWordPressVersions } from 'src/stores/wordpress-versions-api'; @@ -18,20 +19,7 @@ jest.mock( 'src/lib/app-globals', () => ( { isWindows: () => false, } ) ); -jest.mock( 'src/hooks/use-site-details', () => ( { - useSiteDetails: () => ( { - selectedSite: { - id: 'site-123', - name: 'Test Site', - phpVersion: '8.3', - wpVersion: 'latest', - running: true, - }, - updateSite: mockUpdateSite, - stopServer: mockStopServer, - startServer: mockStartServer, - } ), -} ) ); +jest.mock( 'src/hooks/use-site-details' ); jest.mock( 'src/lib/get-ipc-api', () => ( { getIpcApi: () => ( { @@ -97,6 +85,20 @@ describe( 'EditSiteDetails', () => { beforeEach( () => { jest.clearAllMocks(); + ( useSiteDetails as jest.Mock ).mockReturnValue( { + selectedSite: { + id: 'site-123', + name: 'Test Site', + phpVersion: '8.3', + wpVersion: 'latest', + running: true, + }, + updateSite: mockUpdateSite, + stopServer: mockStopServer, + startServer: mockStartServer, + setIsEditModalOpen: jest.fn(), + isEditModalOpen: false, + } ); mockExecuteWPCLiInline.mockResolvedValue( { exitCode: 0 } ); } ); @@ -117,7 +119,19 @@ describe( 'EditSiteDetails', () => { await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); - expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + expect( useSiteDetails().setIsEditModalOpen ).toHaveBeenCalledWith( true ); + + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); + + renderWithProvider( ); + + await waitFor( () => { + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); + expect( screen.getByLabelText( 'Site name' ) ).toHaveValue( 'Test Site' ); expect( screen.getByLabelText( 'PHP version' ) ).toHaveValue( '8.3' ); expect( screen.getByLabelText( 'WordPress version' ) ).toHaveValue( 'latest' ); @@ -131,32 +145,45 @@ describe( 'EditSiteDetails', () => { const user = userEvent.setup(); await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); - await user.click( screen.getByRole( 'button', { name: 'Cancel' } ) ); + expect( useSiteDetails().setIsEditModalOpen ).toHaveBeenCalledWith( true ); - expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument(); + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); + renderWithProvider( ); + await waitFor( () => { + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); + + await user.click( screen.getByRole( 'button', { name: 'Cancel' } ) ); + expect( useSiteDetails().setIsEditModalOpen ).toHaveBeenCalledWith( false ); } ); it( 'should disable the save button when no changes are made', async () => { + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); renderWithProvider( ); await waitFor( () => { expect( mockGetAllCustomDomains ).toHaveBeenCalled(); } ); - const user = userEvent.setup(); - - await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); expect( screen.getByRole( 'button', { name: 'Save' } ) ).toBeDisabled(); } ); it( 'should enable the save button when site name is changed', async () => { + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); renderWithProvider( ); await waitFor( () => { expect( mockGetAllCustomDomains ).toHaveBeenCalled(); } ); const user = userEvent.setup(); - await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); - const siteNameInput = screen.getByLabelText( 'Site name' ); await user.clear( siteNameInput ); await user.type( siteNameInput, 'New Site Name' ); @@ -165,14 +192,16 @@ describe( 'EditSiteDetails', () => { } ); it( 'should enable the save button when PHP version is changed', async () => { + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); renderWithProvider( ); await waitFor( () => { expect( mockGetAllCustomDomains ).toHaveBeenCalled(); } ); const user = userEvent.setup(); - await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); - const phpVersionSelect = screen.getByLabelText( 'PHP version' ); await user.selectOptions( phpVersionSelect, '8.2' ); @@ -180,14 +209,16 @@ describe( 'EditSiteDetails', () => { } ); it( 'should enable the save button when WordPress version is changed', async () => { + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); renderWithProvider( ); await waitFor( () => { expect( mockGetAllCustomDomains ).toHaveBeenCalled(); } ); const user = userEvent.setup(); - await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); await user.selectOptions( wpVersionSelect, '6.4' ); @@ -195,14 +226,16 @@ describe( 'EditSiteDetails', () => { } ); it( 'should disable the save button when site name is empty', async () => { + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); renderWithProvider( ); await waitFor( () => { expect( mockGetAllCustomDomains ).toHaveBeenCalled(); } ); const user = userEvent.setup(); - await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); - const siteNameInput = screen.getByLabelText( 'Site name' ); await user.clear( siteNameInput ); @@ -210,14 +243,16 @@ describe( 'EditSiteDetails', () => { } ); it( 'should update site when save button is clicked with changed site name', async () => { + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); renderWithProvider( ); await waitFor( () => { expect( mockGetAllCustomDomains ).toHaveBeenCalled(); } ); const user = userEvent.setup(); - await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); - const siteNameInput = screen.getByLabelText( 'Site name' ); await user.clear( siteNameInput ); await user.type( siteNameInput, 'New Site Name' ); @@ -232,14 +267,16 @@ describe( 'EditSiteDetails', () => { } ); it( 'should update site and restart server when PHP version is changed', async () => { + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); renderWithProvider( ); await waitFor( () => { expect( mockGetAllCustomDomains ).toHaveBeenCalled(); } ); const user = userEvent.setup(); - await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); - const phpVersionSelect = screen.getByLabelText( 'PHP version' ); await user.selectOptions( phpVersionSelect, '8.2' ); @@ -255,11 +292,16 @@ describe( 'EditSiteDetails', () => { } ); it( 'should update isWpAutoUpdating to false when changed from latest to specific version', async () => { + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); renderWithProvider( ); + await waitFor( () => { + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); const user = userEvent.setup(); - await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); await user.selectOptions( wpVersionSelect, '6.4' ); @@ -278,11 +320,16 @@ describe( 'EditSiteDetails', () => { } ); it( 'should update WordPress version when changed to beta', async () => { + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); renderWithProvider( ); + await waitFor( () => { + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); const user = userEvent.setup(); - await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); await user.selectOptions( wpVersionSelect, '6.8-beta1' ); @@ -296,14 +343,19 @@ describe( 'EditSiteDetails', () => { } ); it( 'should show error when WordPress version update fails', async () => { + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); const consoleSpy = jest.spyOn( console, 'error' ).mockImplementation( () => {} ); mockExecuteWPCLiInline.mockResolvedValue( { exitCode: 1, stderr: 'Update failed' } ); renderWithProvider( ); + await waitFor( () => { + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); const user = userEvent.setup(); - await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); await user.selectOptions( wpVersionSelect, '6.4' ); @@ -320,16 +372,21 @@ describe( 'EditSiteDetails', () => { } ); it( 'should disable form controls when site is being edited', async () => { + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); // Mock the updateSite to delay completion mockUpdateSite.mockImplementation( () => new Promise( ( resolve ) => setTimeout( resolve, 100 ) ) ); renderWithProvider( ); + await waitFor( () => { + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); const user = userEvent.setup(); - await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); - const siteNameInput = screen.getByLabelText( 'Site name' ); await user.clear( siteNameInput ); await user.type( siteNameInput, 'New Site Name' ); @@ -351,37 +408,50 @@ describe( 'EditSiteDetails', () => { } ); it( 'should disable WordPress version field when offline', async () => { + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); ( useOffline as jest.Mock ).mockReturnValue( true ); renderWithProvider( ); - const user = userEvent.setup(); - - await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); + await waitFor( () => { + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); expect( wpVersionSelect ).toBeDisabled(); } ); it( 'should enable WordPress version field when online', async () => { + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); ( useOffline as jest.Mock ).mockReturnValue( false ); renderWithProvider( ); - const user = userEvent.setup(); - - await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); + await waitFor( () => { + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); expect( wpVersionSelect ).not.toBeDisabled(); } ); it( 'should show tooltip with offline message when hovering over disabled WordPress version field', async () => { + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); ( useOffline as jest.Mock ).mockReturnValue( true ); renderWithProvider( ); + await waitFor( () => { + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); const user = userEvent.setup(); - await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); await user.hover( wpVersionSelect ); @@ -391,13 +461,15 @@ describe( 'EditSiteDetails', () => { } ); it( 'should not show tooltip when hovering over WordPress version field while online', async () => { + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); ( useOffline as jest.Mock ).mockReturnValue( false ); renderWithProvider( ); const user = userEvent.setup(); - await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); - const wpVersionSelect = screen.getByLabelText( 'WordPress version' ); await user.hover( wpVersionSelect ); @@ -406,11 +478,16 @@ describe( 'EditSiteDetails', () => { ).not.toBeInTheDocument(); } ); - it( 'should fetch WordPress versions when clicking edit site', async () => { + it( 'should fetch WordPress versions when modal opens', async () => { + ( useSiteDetails as jest.Mock ).mockReturnValue( { + ...useSiteDetails(), + isEditModalOpen: true, + } ); renderWithProvider( ); - const user = userEvent.setup(); + await waitFor( () => { + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); - await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); expect( useGetWordPressVersions ).toHaveBeenCalled(); } ); } ); diff --git a/src/preload.ts b/src/preload.ts index 6c8538812..1b130fdcd 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -123,6 +123,7 @@ const api: IpcApi = { ipcRenderer.invoke( 'listWpContentFolders', siteId, subdir ), getProviderConstants: () => ipcRendererInvoke( 'getProviderConstants' ), validateBlueprint: ( blueprintJson ) => ipcRendererInvoke( 'validateBlueprint', blueprintJson ), + showSiteContextMenu: ( context ) => ipcRendererSend( 'showSiteContextMenu', context ), setWindowControlVisibility: ( visible ) => ipcRendererInvoke( 'setWindowControlVisibility', visible ), }; diff --git a/src/tests/show-site-context-menu.test.ts b/src/tests/show-site-context-menu.test.ts new file mode 100644 index 000000000..3a1934ef3 --- /dev/null +++ b/src/tests/show-site-context-menu.test.ts @@ -0,0 +1,320 @@ +/** + * @jest-environment node + */ +import { IpcMainInvokeEvent, BrowserWindow, Menu, MenuItem } from 'electron'; +import fs from 'fs'; +import { showSiteContextMenu } from 'src/ipc-handlers'; +import { sendIpcEventToRendererWithWindow } from 'src/ipc-utils'; + +jest.mock( 'src/ipc-utils' ); + +const mockIpcMainInvokeEvent = { + sender: { isDestroyed: jest.fn( () => false ) }, + // Double assert the type with `unknown` to simplify mocking this value +} as unknown as IpcMainInvokeEvent; + +jest.mock( 'fs' ); + +describe( 'showSiteContextMenu', () => { + let mockMenu: { append: jest.Mock; popup: jest.Mock }; + let mockWindow: { isDestroyed: jest.Mock }; + let menuItems: MenuItem[]; + + beforeEach( () => { + jest.clearAllMocks(); + menuItems = []; + mockMenu = { + append: jest.fn( ( item: MenuItem ) => menuItems.push( item ) ), + popup: jest.fn(), + }; + mockWindow = { + isDestroyed: jest.fn( () => false ), + }; + + ( Menu as unknown as jest.Mock ) = jest.fn( () => mockMenu ); + ( MenuItem as unknown as jest.Mock ) = jest.fn( ( config ) => config ); + ( BrowserWindow.fromWebContents as jest.Mock ) = jest.fn( () => mockWindow ); + } ); + + const baseContext = { + siteId: 'test-site-id', + finderLabel: 'Finder', + editorLabel: 'VS Code', + terminalLabel: 'Terminal', + }; + + describe( 'when site is running', () => { + it( 'should show Stop menu item as enabled when not adding site', () => { + showSiteContextMenu( mockIpcMainInvokeEvent, { + ...baseContext, + isRunning: true, + isLoading: false, + isAddingSite: false, + } ); + + const stopItem = menuItems.find( ( item ) => item.label === 'Stop' ); + expect( stopItem ).toBeDefined(); + expect( stopItem?.enabled ).toBe( true ); + } ); + + it( 'should send stop action when Stop is clicked', () => { + showSiteContextMenu( mockIpcMainInvokeEvent, { + ...baseContext, + isRunning: true, + isLoading: false, + isAddingSite: false, + } ); + + const stopItem = menuItems.find( ( item ) => item.label === 'Stop' ); + stopItem?.click?.(); + + expect( sendIpcEventToRendererWithWindow ).toHaveBeenCalledWith( + mockWindow, + 'site-context-menu-action', + { + action: 'stop', + siteId: 'test-site-id', + } + ); + } ); + } ); + + describe( 'when site is being added', () => { + it( 'should show all menu items as disabled when adding site', () => { + showSiteContextMenu( mockIpcMainInvokeEvent, { + ...baseContext, + isRunning: true, + isLoading: false, + isAddingSite: true, + } ); + + const stopItem = menuItems.find( ( item ) => item.label === 'Stop' ); + const openSiteItem = menuItems.find( ( item ) => item.label === 'Open site' ); + const wpAdminItem = menuItems.find( ( item ) => item.label === 'WP admin' ); + const finderItem = menuItems.find( ( item ) => item.label === 'Open in Finder' ); + const editorItem = menuItems.find( ( item ) => item.label === 'Open in VS Code' ); + const terminalItem = menuItems.find( ( item ) => item.label === 'Open in Terminal' ); + const editItem = menuItems.find( ( item ) => item.label === 'Edit site…' ); + const deleteItem = menuItems.find( ( item ) => item.label === 'Delete site…' ); + + expect( stopItem?.enabled ).toBe( false ); + expect( openSiteItem?.enabled ).toBe( false ); + expect( wpAdminItem?.enabled ).toBe( false ); + expect( finderItem?.enabled ).toBe( false ); + expect( editorItem?.enabled ).toBe( false ); + expect( terminalItem?.enabled ).toBe( false ); + expect( editItem?.enabled ).toBe( false ); + expect( deleteItem?.enabled ).toBe( false ); + + showSiteContextMenu( mockIpcMainInvokeEvent, { + ...baseContext, + isRunning: false, + isLoading: false, + isAddingSite: true, + } ); + + const startItem = menuItems.find( ( item ) => item.label === 'Start' ); + expect( startItem?.enabled ).toBe( false ); + } ); + } ); + + describe( 'when site is stopped', () => { + it( 'should show Start menu item as enabled when not loading or adding site', () => { + showSiteContextMenu( mockIpcMainInvokeEvent, { + ...baseContext, + isRunning: false, + isLoading: false, + isAddingSite: false, + } ); + + const startItem = menuItems.find( ( item ) => item.label === 'Start' ); + expect( startItem ).toBeDefined(); + expect( startItem?.enabled ).toBe( true ); + } ); + + it( 'should show Start menu item as disabled when loading', () => { + showSiteContextMenu( mockIpcMainInvokeEvent, { + ...baseContext, + isRunning: false, + isLoading: true, + isAddingSite: false, + } ); + + const startItem = menuItems.find( ( item ) => item.label === 'Start' ); + expect( startItem ).toBeDefined(); + expect( startItem?.enabled ).toBe( false ); + } ); + + it( 'should send start action when Start is clicked', () => { + showSiteContextMenu( mockIpcMainInvokeEvent, { + ...baseContext, + isRunning: false, + isLoading: false, + isAddingSite: false, + } ); + + const startItem = menuItems.find( ( item ) => item.label === 'Start' ); + startItem?.click?.(); + + expect( sendIpcEventToRendererWithWindow ).toHaveBeenCalledWith( + mockWindow, + 'site-context-menu-action', + { + action: 'start', + siteId: 'test-site-id', + } + ); + } ); + } ); + + describe( 'menu item actions', () => { + beforeEach( () => { + showSiteContextMenu( mockIpcMainInvokeEvent, { + ...baseContext, + isRunning: true, + isLoading: false, + isAddingSite: false, + } ); + } ); + + it( 'should send open-site action when Open site is clicked', () => { + const openSiteItem = menuItems.find( ( item ) => item.label === 'Open site' ); + openSiteItem?.click?.(); + + expect( sendIpcEventToRendererWithWindow ).toHaveBeenCalledWith( + mockWindow, + 'site-context-menu-action', + { + action: 'open-site', + siteId: 'test-site-id', + } + ); + } ); + + it( 'should send open-admin action when WP admin is clicked', () => { + const wpAdminItem = menuItems.find( ( item ) => item.label === 'WP admin' ); + wpAdminItem?.click?.(); + + expect( sendIpcEventToRendererWithWindow ).toHaveBeenCalledWith( + mockWindow, + 'site-context-menu-action', + { + action: 'open-admin', + siteId: 'test-site-id', + } + ); + } ); + + it( 'should send open-finder action when Open in Finder is clicked', () => { + const finderItem = menuItems.find( ( item ) => item.label === 'Open in Finder' ); + finderItem?.click?.(); + + expect( sendIpcEventToRendererWithWindow ).toHaveBeenCalledWith( + mockWindow, + 'site-context-menu-action', + { + action: 'open-finder', + siteId: 'test-site-id', + } + ); + } ); + + it( 'should send open-editor action when Open in VS Code is clicked', () => { + const editorItem = menuItems.find( ( item ) => item.label === 'Open in VS Code' ); + editorItem?.click?.(); + + expect( sendIpcEventToRendererWithWindow ).toHaveBeenCalledWith( + mockWindow, + 'site-context-menu-action', + { + action: 'open-editor', + siteId: 'test-site-id', + } + ); + } ); + + it( 'should send open-terminal action when Open in Terminal is clicked', () => { + const terminalItem = menuItems.find( ( item ) => item.label === 'Open in Terminal' ); + terminalItem?.click?.(); + + expect( sendIpcEventToRendererWithWindow ).toHaveBeenCalledWith( + mockWindow, + 'site-context-menu-action', + { + action: 'open-terminal', + siteId: 'test-site-id', + } + ); + } ); + + it( 'should send edit-site action when Edit site is clicked', () => { + const editItem = menuItems.find( ( item ) => item.label === 'Edit site…' ); + editItem?.click?.(); + + expect( sendIpcEventToRendererWithWindow ).toHaveBeenCalledWith( + mockWindow, + 'site-context-menu-action', + { + action: 'edit-site', + siteId: 'test-site-id', + } + ); + } ); + + it( 'should send delete action when Delete site is clicked', () => { + const deleteItem = menuItems.find( ( item ) => item.label === 'Delete site…' ); + deleteItem?.click?.(); + + expect( sendIpcEventToRendererWithWindow ).toHaveBeenCalledWith( + mockWindow, + 'site-context-menu-action', + { + action: 'delete', + siteId: 'test-site-id', + } + ); + } ); + } ); + + describe( 'when no editor is available', () => { + it( 'should not show editor menu item when editorLabel is null', () => { + showSiteContextMenu( mockIpcMainInvokeEvent, { + ...baseContext, + editorLabel: null, + isRunning: false, + isLoading: false, + isAddingSite: false, + } ); + + const editorItem = menuItems.find( + ( item ) => item.label?.includes( 'Open in' ) && item.label?.includes( 'Code' ) + ); + expect( editorItem ).toBeUndefined(); + } ); + } ); + + describe( 'menu structure', () => { + it( 'should have correct separator count', () => { + showSiteContextMenu( mockIpcMainInvokeEvent, { + ...baseContext, + isRunning: false, + isLoading: false, + isAddingSite: false, + } ); + + const separators = menuItems.filter( ( item ) => item.type === 'separator' ); + expect( separators.length ).toBe( 3 ); + } ); + + it( 'should call popup with window', () => { + showSiteContextMenu( mockIpcMainInvokeEvent, { + ...baseContext, + isRunning: false, + isLoading: false, + isAddingSite: false, + } ); + + expect( mockMenu.popup ).toHaveBeenCalledWith( { window: mockWindow } ); + } ); + } ); +} );