Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .distignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
.nvmrc
.node-version
.wp-env.json
.wp-env.override.json

AGENTS.md
DESIGN.md
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ on:
- 'hotfix/[0-9]+.[0-9]+*'
paths:
- 'assets/src/**'
- 'tests/package/**'
- '.distignore'
- 'package.json'
- 'package-lock.json'
- 'postcss.config.js'
Expand All @@ -19,6 +21,8 @@ on:
pull_request:
paths:
- 'assets/src/**'
- 'tests/package/**'
- '.distignore'
- 'package.json'
- 'package-lock.json'
- 'postcss.config.js'
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .wordpress-org/blueprints/pr-preview.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
},
"preferredVersions": {
"php": "8.4",
"wp": "6.9"
"wp": "7.1.3"
},
"steps": [
{
Expand Down
2 changes: 2 additions & 0 deletions .wp-env.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{
"core": "https://wordpress.org/wordpress-7.1.3.zip",
"phpVersion": "8.3",
"plugins": [
"."
],
Expand Down
29 changes: 11 additions & 18 deletions assets/src/js/admin/SettingsApp.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 : '';
}
} );
} );
Expand All @@ -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 );
Expand All @@ -75,6 +80,7 @@ const SettingsApp = () => {
};

const handleSave = async () => {
const submittedSnapshot = { ...fieldValues };
setSaving( true );
setMessage( null );
try {
Expand All @@ -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' } );
}
Expand All @@ -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 );
Expand All @@ -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 ) {
Expand Down
126 changes: 126 additions & 0 deletions assets/src/js/admin/SettingsApp.test.jsx
Original file line number Diff line number Diff line change
@@ -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 } ) => (
<>
<label htmlFor="fixture-preconnect">
Preconnect
<input
id="fixture-preconnect"
value={ fieldValues.preconnect }
onChange={ ( event ) => onFieldChange( 'preconnect', event.target.value ) }
/>
</label>
<span>{ diagnostics.summary }</span>
</>
) );

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( <SettingsApp /> );
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( <SettingsApp /> );
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( <SettingsApp /> );
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( <SettingsApp /> );
edit( '//interrupted.example.test' );
fireEvent.click( save() );
await screen.findByText( 'Connection interrupted.' );
expect( save() ).toBeEnabled();
edit( '//original.example.test' );
expect( save() ).toBeDisabled();
} );
} );
Loading
Loading