diff --git a/.distignore b/.distignore index 9b0ffd9..8fa9daa 100644 --- a/.distignore +++ b/.distignore @@ -37,6 +37,7 @@ .nvmrc .node-version .wp-env.json +.wp-env.override.json AGENTS.md DESIGN.md diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 2b5ad78..f0b4846 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -75,6 +75,14 @@ jobs: - name: Build frontend assets run: npm run build + - name: Install the packaged candidate into the test environment + run: | + npm run plugin-zip + node tests/package/validate-package.mjs + mkdir -p build/e2e + unzip -q perform.zip -d build/e2e + node -e 'require("fs").writeFileSync(".wp-env.override.json", JSON.stringify({plugins:["./build/e2e/perform"]}))' + - name: Install Playwright browser run: npx playwright install --with-deps chromium diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 152c63e..b6b8aa0 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -9,6 +9,8 @@ on: - 'hotfix/[0-9]+.[0-9]+*' paths: - 'assets/src/**' + - 'tests/package/**' + - '.distignore' - 'package.json' - 'package-lock.json' - 'postcss.config.js' @@ -19,6 +21,8 @@ on: pull_request: paths: - 'assets/src/**' + - 'tests/package/**' + - '.distignore' - 'package.json' - 'package-lock.json' - 'postcss.config.js' @@ -71,8 +75,21 @@ jobs: - name: Lint CSS run: npm run lint:css + - name: Run JavaScript unit tests + run: npm run test:unit -- --watch=false + - name: Build production assets run: npm run build - name: Validate plugin zip generation run: npm run plugin-zip + + - name: Validate packaged runtime and metadata + run: node tests/package/validate-package.mjs + + - name: Upload candidate package + uses: actions/upload-artifact@v4 + with: + name: perform-candidate-${{ github.event.pull_request.head.sha || github.sha }} + path: perform.zip + retention-days: 14 diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 2b7ecc8..5bbf31a 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -39,7 +39,7 @@ jobs: strategy: fail-fast: false matrix: - php_version: [ '8.1', '8.2', '8.3' ] + php_version: [ '7.4', '8.1', '8.2', '8.3' ] steps: - name: Checkout code diff --git a/.wordpress-org/blueprints/pr-preview.json b/.wordpress-org/blueprints/pr-preview.json index 52a6dcd..084b39e 100644 --- a/.wordpress-org/blueprints/pr-preview.json +++ b/.wordpress-org/blueprints/pr-preview.json @@ -6,7 +6,7 @@ }, "preferredVersions": { "php": "8.4", - "wp": "6.9" + "wp": "7.1.3" }, "steps": [ { diff --git a/.wp-env.json b/.wp-env.json index f079bc9..18cd433 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -1,4 +1,6 @@ { + "core": "https://wordpress.org/wordpress-7.1.3.zip", + "phpVersion": "8.3", "plugins": [ "." ], diff --git a/assets/src/js/admin/SettingsApp.jsx b/assets/src/js/admin/SettingsApp.jsx index e1899c4..7177ac9 100644 --- a/assets/src/js/admin/SettingsApp.jsx +++ b/assets/src/js/admin/SettingsApp.jsx @@ -37,10 +37,14 @@ const SettingsApp = () => { SAVED_SETTINGS && Object.prototype.hasOwnProperty.call( SAVED_SETTINGS, f.id ) ? SAVED_SETTINGS[ f.id ] : undefined; - if ( typeof savedVal !== 'undefined' ) { + if ( typeof savedVal !== 'undefined' && ! ( 'select' === f.type && '' === savedVal ) ) { values[ f.id ] = savedVal; + } else if ( typeof f.default !== 'undefined' ) { + values[ f.id ] = f.default; + } else if ( 'select' === f.type ) { + values[ f.id ] = Object.keys( f.options || {} )[ 0 ] || ''; } else { - values[ f.id ] = f.default ?? ( f.type === 'toggle' ? false : '' ); + values[ f.id ] = f.type === 'toggle' ? false : ''; } } ); } ); @@ -49,6 +53,7 @@ const SettingsApp = () => { }, [ fields ] ); const [ fieldValues, setFieldValues ] = useState( initialValues ); + const [ savedSnapshot, setSavedSnapshot ] = useState( initialValues ); const [ saving, setSaving ] = useState( false ); const [ message, setMessage ] = useState( null ); const [ diagnostics, setDiagnostics ] = useState( INITIAL_DIAGNOSTICS ); @@ -75,6 +80,7 @@ const SettingsApp = () => { }; const handleSave = async () => { + const submittedSnapshot = { ...fieldValues }; setSaving( true ); setMessage( null ); try { @@ -86,20 +92,17 @@ const SettingsApp = () => { body: new URLSearchParams( { action: 'perform_save_settings', nonce: SETTINGS.nonce || '', - data: JSON.stringify( fieldValues ), + data: JSON.stringify( submittedSnapshot ), } ), } ); const json = await res.json(); if ( json && json.success ) { + // Later edits remain dirty until their own submission is acknowledged. + setSavedSnapshot( submittedSnapshot ); setMessage( { text: json.data?.message || 'Settings saved.', type: 'success' } ); if ( json.data?.diagnostics ) { setDiagnostics( json.data.diagnostics ); } - // update initialValues snapshot - // mutate initialValues object won't update memo, so reset by rebuild: setFieldValues equals current, but we need to reset initialValues - simplest approach: set initial snapshot to current by resetting via a state. - // We'll set the initialValues by replacing the state used for comparison: emulate by setting all initialValues to current values via a ref - but here we'll just clear dirty by resetting initialValues via resetting fieldValues baseline. - // For simplicity, update initialValues by assigning to window.performwpSettings._initial = fieldValues (not ideal), but we can update local initialValues via a small trick: setFieldValues to same and update a savedSnapshot state. - // Implement savedSnapshot state instead. } else { setMessage( { text: ( json && json.data && json.data.message ) || 'Save failed.', type: 'error' } ); } @@ -110,9 +113,6 @@ const SettingsApp = () => { } }; - // Add a savedSnapshot state to serve as baseline for dirty calculation - const [ savedSnapshot, setSavedSnapshot ] = useState( initialValues ); - useEffect( () => { // when initialValues changes (first render) set snapshot setSavedSnapshot( initialValues ); @@ -125,13 +125,6 @@ const SettingsApp = () => { return Object.keys( fieldValues ).some( ( k ) => fieldValues[ k ] !== savedSnapshot[ k ] ); }, [ fieldValues, savedSnapshot ] ); - // update savedSnapshot on successful save by watching message success - useEffect( () => { - if ( message && message.type === 'success' ) { - setSavedSnapshot( fieldValues ); - } - }, [ message, fieldValues ] ); - // Auto-dismiss message after 5 seconds useEffect( () => { if ( ! message || ! message.text ) { diff --git a/assets/src/js/admin/SettingsApp.test.jsx b/assets/src/js/admin/SettingsApp.test.jsx new file mode 100644 index 0000000..c016425 --- /dev/null +++ b/assets/src/js/admin/SettingsApp.test.jsx @@ -0,0 +1,126 @@ +/* eslint-env jest */ +import '@testing-library/jest-dom'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +jest.mock( './SettingsHeader', () => () => null ); +jest.mock( './DiagnosticsPanel', () => () => null ); +jest.mock( './SettingsNav', () => ( { fieldValues, onFieldChange, diagnostics } ) => ( + <> + + { diagnostics.summary } + +) ); + +window.performwpSettings = { + activeTab: 'assets', + tabs: { assets: 'Assets' }, + fields: { + assets: [ + { + fields: [ + { id: 'preconnect', type: 'textarea' }, + { + id: 'page_cache_ttl', + type: 'select', + default: '3600', + options: { 300: '5 minutes', 3600: '1 hour' }, + }, + { + id: 'page_cache_swr_ttl', + type: 'select', + default: '21600', + options: { 900: '15 minutes', 21600: '6 hours' }, + }, + ], + }, + ], + }, + saved: { preconnect: '//original.example.test', page_cache_swr_ttl: '' }, +}; +const SettingsApp = require( './SettingsApp' ).default; + +const edit = ( value ) => fireEvent.change( screen.getByLabelText( 'Preconnect' ), { target: { value } } ); +const save = () => screen.getByRole( 'button', { name: 'Save Settings' } ); +const success = { + success: true, + data: { message: 'Settings saved.', diagnostics: { summary: 'Refreshed diagnostics' } }, +}; + +describe( 'settings acknowledged baseline', () => { + beforeEach( () => { + window.fetch = jest.fn().mockResolvedValue( { json: async () => success } ); + } ); + + it( 'disables Save only when current values match the acknowledged submission', async () => { + render( ); + expect( save() ).toBeDisabled(); + edit( '//submitted.example.test' ); + fireEvent.click( save() ); + await screen.findByText( 'Settings saved.' ); + expect( save() ).toBeDisabled(); + expect( screen.getByText( 'Refreshed diagnostics' ) ).toBeInTheDocument(); + const submitted = JSON.parse( window.fetch.mock.calls[ 0 ][ 1 ].body.get( 'data' ) ); + expect( submitted.page_cache_ttl ).toBe( '3600' ); + expect( submitted.page_cache_swr_ttl ).toBe( '21600' ); + edit( '//later.example.test' ); + expect( save() ).toBeEnabled(); + edit( '//submitted.example.test' ); + expect( save() ).toBeDisabled(); + } ); + + it( 'retains edits made while a request is pending and submits them on the next save', async () => { + let finish; + window.fetch.mockReturnValueOnce( + new Promise( ( resolve ) => { + finish = resolve; + } ) + ); + render( ); + edit( '//submitted.example.test' ); + fireEvent.click( save() ); + expect( screen.getByRole( 'button', { name: /Saving/ } ) ).toBeDisabled(); + edit( '//pending.example.test' ); + await act( async () => finish( { json: async () => success } ) ); + expect( save() ).toBeEnabled(); + expect( JSON.parse( window.fetch.mock.calls[ 0 ][ 1 ].body.get( 'data' ) ).preconnect ).toBe( + '//submitted.example.test' + ); + fireEvent.click( save() ); + await waitFor( () => expect( save() ).toBeDisabled() ); + expect( JSON.parse( window.fetch.mock.calls[ 1 ][ 1 ].body.get( 'data' ) ).preconnect ).toBe( + '//pending.example.test' + ); + } ); + + it( 'keeps edits dirty after a rejected save and permits retry', async () => { + window.fetch.mockResolvedValueOnce( { + json: async () => ( { success: false, data: { message: 'Rejected save.' } } ), + } ); + render( ); + edit( '//rejected.example.test' ); + fireEvent.click( save() ); + await screen.findByRole( 'alert' ); + expect( save() ).toBeEnabled(); + fireEvent.click( save() ); + await screen.findByText( 'Settings saved.' ); + expect( save() ).toBeDisabled(); + } ); + + it( 'keeps edits dirty after an interrupted response', async () => { + window.fetch.mockRejectedValueOnce( new Error( 'Connection interrupted.' ) ); + render( ); + edit( '//interrupted.example.test' ); + fireEvent.click( save() ); + await screen.findByText( 'Connection interrupted.' ); + expect( save() ).toBeEnabled(); + edit( '//original.example.test' ); + expect( save() ).toBeDisabled(); + } ); +} ); diff --git a/languages/perform.pot b/languages/perform.pot index 21da56c..5e03ed5 100644 --- a/languages/perform.pot +++ b/languages/perform.pot @@ -7,7 +7,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Language-Team: PerformWP \n" -"POT-Creation-Date: 2026-08-11 05:16+0000\n" +"POT-Creation-Date: 2026-10-07 11:28+0000\n" "Report-Msgid-Bugs-To: https://github.com/performwp/perform/issues/new\n" "X-Poedit-Basepath: ..\n" "X-Poedit-KeywordsList: __;_e;_ex:1,2c;_n:1,2;_n_noop:1,2;_nx:1,2,4c;_nx_noop:1,2,3c;_x:1,2c;esc_attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n" @@ -384,7 +384,7 @@ msgstr "" msgid "4 Minutes" msgstr "" -#: src/Includes/Helpers.php:612, src/Includes/Helpers.php:747 +#: src/Includes/Helpers.php:612, src/Includes/Helpers.php:748 msgid "5 Minutes" msgstr "" @@ -480,219 +480,219 @@ msgstr "" msgid "Caches full HTML responses for anonymous visitors." msgstr "" -#: src/Includes/Helpers.php:745 +#: src/Includes/Helpers.php:746 msgid "Cache TTL" msgstr "" -#: src/Includes/Helpers.php:748, src/Includes/Helpers.php:767 +#: src/Includes/Helpers.php:749, src/Includes/Helpers.php:769 msgid "15 Minutes" msgstr "" -#: src/Includes/Helpers.php:749, src/Includes/Helpers.php:768 +#: src/Includes/Helpers.php:750, src/Includes/Helpers.php:770 msgid "30 Minutes" msgstr "" -#: src/Includes/Helpers.php:750, src/Includes/Helpers.php:769 +#: src/Includes/Helpers.php:751, src/Includes/Helpers.php:771 msgid "1 Hour" msgstr "" -#: src/Includes/Helpers.php:751, src/Includes/Helpers.php:770 +#: src/Includes/Helpers.php:752, src/Includes/Helpers.php:772 msgid "6 Hours" msgstr "" -#: src/Includes/Helpers.php:752, src/Includes/Helpers.php:772 +#: src/Includes/Helpers.php:753, src/Includes/Helpers.php:774 msgid "24 Hours" msgstr "" -#: src/Includes/Helpers.php:754 +#: src/Includes/Helpers.php:755 msgid "How long a page remains fresh before becoming stale." msgstr "" -#: src/Includes/Helpers.php:765 +#: src/Includes/Helpers.php:767 msgid "Stale Revalidate Window" msgstr "" -#: src/Includes/Helpers.php:771 +#: src/Includes/Helpers.php:773 msgid "12 Hours" msgstr "" -#: src/Includes/Helpers.php:774 +#: src/Includes/Helpers.php:776 msgid "Serve stale pages while refreshing them in the background." msgstr "" -#: src/Includes/Helpers.php:785 +#: src/Includes/Helpers.php:787 msgid "Separate Query Params" msgstr "" -#: src/Includes/Helpers.php:786 +#: src/Includes/Helpers.php:788 msgid "Comma-separated query keys that should generate separate cache entries. Tracking params are ignored automatically." msgstr "" -#: src/Includes/Helpers.php:797 +#: src/Includes/Helpers.php:799 msgid "Bypass Exact Paths" msgstr "" -#: src/Includes/Helpers.php:798 +#: src/Includes/Helpers.php:800 msgid "One URL path per line that should bypass full-page cache exactly, such as /account/dashboard." msgstr "" -#: src/Includes/Helpers.php:811 +#: src/Includes/Helpers.php:813 msgid "Bypass Path Prefixes" msgstr "" -#: src/Includes/Helpers.php:812 +#: src/Includes/Helpers.php:814 msgid "One URL path prefix per line that should bypass full-page cache, such as /checkout or /private." msgstr "" -#: src/Includes/Helpers.php:825 +#: src/Includes/Helpers.php:827 msgid "Bypass Query Keys" msgstr "" -#: src/Includes/Helpers.php:826 +#: src/Includes/Helpers.php:828 msgid "Query keys that should bypass cache reads and writes entirely. This is separate from query keys that create separate cache entries." msgstr "" -#: src/Includes/Helpers.php:839 +#: src/Includes/Helpers.php:841 msgid "Bypass Cookie Names" msgstr "" -#: src/Includes/Helpers.php:840 +#: src/Includes/Helpers.php:842 msgid "Cookie names that should bypass cache reads and writes. Cookie values are never needed for matching." msgstr "" -#: src/Includes/Helpers.php:853 +#: src/Includes/Helpers.php:855 msgid "Bypass Cookie Prefixes" msgstr "" -#: src/Includes/Helpers.php:854 +#: src/Includes/Helpers.php:856 msgid "Cookie name prefixes that should bypass cache reads and writes, such as wp-postpass_ or custom_session_." msgstr "" -#: src/Includes/Helpers.php:867 +#: src/Includes/Helpers.php:869 msgid "Preload and Observability" msgstr "" -#: src/Includes/Helpers.php:868 +#: src/Includes/Helpers.php:870 msgid "Warm cache from sitemaps and monitor cache effectiveness." msgstr "" -#: src/Includes/Helpers.php:873 +#: src/Includes/Helpers.php:875 msgid "Enable Adaptive Preloader" msgstr "" -#: src/Includes/Helpers.php:874 +#: src/Includes/Helpers.php:876 msgid "Preloads cache from sitemap and high-miss URLs using adaptive request rate." msgstr "" -#: src/Includes/Helpers.php:885 +#: src/Includes/Helpers.php:888 msgid "Slow Request Threshold" msgstr "" -#: src/Includes/Helpers.php:887 +#: src/Includes/Helpers.php:890 msgid "500ms" msgstr "" -#: src/Includes/Helpers.php:888 +#: src/Includes/Helpers.php:891 msgid "800ms" msgstr "" -#: src/Includes/Helpers.php:889 +#: src/Includes/Helpers.php:892 msgid "1200ms" msgstr "" -#: src/Includes/Helpers.php:890 +#: src/Includes/Helpers.php:893 msgid "2000ms" msgstr "" -#: src/Includes/Helpers.php:892 +#: src/Includes/Helpers.php:895 msgid "Tracks uncached requests slower than this value in observability stats." msgstr "" -#: src/Includes/Helpers.php:903 +#: src/Includes/Helpers.php:906 msgid "Cloudflare" msgstr "" -#: src/Includes/Helpers.php:904 +#: src/Includes/Helpers.php:907 msgid "Sync local cache purge events to Cloudflare (free-tier friendly)." msgstr "" -#: src/Includes/Helpers.php:909 +#: src/Includes/Helpers.php:912 msgid "Enable Cloudflare Cache Sync" msgstr "" -#: src/Includes/Helpers.php:910 +#: src/Includes/Helpers.php:913 msgid "Purge Cloudflare URLs when Perform purges local cache." msgstr "" -#: src/Includes/Helpers.php:921 +#: src/Includes/Helpers.php:924 msgid "Cloudflare Zone ID" msgstr "" -#: src/Includes/Helpers.php:922 +#: src/Includes/Helpers.php:925 msgid "Your Cloudflare Zone ID." msgstr "" -#: src/Includes/Helpers.php:933 +#: src/Includes/Helpers.php:936 msgid "Cloudflare API Token" msgstr "" -#: src/Includes/Helpers.php:934 +#: src/Includes/Helpers.php:937 msgid "API token with Zone:Cache Purge permission." msgstr "" -#: src/Includes/Helpers.php:947 +#: src/Includes/Helpers.php:950 msgid "Advanced Settings" msgstr "" -#: src/Includes/Helpers.php:948 +#: src/Includes/Helpers.php:951 msgid "Settings for advanced configurations." msgstr "" -#: src/Includes/Helpers.php:953 +#: src/Includes/Helpers.php:956 msgid "Admin Performance Monitor" msgstr "" -#: src/Includes/Helpers.php:954 +#: src/Includes/Helpers.php:957 msgid "Collect bounded, private aggregate timing, memory, and query-count diagnostics for WordPress admin requests." msgstr "" -#: src/Includes/Helpers.php:959 +#: src/Includes/Helpers.php:962 msgid "Admin Asset Audit" msgstr "" -#: src/Includes/Helpers.php:960 +#: src/Includes/Helpers.php:963 msgid "Temporarily inventory script and style handles across sampled WordPress admin screens without disabling anything." msgstr "" -#: src/Includes/Helpers.php:965 +#: src/Includes/Helpers.php:968 msgid "Remove Data on Uninstall" msgstr "" -#: src/Includes/Helpers.php:966 +#: src/Includes/Helpers.php:969 msgid "Enabling this will remove all plugin data upon uninstallation." msgstr "" -#: src/Includes/Helpers.php:979 +#: src/Includes/Helpers.php:982 msgid "WooCommerce Settings" msgstr "" -#: src/Includes/Helpers.php:980 +#: src/Includes/Helpers.php:983 msgid "Settings specific to WooCommerce." msgstr "" -#: src/Includes/Helpers.php:985 +#: src/Includes/Helpers.php:988 msgid "Enable WooCommerce Manager" msgstr "" -#: src/Includes/Helpers.php:986 +#: src/Includes/Helpers.php:989 msgid "Enabling this will allow you to manage WooCommerce specific settings." msgstr "" -#: src/Includes/Helpers.php:997 +#: src/Includes/Helpers.php:1000 msgid "Enable WooCommerce Cache" msgstr "" -#: src/Includes/Helpers.php:998 +#: src/Includes/Helpers.php:1001 msgid "Enabling this will cache WooCommerce pages for better performance." msgstr "" @@ -880,11 +880,15 @@ msgstr "" msgid "Cache Stats" msgstr "" -#: src/Admin/Settings/Menu.php:243 +#: src/Admin/Settings/Menu.php:199 +msgid "Settings data is invalid. Please try again." +msgstr "" + +#: src/Admin/Settings/Menu.php:245 msgid "Unable to save the settings. Please try again." msgstr "" -#: src/Admin/Settings/Menu.php:235 +#: src/Admin/Settings/Menu.php:237 msgid "Settings saved successfully." msgstr "" diff --git a/readme.txt b/readme.txt index 11ccd28..9ca1e34 100644 --- a/readme.txt +++ b/readme.txt @@ -68,6 +68,8 @@ Contributions and bug reports welcome on GitHub: https://github.com/performwp/pe == Changelog == = 1.8.0 = +- Preserved unsaved settings edits during saving and success notifications. +- Preserved numeric cache settings and their established defaults when saving the whole form. - Added privacy-conscious site inventory and system-health guidance for performance troubleshooting. - Added bounded diagnostics for autoloaded options, object cache status, slow admin screens, scheduled tasks, admin assets, plugin impact, and Action Scheduler queues. - Added a performance health dashboard that reports observed evidence without claiming unmeasured savings. diff --git a/src/Admin/Settings/ActionSchedulerAudit.php b/src/Admin/Settings/ActionSchedulerAudit.php index 9a8de4b..0aaec8d 100644 --- a/src/Admin/Settings/ActionSchedulerAudit.php +++ b/src/Admin/Settings/ActionSchedulerAudit.php @@ -50,7 +50,7 @@ public static function refresh() { global $wpdb; if ( ! is_object( $wpdb ) || empty( $wpdb->prefix ) ) { - throw new RuntimeException( __( 'The database is unavailable.', 'perform' ) ); + throw new RuntimeException( esc_html__( 'The database is unavailable.', 'perform' ) ); } $actions_table = $wpdb->prefix . 'actionscheduler_actions'; @@ -101,7 +101,7 @@ public static function refresh() { // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( ! is_array( $status_rows ) || ! is_array( $hook_rows ) || ! is_array( $group_rows ) || ! empty( $wpdb->last_error ) ) { - throw new RuntimeException( __( 'The Action Scheduler diagnostic could not be completed.', 'perform' ) ); + throw new RuntimeException( esc_html__( 'The Action Scheduler diagnostic could not be completed.', 'perform' ) ); } $counts = [ @@ -278,7 +278,7 @@ private static function persist( array $snapshot ) { $snapshot['isStale'] = false; $is_saved = update_option( self::OPTION_NAME, $snapshot, false ); if ( ! $is_saved && get_option( self::OPTION_NAME ) !== $snapshot ) { - throw new RuntimeException( __( 'The Action Scheduler diagnostic could not be saved.', 'perform' ) ); + throw new RuntimeException( esc_html__( 'The Action Scheduler diagnostic could not be saved.', 'perform' ) ); } return $snapshot; } diff --git a/src/Admin/Settings/AutoloadOptionsAudit.php b/src/Admin/Settings/AutoloadOptionsAudit.php index 9f78948..79d4153 100644 --- a/src/Admin/Settings/AutoloadOptionsAudit.php +++ b/src/Admin/Settings/AutoloadOptionsAudit.php @@ -52,7 +52,7 @@ public static function refresh() { global $wpdb; if ( ! is_object( $wpdb ) || empty( $wpdb->options ) ) { - throw new RuntimeException( __( 'The options table is unavailable.', 'perform' ) ); + throw new RuntimeException( esc_html__( 'The options table is unavailable.', 'perform' ) ); } $autoload_values = self::get_autoload_values(); @@ -78,7 +78,7 @@ public static function refresh() { $options = $wpdb->get_results( $options_query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared above; manual diagnostic persisted below. if ( ! is_array( $summary ) || ! is_array( $options ) || ! empty( $wpdb->last_error ) ) { - throw new RuntimeException( __( 'The options audit could not be completed.', 'perform' ) ); + throw new RuntimeException( esc_html__( 'The options audit could not be completed.', 'perform' ) ); } $generated_at = time(); @@ -104,7 +104,7 @@ public static function refresh() { $is_saved = update_option( self::OPTION_NAME, $snapshot, false ); if ( ! $is_saved && get_option( self::OPTION_NAME ) !== $snapshot ) { - throw new RuntimeException( __( 'The audit result could not be saved.', 'perform' ) ); + throw new RuntimeException( esc_html__( 'The audit result could not be saved.', 'perform' ) ); } return $snapshot; diff --git a/src/Admin/Settings/CronPressureAudit.php b/src/Admin/Settings/CronPressureAudit.php index 57c5e4e..c3ac314 100644 --- a/src/Admin/Settings/CronPressureAudit.php +++ b/src/Admin/Settings/CronPressureAudit.php @@ -150,7 +150,7 @@ public static function refresh() { $is_saved = update_option( self::OPTION_NAME, $snapshot, false ); if ( ! $is_saved && get_option( self::OPTION_NAME ) !== $snapshot ) { - throw new RuntimeException( __( 'The scheduled-task diagnostic could not be saved.', 'perform' ) ); + throw new RuntimeException( esc_html__( 'The scheduled-task diagnostic could not be saved.', 'perform' ) ); } return $snapshot; diff --git a/src/Admin/Settings/Menu.php b/src/Admin/Settings/Menu.php index 5d56453..21a8bf2 100644 --- a/src/Admin/Settings/Menu.php +++ b/src/Admin/Settings/Menu.php @@ -185,20 +185,22 @@ public function save_settings() { $sanitized_post[ $key ] = esc_url_raw( $raw_val ); break; case 'select': - // Ensure value is one of allowed options when provided. - $opts = $field_def['options'] ?? []; - $is_ok = false; + // PHP converts numeric option keys to integers; the browser posts strings. + $opts = $field_def['options'] ?? []; + $allowed_values = []; if ( is_array( $opts ) && ! empty( $opts ) ) { - // If associative array (value=>label) check keys, otherwise check values. - $keys = array_keys( $opts ); - $vals = array_values( $opts ); - if ( array_diff_key( $opts, array_values( $opts ) ) ) { - $is_ok = in_array( $raw_val, $keys, true ); - } else { - $is_ok = in_array( $raw_val, $vals, true ); - } + $is_list = array_keys( $opts ) === range( 0, count( $opts ) - 1 ); + $allowed_values = array_map( 'strval', $is_list ? $opts : array_keys( $opts ) ); } - $sanitized_post[ $key ] = $is_ok ? sanitize_text_field( $raw_val ) : ''; + if ( ! in_array( (string) $raw_val, $allowed_values, true ) ) { + wp_send_json_error( + [ + 'type' => 'error', + 'message' => esc_html__( 'Settings data is invalid. Please try again.', 'perform' ), + ] + ); + } + $sanitized_post[ $key ] = sanitize_text_field( $raw_val ); break; case 'number': $sanitized_post[ $key ] = is_numeric( $raw_val ) ? intval( $raw_val ) : 0; diff --git a/src/Admin/Settings/SiteInventory.php b/src/Admin/Settings/SiteInventory.php index e6737bf..e6a40d9 100644 --- a/src/Admin/Settings/SiteInventory.php +++ b/src/Admin/Settings/SiteInventory.php @@ -85,7 +85,7 @@ public static function refresh() { $is_saved = update_option( self::OPTION_NAME, $snapshot, false ); if ( ! $is_saved && get_option( self::OPTION_NAME ) !== $snapshot ) { - throw new RuntimeException( __( 'The site inventory could not be saved.', 'perform' ) ); + throw new RuntimeException( esc_html__( 'The site inventory could not be saved.', 'perform' ) ); } return $snapshot; diff --git a/src/Admin/Settings/SystemHealth.php b/src/Admin/Settings/SystemHealth.php index 572d7cb..b1a3049 100644 --- a/src/Admin/Settings/SystemHealth.php +++ b/src/Admin/Settings/SystemHealth.php @@ -146,7 +146,7 @@ private static function execution_time_signal() { ? __( 'The limit supports ordinary WordPress maintenance tasks.', 'perform' ) : __( 'Short limits can interrupt imports, updates, or maintenance; review with your host.', 'perform' ); - return self::signal( 'execution-time', __( 'Maximum execution time', 'perform' ), sprintf( __( '%d seconds', 'perform' ), $seconds ), $status, $copy ); + return self::signal( 'execution-time', __( 'Maximum execution time', 'perform' ), sprintf( /* translators: %d: Maximum execution time in seconds. */ __( '%d seconds', 'perform' ), $seconds ), $status, $copy ); } /** diff --git a/src/Includes/Helpers.php b/src/Includes/Helpers.php index 819dba6..c326e5d 100644 --- a/src/Includes/Helpers.php +++ b/src/Includes/Helpers.php @@ -742,6 +742,7 @@ public static function get_settings_fields() { [ 'id' => 'page_cache_ttl', 'type' => 'select', + 'default' => '3600', 'name' => esc_html__( 'Cache TTL', 'perform' ), 'options' => [ '300' => esc_html__( '5 Minutes', 'perform' ), @@ -762,6 +763,7 @@ public static function get_settings_fields() { [ 'id' => 'page_cache_swr_ttl', 'type' => 'select', + 'default' => '21600', 'name' => esc_html__( 'Stale Revalidate Window', 'perform' ), 'options' => [ '900' => esc_html__( '15 Minutes', 'perform' ), @@ -882,6 +884,7 @@ public static function get_settings_fields() { [ 'id' => 'cache_slow_request_threshold_ms', 'type' => 'select', + 'default' => '1200', 'name' => esc_html__( 'Slow Request Threshold', 'perform' ), 'options' => [ '500' => esc_html__( '500ms', 'perform' ), diff --git a/src/Modules/Cache/PageCache.php b/src/Modules/Cache/PageCache.php index abca636..c2d59d0 100644 --- a/src/Modules/Cache/PageCache.php +++ b/src/Modules/Cache/PageCache.php @@ -833,7 +833,7 @@ public function render_observability_page() { -

+

diff --git a/tests/e2e/perform-smoke.spec.js b/tests/e2e/perform-smoke.spec.js index 69595e1..c7dc5c0 100644 --- a/tests/e2e/perform-smoke.spec.js +++ b/tests/e2e/perform-smoke.spec.js @@ -484,3 +484,67 @@ test( 'lower-privilege users cannot access the legacy or canonical Cache Stats r expect( adminAssetResponse.status() ).toBe( 403 ); expect( await adminAssetResponse.json() ).toMatchObject( { success: false } ); } ); +test( 'settings retain edits during pending saves, success notices, and interrupted retries', async ( { + page, +} ) => { + await login( page ); + await openAdminPage( + page, + '/wp-admin/options-general.php?page=perform_settings&tab=assets' + ); + const preconnect = page.getByRole( 'textbox', { name: 'Preconnect' } ); + const saveButton = page.getByRole( 'button', { name: 'Save Settings' } ); + const submitted = `//submitted-${ Date.now() }.example.test`; + const later = `//later-${ Date.now() }.example.test`; + let releaseRequest; + const pending = new Promise( ( resolve ) => { + releaseRequest = resolve; + } ); + let requestStarted; + const started = new Promise( ( resolve ) => { + requestStarted = resolve; + } ); + await page.route( '**/admin-ajax.php', async ( route ) => { + if ( + route + .request() + .postData() + ?.includes( 'action=perform_save_settings' ) + ) { + requestStarted(); + await pending; + } + await route.continue(); + } ); + await preconnect.fill( submitted ); + await saveButton.click(); + await started; + await preconnect.fill( later ); + releaseRequest(); + await expect( page.getByRole( 'status' ) ).toContainText( + 'Settings saved' + ); + await expect( saveButton ).toBeEnabled(); + await page.unroute( '**/admin-ajax.php' ); + await saveButton.focus(); + await page.keyboard.press( 'Enter' ); + await expect( saveButton ).toBeDisabled(); + await preconnect.fill( `${ later }\n//notice.example.test` ); + await expect( saveButton ).toBeEnabled(); + await page.route( '**/admin-ajax.php', ( route ) => + route.abort( 'failed' ) + ); + await saveButton.click(); + await expect( page.getByRole( 'alert' ) ).toBeVisible(); + await expect( saveButton ).toBeEnabled(); + await page.unroute( '**/admin-ajax.php' ); + await saveButton.click(); + await expect( page.getByRole( 'status' ) ).toContainText( + 'Settings saved' + ); + await expect( saveButton ).toBeDisabled(); + await page.reload(); + await expect( preconnect ).toHaveValue( + `${ later }\n//notice.example.test` + ); +} ); diff --git a/tests/package/validate-package.mjs b/tests/package/validate-package.mjs new file mode 100644 index 0000000..4c436ce --- /dev/null +++ b/tests/package/validate-package.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; + +const zip = process.argv[ 2 ] || 'perform.zip'; +const entries = execFileSync( 'unzip', [ '-Z1', zip ], { encoding: 'utf8' } ).trim().split( '\n' ); +const readEntry = ( path ) => execFileSync( 'unzip', [ '-p', zip, `perform/${ path }` ], { encoding: 'utf8' } ); +const version = JSON.parse( readFileSync( 'package.json', 'utf8' ) ).version; + +for ( const path of entries ) { + assert.ok( path.startsWith( 'perform/' ) && ! path.split( '/' ).includes( '..' ), `Unexpected ZIP path: ${ path }` ); + assert.ok( ! /^perform\/(?:tests|node_modules|build|\.git|\.github|assets\/src|vendor\/(?:bin|phpunit|phpstan))\//.test( path ), `Development content: ${ path }` ); + assert.ok( ! /^perform\/\.wp-env/.test( path ), `Environment configuration: ${ path }` ); +} +for ( const path of [ + 'perform.php', 'readme.txt', 'config/constants.php', 'src/Plugin.php', + 'assets/dist/js/admin.min.js', 'assets/dist/js/perform.min.js', + 'assets/dist/css/admin.css', 'assets/dist/css/perform.css', + 'vendor/autoload.php', 'vendor/freemius/wordpress-sdk/start.php', +] ) { + assert.ok( readEntry( path ).length > 0, `Missing or empty runtime file: ${ path }` ); +} +assert.equal( readEntry( 'perform.php' ).match( /\* Version:\s*(\S+)/ )[ 1 ], version ); +assert.equal( readEntry( 'config/constants.php' ).match( /'PERFORM_VERSION',\s*'([^']+)'/ )[ 1 ], version ); +assert.equal( readEntry( 'readme.txt' ).match( /^Stable tag:\s*(\S+)/m )[ 1 ], version ); +assert.equal( readEntry( 'readme.txt' ).match( /^Tested up to:\s*(\S+)/m )[ 1 ], '7.1' ); +assert.equal( readEntry( 'readme.txt' ).match( /^Requires PHP:\s*(\S+)/m )[ 1 ], '7.4' ); +const dependencies = JSON.parse( readEntry( 'vendor/composer/installed.json' ) ); +assert.equal( dependencies.dev, false, 'Development Composer install was packaged' ); +assert.deepEqual( dependencies.packages.map( ( dependency ) => dependency.name ).sort(), [ 'composer/installers', 'freemius/wordpress-sdk' ] ); +assert.ok( ! entries.some( ( path ) => path.startsWith( 'perform/vendor/composer/installers/' ) ), 'Build-only installer files were packaged' ); +console.log( JSON.stringify( { version, files: entries.length, sha256: createHash( 'sha256' ).update( readFileSync( zip ) ).digest( 'hex' ), result: 'pass' } ) ); diff --git a/tests/unit-tests/tests-settings-menu.php b/tests/unit-tests/tests-settings-menu.php index 652d9c1..2edfe6f 100644 --- a/tests/unit-tests/tests-settings-menu.php +++ b/tests/unit-tests/tests-settings-menu.php @@ -2,6 +2,8 @@ use PHPUnit\Framework\TestCase; use Perform\Admin\Settings\Menu; +use Perform\Admin\Settings\ClientPayload; +use Perform\Includes\Helpers; final class Tests_Settings_Menu extends TestCase { protected function setUp(): void { @@ -105,6 +107,79 @@ public function test_multiline_settings_preserve_line_boundaries() { ); } + public function test_full_form_save_preserves_numeric_selects_secrets_and_legacy_values() { + $paths = array_map( + static function ( $index ) { + return '/private-' . $index; + }, + range( 1, 125 ) + ); + $long_hint = 'https://example.com/' . str_repeat( 'a', 2200 ); + $settings = [ + 'page_cache_ttl' => '3600', + 'cloudflare_api_token' => 'fixture-secret', + 'cache_bypass_exact_paths' => $paths, + 'preconnect' => [ $long_hint ], + 'extension_value' => [ 'legacy' => 'preserved' ], + ]; + $payload = ClientPayload::sanitize_for_client( $settings ); + foreach ( Helpers::get_settings_fields() as $cards ) { + foreach ( $cards as $card ) { + foreach ( $card['fields'] ?? [] as $field ) { + if ( isset( $payload[ $field['id'] ] ) ) { + continue; + } + $payload[ $field['id'] ] = $field['default'] ?? ( 'toggle' === $field['type'] ? false : ( 'select' === $field['type'] ? (string) array_key_first( $field['options'] ) : '' ) ); + } + } + } + $GLOBALS['perform_test_options']['perform_settings'] = $settings; + $GLOBALS['perform_test_nonce_valid'] = true; + $_POST = [ + 'nonce' => 'valid', + 'data' => wp_json_encode( $payload ), + ]; + try { + ( new Menu() )->save_settings(); + $this->fail( 'Expected JSON response.' ); + } catch ( RuntimeException $exception ) { + $this->assertTrue( $GLOBALS['perform_test_json_response']['success'] ); + } + $saved = $GLOBALS['perform_test_options']['perform_settings']; + $this->assertSame( '3600', $saved['page_cache_ttl'] ); + $this->assertSame( '21600', $saved['page_cache_swr_ttl'] ); + $this->assertSame( '1200', $saved['cache_slow_request_threshold_ms'] ); + $this->assertSame( 'fixture-secret', $saved['cloudflare_api_token'] ); + $this->assertSame( $paths, $saved['cache_bypass_exact_paths'] ); + $this->assertSame( [ $long_hint ], $saved['preconnect'] ); + $this->assertSame( $settings['extension_value'], $saved['extension_value'] ); + } + + public function test_invalid_select_rejects_whole_save_without_changing_settings() { + $settings = [ + 'page_cache_ttl' => '3600', + 'enable_ssl' => 0, + ]; + $GLOBALS['perform_test_options']['perform_settings'] = $settings; + $GLOBALS['perform_test_nonce_valid'] = true; + $_POST = [ + 'nonce' => 'valid', + 'data' => wp_json_encode( + [ + 'enable_ssl' => true, + 'page_cache_ttl' => 'invalid', + ] + ), + ]; + try { + ( new Menu() )->save_settings(); + $this->fail( 'Expected JSON response.' ); + } catch ( RuntimeException $exception ) { + $this->assertFalse( $GLOBALS['perform_test_json_response']['success'] ); + } + $this->assertSame( $settings, $GLOBALS['perform_test_options']['perform_settings'] ); + } + public function test_existing_multiline_arrays_keep_their_storage_shape() { $menu = new Menu(); $method = new ReflectionMethod( $menu, 'normalize_multiline_setting' );