diff --git a/apps/studio/src/components/tests/content-tab-settings.actions.test.tsx b/apps/studio/src/components/tests/content-tab-settings.actions.test.tsx new file mode 100644 index 0000000000..ede32c7e6b --- /dev/null +++ b/apps/studio/src/components/tests/content-tab-settings.actions.test.tsx @@ -0,0 +1,178 @@ +// To run tests, execute `npm run test -- src/components/tests/content-tab-settings.actions.test.tsx` from the root directory +/** + * Site Management UI integration tests — duplicate & delete (STU-1867). + * + * Part of migrating apps/studio/e2e/sites.test.ts to fast jsdom tests. Unlike the + * sibling content-tab-settings.test.tsx (which mocks `useSiteDetails`), these mount + * the real `SiteDetailsProvider` and mock only the IPC bridge, then assert the exact + * `getIpcApi()` command a user interaction generates — the "what command does the UI + * fire" model agreed in the 6/24 testing huddle. Duplicate has no CLI command, so this + * is its primary automated coverage; the old e2e delete tests were skipped on Windows. + * + * Smoke-sheet rows: Site Management #9 (duplicate), #12 (delete, keep dir), #13 (delete, +dir). + */ +import { render, screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { ReactNode } from 'react'; +import { Provider } from 'react-redux'; +import { vi, beforeAll, beforeEach } from 'vitest'; +import { ContentTabSettings } from 'src/components/content-tab-settings'; +import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; +import { SiteDetailsProvider } from 'src/hooks/use-site-details'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { store } from 'src/stores'; + +vi.mock( 'src/lib/get-ipc-api' ); +vi.mock( 'src/lib/app-globals', () => ( { + isWindows: () => false, + isLinux: () => false, +} ) ); +vi.mock( 'src/hooks/use-get-wp-version', () => ( { + useGetWpVersion: () => [ '6.5', vi.fn() ], +} ) ); +vi.mock( 'src/stores/certificate-trust-api', async () => { + const actual = await vi.importActual( 'src/stores/certificate-trust-api' ); + return { + ...( actual || {} ), + useCheckCertificateTrustQuery: vi.fn().mockReturnValue( { data: true } ), + }; +} ); +vi.mock( 'src/stores/wordpress-versions-api', async () => { + const actual = await vi.importActual( 'src/stores/wordpress-versions-api' ); + return { + ...actual, + useGetWordPressVersions: vi.fn( () => ( { + data: [ { label: 'Latest', value: 'latest', isBeta: false, isDevelopment: false } ], + isLoading: false, + } ) ), + }; +} ); + +const SITE_ID = 'site-id'; +const site: SiteDetails = { + id: SITE_ID, + name: 'Test Site', + path: '/path/to/site', + port: 8881, + phpVersion: '8.4', + running: false, + autoStart: false, + adminPassword: btoa( 'test-password' ), +}; + +const wrapper = ( { children }: { children: ReactNode } ) => ( + + + { children } + + +); + +function renderSettings() { + return render( , { wrapper } ); +} + +async function openMoreOptions( user: ReturnType< typeof userEvent.setup > ) { + await user.click( screen.getByRole( 'button', { name: 'More options' } ) ); +} + +// Wait until the provider has finished its initial async site load before interacting. +async function waitForMount() { + await waitFor( () => expect( getIpcApi().getAllCustomDomains ).toHaveBeenCalled() ); +} + +beforeAll( () => { + Object.defineProperty( window, 'ipcListener', { + value: { subscribe: vi.fn().mockReturnValue( () => {} ) }, + writable: true, + } ); +} ); + +beforeEach( () => { + vi.clearAllMocks(); + vi.mocked( getIpcApi, { partial: true } ).mockReturnValue( { + // SiteDetailsProvider + ContentTabSettings/EditSiteDetails mount-time calls + getSiteDetails: vi.fn().mockResolvedValue( [ site ] ), + getConnectedWpcomSites: vi.fn().mockResolvedValue( [] ), + startServer: vi.fn().mockResolvedValue( undefined ), + getAllCustomDomains: vi.fn().mockResolvedValue( [] ), + getXdebugEnabledSite: vi.fn().mockResolvedValue( null ), + executeWPCLiInline: vi.fn().mockResolvedValue( { stdout: '', stderr: '', exitCode: 0 } ), + isCATrusted: vi.fn().mockResolvedValue( true ), + // Duplicate + generateNumberedNameFromList: vi.fn().mockResolvedValue( 'Test Site Copy' ), + copySite: vi.fn().mockResolvedValue( { ...site, id: 'new-site-id', name: 'Test Site Copy' } ), + showNotification: vi.fn(), + // Delete — default to "Cancel" so unrelated tests can't accidentally delete. + showMessageBox: vi.fn().mockResolvedValue( { response: 1, checkboxChecked: false } ), + deleteSite: vi.fn().mockResolvedValue( undefined ), + } ); +} ); + +describe( 'ContentTabSettings — site actions (IPC command boundary)', () => { + it( 'duplicate: fires copySite with the source id and " Copy" name', async () => { + const user = userEvent.setup(); + renderSettings(); + await waitForMount(); + + await openMoreOptions( user ); + await user.click( await screen.findByRole( 'menuitem', { name: 'Duplicate site' } ) ); + + await waitFor( () => { + expect( getIpcApi().copySite ).toHaveBeenCalledWith( + SITE_ID, + expect.any( String ), + 'Test Site Copy' + ); + } ); + } ); + + it( 'delete (+dir): fires deleteSite(id, true) when the files checkbox is checked', async () => { + const user = userEvent.setup(); + vi.mocked( getIpcApi().showMessageBox ).mockResolvedValue( { + response: 0, + checkboxChecked: true, + } ); + renderSettings(); + await waitForMount(); + + await openMoreOptions( user ); + await user.click( await screen.findByRole( 'menuitem', { name: 'Delete site' } ) ); + + await waitFor( () => { + expect( getIpcApi().deleteSite ).toHaveBeenCalledWith( SITE_ID, true ); + } ); + } ); + + it( 'delete (keep dir): fires deleteSite(id, false) when the files checkbox is unchecked', async () => { + const user = userEvent.setup(); + vi.mocked( getIpcApi().showMessageBox ).mockResolvedValue( { + response: 0, + checkboxChecked: false, + } ); + renderSettings(); + await waitForMount(); + + await openMoreOptions( user ); + await user.click( await screen.findByRole( 'menuitem', { name: 'Delete site' } ) ); + + await waitFor( () => { + expect( getIpcApi().deleteSite ).toHaveBeenCalledWith( SITE_ID, false ); + } ); + } ); + + it( 'delete (cancelled): does not fire deleteSite when the dialog is dismissed', async () => { + const user = userEvent.setup(); + // showMessageBox already defaults to the Cancel response (index 1). + renderSettings(); + await waitForMount(); + + await openMoreOptions( user ); + await user.click( await screen.findByRole( 'menuitem', { name: 'Delete site' } ) ); + + await waitFor( () => { + expect( getIpcApi().showMessageBox ).toHaveBeenCalled(); + } ); + expect( getIpcApi().deleteSite ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/apps/studio/src/components/tests/content-tab-settings.test.tsx b/apps/studio/src/components/tests/content-tab-settings.test.tsx index b70d0fbea0..e1d04201be 100644 --- a/apps/studio/src/components/tests/content-tab-settings.test.tsx +++ b/apps/studio/src/components/tests/content-tab-settings.test.tsx @@ -71,7 +71,7 @@ vi.mock( 'src/stores/wordpress-versions-api', async () => { return { ...actual, useGetWordPressVersions: vi.fn( () => ( { - sites: [ + data: [ { label: 'Latest', value: 'latest', isBeta: false, isDevelopment: false }, { label: '6.4', value: '6.4', isBeta: false, isDevelopment: false }, { label: '6.3', value: '6.3', isBeta: false, isDevelopment: false }, diff --git a/apps/studio/src/modules/site-settings/tests/edit-site-details.actions.test.tsx b/apps/studio/src/modules/site-settings/tests/edit-site-details.actions.test.tsx new file mode 100644 index 0000000000..8e419ad6b8 --- /dev/null +++ b/apps/studio/src/modules/site-settings/tests/edit-site-details.actions.test.tsx @@ -0,0 +1,141 @@ +// To run tests, execute `npm run test -- src/modules/site-settings/tests/edit-site-details.actions.test.tsx` from the root directory +/** + * Site Management UI integration tests — edit settings & change PHP version (STU-1867). + * + * Unlike the sibling edit-site-details.test.tsx (which mocks `useSiteDetails` and asserts the + * hook call), these mount the real `SiteDetailsProvider`, open the Edit Site modal for real, + * and assert the exact `getIpcApi().updateSite(site, wpVersion)` command the save generates — + * the "what command does the UI fire" model agreed in the 6/24 testing huddle. Changing the + * PHP version was flagged flaky in the old Playwright suite (Redux/_events round-trip); asserting + * at the IPC boundary sidesteps that. + * + * Smoke-sheet rows: Site Management #10 (edit settings — site name), #11 (change PHP version). + */ +import { render, screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { ReactNode } from 'react'; +import { Provider } from 'react-redux'; +import { vi, beforeAll, beforeEach } from 'vitest'; +import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; +import { SiteDetailsProvider } from 'src/hooks/use-site-details'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import EditSiteDetails from 'src/modules/site-settings/edit-site-details'; +import { store } from 'src/stores'; + +vi.mock( 'src/lib/get-ipc-api' ); +vi.mock( 'src/lib/app-globals', () => ( { + isWindows: () => false, + isLinux: () => false, +} ) ); +vi.mock( 'src/hooks/use-offline', () => ( { + useOffline: vi.fn().mockReturnValue( false ), +} ) ); +vi.mock( 'src/stores/certificate-trust-api', async () => { + const actual = await vi.importActual( 'src/stores/certificate-trust-api' ); + return { + ...( actual || {} ), + useCheckCertificateTrustQuery: vi.fn().mockReturnValue( { data: true } ), + }; +} ); +vi.mock( 'src/stores/wordpress-versions-api', async () => { + const actual = await vi.importActual( 'src/stores/wordpress-versions-api' ); + return { + ...actual, + useGetWordPressVersions: vi.fn( () => ( { + data: [ + { label: 'Latest', value: '6.7.2' }, + { label: '6.4', value: '6.4', isBeta: false, isDevelopment: false }, + ], + isLoading: false, + } ) ), + }; +} ); + +const SITE_ID = 'site-id'; +const site: SiteDetails = { + id: SITE_ID, + name: 'Test Site', + path: '/path/to/site', + port: 8881, + phpVersion: '8.4', + running: false, + autoStart: false, + adminPassword: btoa( 'test-password' ), +}; + +const wrapper = ( { children }: { children: ReactNode } ) => ( + + + { children } + + +); + +function setup() { + const user = userEvent.setup(); + render( , { wrapper } ); + return user; +} + +// Open the Edit Site modal and wait for it to render. +async function openEditModal( user: ReturnType< typeof userEvent.setup > ) { + await waitFor( () => expect( getIpcApi().getAllCustomDomains ).toHaveBeenCalled() ); + await user.click( screen.getByRole( 'button', { name: 'Edit site' } ) ); + await screen.findByRole( 'dialog' ); +} + +// updateSite is called as updateSite( site, wpVersion ); assert on the persisted site arg. +function savedSiteArg() { + return vi.mocked( getIpcApi().updateSite ).mock.calls[ 0 ][ 0 ]; +} + +beforeAll( () => { + Object.defineProperty( window, 'ipcListener', { + value: { subscribe: vi.fn().mockReturnValue( () => {} ) }, + writable: true, + } ); +} ); + +beforeEach( () => { + vi.clearAllMocks(); + // Make the provider select our seeded site deterministically. + localStorage.setItem( 'selectedSiteId', SITE_ID ); + vi.mocked( getIpcApi, { partial: true } ).mockReturnValue( { + getSiteDetails: vi.fn().mockResolvedValue( [ site ] ), + getConnectedWpcomSites: vi.fn().mockResolvedValue( [] ), + startServer: vi.fn().mockResolvedValue( undefined ), + getAllCustomDomains: vi.fn().mockResolvedValue( [] ), + getXdebugEnabledSite: vi.fn().mockResolvedValue( null ), + executeWPCLiInline: vi.fn().mockResolvedValue( { stdout: '', stderr: '', exitCode: 0 } ), + isCATrusted: vi.fn().mockResolvedValue( true ), + // Save command (assertion target) — updateSite then re-reads the site list. + updateSite: vi.fn().mockResolvedValue( undefined ), + showNotification: vi.fn(), + } ); +} ); + +describe( 'EditSiteDetails — save (IPC command boundary)', () => { + it( 'edit settings: fires updateSite with the renamed site', async () => { + const user = setup(); + await openEditModal( user ); + + const nameInput = screen.getByLabelText( 'Site name' ); + await user.clear( nameInput ); + await user.type( nameInput, 'Renamed Site' ); + await user.click( screen.getByRole( 'button', { name: 'Save' } ) ); + + await waitFor( () => expect( getIpcApi().updateSite ).toHaveBeenCalled() ); + expect( savedSiteArg() ).toMatchObject( { id: SITE_ID, name: 'Renamed Site' } ); + } ); + + it( 'change PHP version: fires updateSite with the new phpVersion', async () => { + const user = setup(); + await openEditModal( user ); + + await user.selectOptions( screen.getByLabelText( 'PHP version' ), '8.2' ); + await user.click( screen.getByRole( 'button', { name: 'Save' } ) ); + + await waitFor( () => expect( getIpcApi().updateSite ).toHaveBeenCalled() ); + expect( savedSiteArg() ).toMatchObject( { id: SITE_ID, phpVersion: '8.2' } ); + } ); +} );