From ab0cc210240aa6e38322d98b3e3c1bd6a3345bea Mon Sep 17 00:00:00 2001 From: Daniel Bachhuber Date: Mon, 17 Jun 2024 10:59:53 +0200 Subject: [PATCH] Allow PHP version to be changed from Site Settings (#225) * Display the PHP version on Site Settings screen * Add a modal for editing the PHP version * First pass at saving selected PHP version * Update site details to allow user to set PHP version (#226) * Add phpVersion to updateSiteDetails * Update tests for changing the PHP version * Abstract available PHP versions to a constant * Use PHP constants from `wp-now` (#231) * Use `DEFAULT_PHP_VERSION` constant from `wp-now` * Use available PHP versions of Playground * Add `web-streams-polyfill` package for unit tests * Polyfill `ReadableStream` in unit tests Web streams are used by `php-wasm`, so we need to polyfull them if we import the library in unit tests. * Add unit tests to cover changing PHP version functionality (#233) * Mock `matchMedia` * Expose label in `SettingsRow` component * Add test to cover the case of changing PHP version * Revert "Expose label in `SettingsRow` component" This reverts commit bc37161e54e66ad6a0bdd959f8ef26fc6a0505f3. * Update change PHP version test case * Remove `getPhpVersion` hook and IPC handler (#239) * Remove `getPhpVersion` hook and IPC handler * Use `??` operator instead of `||` when setting the php version * Add inline comment in Jest setup The comment clarifies why we need the polyfill. Related to: https://github.com/Automattic/studio/pull/231#discussion_r1635263097 * Update `ContentTabSettings` unit tests * Bump default PHP version to `8.1` (#240) * Bump default PHP version to `8.1` This change is driven by the fact that version `8.0` already reached its end-of-life by 2024. https://www.php.net/supported-versions.php * Address failure in `createSite` unit test --------- Co-authored-by: Derek Blank Co-authored-by: Carlos Garcia --- jest-setup.ts | 20 +++ package-lock.json | 16 +++ package.json | 1 + src/components/content-tab-settings.tsx | 9 ++ src/components/edit-php-version.tsx | 116 ++++++++++++++++++ .../tests/content-tab-assistant.test.tsx | 1 + ...nt-tab-overview-shortcuts-section.test.tsx | 1 + .../tests/content-tab-overview.test.tsx | 2 + .../tests/content-tab-settings.test.tsx | 83 ++++++++++++- .../tests/content-tab-snapshots.test.tsx | 1 + src/hooks/tests/use-update-demo-site.test.tsx | 1 + src/ipc-handlers.ts | 3 +- src/ipc-types.d.ts | 1 + src/site-server.ts | 4 + src/storage/user-data.ts | 3 +- src/tests/ipc-handlers.test.ts | 1 + src/tests/site-server.test.ts | 1 + vendor/wp-now/src/constants.ts | 2 +- 18 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 src/components/edit-php-version.tsx diff --git a/jest-setup.ts b/jest-setup.ts index 6fd4705c1..ed3989a83 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -1,9 +1,29 @@ import '@testing-library/jest-dom'; +// We need this polyfill because the `ReadableStream` class is +// used by `@php-wasm/universal` and it's not available in the Jest environment. +// eslint-disable-next-line import/no-unresolved +import 'web-streams-polyfill/polyfill'; import nock from 'nock'; if ( typeof window !== 'undefined' ) { // The ipcListener global is usually defined in preload.ts window.ipcListener = { subscribe: jest.fn() }; + + // Mock `matchMedia` as it's not implemented in JSDOM + // Reference: https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom + Object.defineProperty( window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation( ( query ) => ( { + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + } ) ), + } ); } nock.disableNetConnect(); diff --git a/package-lock.json b/package-lock.json index 46d0bf2aa..0c08cc03a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,6 +99,7 @@ "ts-loader": "^9.2.2", "ts-node": "^10.0.0", "typescript": "~5.3.2", + "web-streams-polyfill": "^4.0.0", "webpack-dev-middleware": "5.3.4" }, "optionalDependencies": { @@ -21327,6 +21328,15 @@ "defaults": "^1.0.3" } }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0.tgz", + "integrity": "sha512-0zJXHRAYEjM2tUfZ2DiSOHAa2aw1tisnnhU3ufD57R8iefL+DcdJyRBRyJpG+NUimDgbTI/lH+gAE1PAvV3Cgw==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -37908,6 +37918,12 @@ "defaults": "^1.0.3" } }, + "web-streams-polyfill": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0.tgz", + "integrity": "sha512-0zJXHRAYEjM2tUfZ2DiSOHAa2aw1tisnnhU3ufD57R8iefL+DcdJyRBRyJpG+NUimDgbTI/lH+gAE1PAvV3Cgw==", + "dev": true + }, "webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/package.json b/package.json index e0317be1e..b787612cb 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "ts-loader": "^9.2.2", "ts-node": "^10.0.0", "typescript": "~5.3.2", + "web-streams-polyfill": "^4.0.0", "webpack-dev-middleware": "5.3.4" }, "dependencies": { diff --git a/src/components/content-tab-settings.tsx b/src/components/content-tab-settings.tsx index 6203625c6..47c098729 100644 --- a/src/components/content-tab-settings.tsx +++ b/src/components/content-tab-settings.tsx @@ -1,12 +1,14 @@ import { Icon, file } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { PropsWithChildren } from 'react'; +import { DEFAULT_PHP_VERSION } from '../../vendor/wp-now/src/constants'; import { useGetWpVersion } from '../hooks/use-get-wp-version'; import { getIpcApi } from '../lib/get-ipc-api'; import { decodePassword } from '../lib/passwords'; import Button from './button'; import { CopyTextButton } from './copy-text-button'; import DeleteSite from './delete-site'; +import EditPhpVersion from './edit-php-version'; import EditSite from './edit-site'; interface ContentTabSettingsProps { @@ -30,6 +32,7 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) // Empty strings account for legacy sites lacking a stored password. const storedPassword = decodePassword( selectedSite.adminPassword ?? '' ); const password = storedPassword === '' ? 'password' : storedPassword; + const phpVersion = selectedSite.phpVersion ?? DEFAULT_PHP_VERSION; const wpVersion = useGetWpVersion( selectedSite ); return (
@@ -67,6 +70,12 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) { wpVersion } + +
+ { phpVersion } + +
+
diff --git a/src/components/edit-php-version.tsx b/src/components/edit-php-version.tsx new file mode 100644 index 000000000..947b399bf --- /dev/null +++ b/src/components/edit-php-version.tsx @@ -0,0 +1,116 @@ +import { SupportedPHPVersions } from '@php-wasm/universal'; +import { SelectControl } from '@wordpress/components'; +import { useI18n } from '@wordpress/react-i18n'; +import { FormEvent, useCallback, useEffect, useState } from 'react'; +import { DEFAULT_PHP_VERSION } from '../../vendor/wp-now/src/constants'; +import { useSiteDetails } from '../hooks/use-site-details'; +import Button from './button'; +import Modal from './modal'; + +export default function EditPhpVersion() { + const { __ } = useI18n(); + const { updateSite, selectedSite, stopServer, startServer } = useSiteDetails(); + const [ editPhpVersionError, setEditPhpVersionError ] = useState( '' ); + const [ selectedPhpVersion, setSelectedPhpVersion ] = useState( DEFAULT_PHP_VERSION ); + const [ needsToEditPhpVersion, setNeedsToEditPhpVersion ] = useState( false ); + const [ isEditingSite, setIsEditingSite ] = useState( false ); + + useEffect( () => { + if ( selectedSite ) { + setSelectedPhpVersion( selectedSite.phpVersion ); + } + }, [ selectedSite ] ); + + const resetLocalState = useCallback( () => { + setNeedsToEditPhpVersion( false ); + setSelectedPhpVersion( '' ); + setEditPhpVersionError( '' ); + }, [] ); + + const onSiteEdit = useCallback( + async ( event: FormEvent ) => { + event.preventDefault(); + if ( ! selectedSite ) { + return; + } + setIsEditingSite( true ); + try { + const running = selectedSite.running; + await updateSite( { + ...selectedSite, + phpVersion: selectedPhpVersion, + } ); + if ( running ) { + await stopServer( selectedSite.id ); + await startServer( selectedSite.id ); + } + setNeedsToEditPhpVersion( false ); + resetLocalState(); + } catch ( e ) { + setEditPhpVersionError( ( e as Error )?.message ); + } + setIsEditingSite( false ); + }, + [ updateSite, selectedSite, selectedPhpVersion, resetLocalState, startServer, stopServer ] + ); + + return ( + <> + { needsToEditPhpVersion && ( + +
+ +
+ + +
+
+
+ ) } + + + ); +} diff --git a/src/components/tests/content-tab-assistant.test.tsx b/src/components/tests/content-tab-assistant.test.tsx index 4fe5672ff..bb104f1a9 100644 --- a/src/components/tests/content-tab-assistant.test.tsx +++ b/src/components/tests/content-tab-assistant.test.tsx @@ -24,6 +24,7 @@ const runningSite = { port: 8881, path: '/path/to/site', running: true, + phpVersion: '8.0', id: 'site-id', url: 'http://example.com', }; diff --git a/src/components/tests/content-tab-overview-shortcuts-section.test.tsx b/src/components/tests/content-tab-overview-shortcuts-section.test.tsx index e8cbfe561..c7ad8e8e5 100644 --- a/src/components/tests/content-tab-overview-shortcuts-section.test.tsx +++ b/src/components/tests/content-tab-overview-shortcuts-section.test.tsx @@ -10,6 +10,7 @@ const selectedSite: StartedSiteDetails = { port: 8881, path: '/path/to/site', running: true, + phpVersion: '8.0', id: 'site-id', url: 'http://example.com', }; diff --git a/src/components/tests/content-tab-overview.test.tsx b/src/components/tests/content-tab-overview.test.tsx index 02a64d902..76c6b92bc 100644 --- a/src/components/tests/content-tab-overview.test.tsx +++ b/src/components/tests/content-tab-overview.test.tsx @@ -9,6 +9,7 @@ const runningSite: StartedSiteDetails = { name: 'Test Site', port: 8881, path: '/path/to/site', + phpVersion: '8.0', running: true, id: 'site-id', url: 'http://example.com', @@ -18,6 +19,7 @@ const notRunningSite: SiteDetails = { name: 'Test Site', port: 8881, path: '/path/to/site', + phpVersion: '8.0', running: false, id: 'site-id', }; diff --git a/src/components/tests/content-tab-settings.test.tsx b/src/components/tests/content-tab-settings.test.tsx index 989610f22..466906937 100644 --- a/src/components/tests/content-tab-settings.test.tsx +++ b/src/components/tests/content-tab-settings.test.tsx @@ -1,5 +1,5 @@ // To run tests, execute `npm run test -- src/components/content-tab-settings.test.tsx` from the root directory -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { useGetWpVersion } from '../../hooks/use-get-wp-version'; import { useOffline } from '../../hooks/use-offline'; @@ -17,6 +17,7 @@ const selectedSite: SiteDetails = { path: '/path/to/site', adminPassword: btoa( 'test-password' ), running: false, + phpVersion: '8.0', id: 'site-id', }; @@ -148,4 +149,84 @@ describe( 'ContentTabSettings', () => { expect( copyText ).toHaveBeenCalledWith( 'password' ); } ); } ); + + describe( 'PHP version', () => { + it( 'changes PHP version when site is not running', async () => { + const user = userEvent.setup(); + + const updateSite = jest.fn(); + const startServer = jest.fn(); + const stopServer = jest.fn(); + // Mock snapshots to include a snapshot for the selected site + ( useSiteDetails as jest.Mock ).mockReturnValue( { + selectedSite: { ...selectedSite, running: false } as SiteDetails, + snapshots: [ { localSiteId: selectedSite.id } ], + updateSite, + startServer, + stopServer, + } ); + + const { rerender } = render( ); + expect( screen.getByText( '8.0' ) ).toBeVisible(); + await user.click( screen.getByRole( 'button', { name: 'Edit PHP version' } ) ); + const dialog = screen.getByRole( 'dialog' ); + expect( dialog ).toBeVisible(); + await user.selectOptions( + within( dialog ).getByRole( 'combobox', { + name: 'PHP version', + } ), + '8.2' + ); + await user.click( + within( dialog ).getByRole( 'button', { + name: 'Save', + } ) + ); + expect( updateSite ).toHaveBeenCalledWith( expect.objectContaining( { phpVersion: '8.2' } ) ); + expect( stopServer ).not.toHaveBeenCalled(); + expect( startServer ).not.toHaveBeenCalled(); + + rerender( ); + expect( screen.getByText( '8.2' ) ).toBeVisible(); + } ); + + it( 'changes PHP version and restarts site when site is running', async () => { + const user = userEvent.setup(); + + const updateSite = jest.fn(); + const startServer = jest.fn(); + const stopServer = jest.fn(); + // Mock snapshots to include a snapshot for the selected site + ( useSiteDetails as jest.Mock ).mockReturnValue( { + selectedSite: { ...selectedSite, running: true } as SiteDetails, + snapshots: [ { localSiteId: selectedSite.id } ], + updateSite, + startServer, + stopServer, + } ); + + const { rerender } = render( ); + expect( screen.getByText( '8.0' ) ).toBeVisible(); + await user.click( screen.getByRole( 'button', { name: 'Edit PHP version' } ) ); + const dialog = screen.getByRole( 'dialog' ); + expect( dialog ).toBeVisible(); + await user.selectOptions( + within( dialog ).getByRole( 'combobox', { + name: 'PHP version', + } ), + '8.2' + ); + await user.click( + within( dialog ).getByRole( 'button', { + name: 'Save', + } ) + ); + expect( updateSite ).toHaveBeenCalledWith( expect.objectContaining( { phpVersion: '8.2' } ) ); + expect( stopServer ).toHaveBeenCalled(); + expect( startServer ).toHaveBeenCalled(); + + rerender( ); + expect( screen.getByText( '8.2' ) ).toBeVisible(); + } ); + } ); } ); diff --git a/src/components/tests/content-tab-snapshots.test.tsx b/src/components/tests/content-tab-snapshots.test.tsx index abf042f56..a66de4fc8 100644 --- a/src/components/tests/content-tab-snapshots.test.tsx +++ b/src/components/tests/content-tab-snapshots.test.tsx @@ -46,6 +46,7 @@ const selectedSite = { name: 'Test Site', running: false as const, path: '/test-site', + phpVersion: '8.0', adminPassword: btoa( 'test-password' ), }; diff --git a/src/hooks/tests/use-update-demo-site.test.tsx b/src/hooks/tests/use-update-demo-site.test.tsx index 80d1854ec..baa20d949 100644 --- a/src/hooks/tests/use-update-demo-site.test.tsx +++ b/src/hooks/tests/use-update-demo-site.test.tsx @@ -40,6 +40,7 @@ describe( 'useUpdateDemoSite', () => { const mockLocalSite: SiteDetails = { name: 'Test Site', running: false, + phpVersion: '8.0', id: '54321', path: '/path/to/site', }; diff --git a/src/ipc-handlers.ts b/src/ipc-handlers.ts index 81a2199d8..fe42ad85d 100644 --- a/src/ipc-handlers.ts +++ b/src/ipc-handlers.ts @@ -14,7 +14,7 @@ import nodePath from 'path'; import * as Sentry from '@sentry/electron/main'; import archiver from 'archiver'; import { copySync } from 'fs-extra'; -import { SQLITE_FILENAME } from '../vendor/wp-now/src/constants'; +import { SQLITE_FILENAME, DEFAULT_PHP_VERSION } from '../vendor/wp-now/src/constants'; import { downloadSqliteIntegrationPlugin } from '../vendor/wp-now/src/download'; import { executeWPCli } from '../vendor/wp-now/src/execute-wp-cli'; import { LIMIT_ARCHIVE_SIZE } from './constants'; @@ -158,6 +158,7 @@ export async function createSite( path, adminPassword: createPassword(), running: false, + phpVersion: DEFAULT_PHP_VERSION, } as const; const server = SiteServer.create( details ); diff --git a/src/ipc-types.d.ts b/src/ipc-types.d.ts index 305b0ba67..201dc8213 100644 --- a/src/ipc-types.d.ts +++ b/src/ipc-types.d.ts @@ -18,6 +18,7 @@ interface StoppedSiteDetails { name: string; path: string; port?: number; + phpVersion: string; adminPassword?: string; themeDetails?: { name: string; diff --git a/src/site-server.ts b/src/site-server.ts index 05f8e585a..9ecee6ed5 100644 --- a/src/site-server.ts +++ b/src/site-server.ts @@ -3,6 +3,7 @@ import nodePath from 'path'; import * as Sentry from '@sentry/electron/main'; import { getWpNowConfig } from '../vendor/wp-now/src'; import { WPNowMode } from '../vendor/wp-now/src/config'; +import { DEFAULT_PHP_VERSION } from '../vendor/wp-now/src/constants'; import { getWordPressVersionPath } from '../vendor/wp-now/src/download'; import { pathExists, recursiveCopyDirectory, isEmptyDir } from './lib/fs-utils'; import { decodePassword } from './lib/passwords'; @@ -74,6 +75,7 @@ export class SiteServer { port, adminPassword: decodePassword( this.details.adminPassword ?? '' ), siteTitle: this.details.name, + php: this.details.phpVersion ?? DEFAULT_PHP_VERSION, } ); const absoluteUrl = `http://localhost:${ port }`; options.absoluteUrl = absoluteUrl; @@ -99,6 +101,7 @@ export class SiteServer { ...this.details, url: this.server.url, port: this.server.options.port, + phpVersion: this.server.options.phpVersion ?? DEFAULT_PHP_VERSION, running: true, themeDetails, }; @@ -109,6 +112,7 @@ export class SiteServer { ...this.details, name: site.name, path: site.path, + phpVersion: site.phpVersion, }; } diff --git a/src/storage/user-data.ts b/src/storage/user-data.ts index ad27a70bc..d7a0cceba 100644 --- a/src/storage/user-data.ts +++ b/src/storage/user-data.ts @@ -75,7 +75,7 @@ export async function saveUserData( data: UserData ): Promise< void > { function toDiskFormat( { sites, ...rest }: UserData ): PersistedUserData { return { version: 1, - sites: sites.map( ( { id, path, adminPassword, port, name, themeDetails } ) => { + sites: sites.map( ( { id, path, adminPassword, port, phpVersion, name, themeDetails } ) => { // No object spreading allowed. TypeScript's structural typing is too permissive and // will permit us to persist properties that aren't in the type definition. // Add each property explicitly instead. @@ -85,6 +85,7 @@ function toDiskFormat( { sites, ...rest }: UserData ): PersistedUserData { path, adminPassword, port, + phpVersion, themeDetails: { name: themeDetails?.name || '', path: themeDetails?.path || '', diff --git a/src/tests/ipc-handlers.test.ts b/src/tests/ipc-handlers.test.ts index cd55f76c7..0844df1a4 100644 --- a/src/tests/ipc-handlers.test.ts +++ b/src/tests/ipc-handlers.test.ts @@ -59,6 +59,7 @@ describe( 'createSite', () => { id: expect.any( String ), name: 'Test', path: '/test', + phpVersion: '8.1', running: false, } ); } ); diff --git a/src/tests/site-server.test.ts b/src/tests/site-server.test.ts index 4f0480b8a..c1af52819 100644 --- a/src/tests/site-server.test.ts +++ b/src/tests/site-server.test.ts @@ -31,6 +31,7 @@ describe( 'SiteServer', () => { path: 'test-path', port: 1234, adminPassword: 'test-password', + phpVersion: '8.0', running: false, themeDetails: undefined, } ); diff --git a/vendor/wp-now/src/constants.ts b/vendor/wp-now/src/constants.ts index 4dce4a372..113a3b618 100644 --- a/vendor/wp-now/src/constants.ts +++ b/vendor/wp-now/src/constants.ts @@ -17,7 +17,7 @@ export const DEFAULT_PORT = 8881; /** * The default PHP version to use when running the WP Now server. */ -export const DEFAULT_PHP_VERSION = '8.0'; +export const DEFAULT_PHP_VERSION = '8.1'; /** * The default WordPress version to use when running the WP Now server.