diff options
-rw-r--r-- | .github/workflows/performance.yml | 170 | ||||
-rw-r--r-- | tests/performance/compare-results.js | 242 | ||||
-rw-r--r-- | tests/performance/config/global-setup.js | 4 | ||||
-rw-r--r-- | tests/performance/config/performance-reporter.js | 74 | ||||
-rw-r--r-- | tests/performance/log-results.js | 123 | ||||
-rw-r--r-- | tests/performance/playwright.config.js | 3 | ||||
-rw-r--r-- | tests/performance/results.js | 42 | ||||
-rw-r--r-- | tests/performance/specs/admin-l10n.test.js | 52 | ||||
-rw-r--r-- | tests/performance/specs/admin.test.js | 72 | ||||
-rw-r--r-- | tests/performance/specs/home-block-theme-l10n.test.js | 63 | ||||
-rw-r--r-- | tests/performance/specs/home-block-theme.test.js | 57 | ||||
-rw-r--r-- | tests/performance/specs/home-classic-theme-l10n.test.js | 62 | ||||
-rw-r--r-- | tests/performance/specs/home-classic-theme.test.js | 56 | ||||
-rw-r--r-- | tests/performance/specs/home.test.js | 79 | ||||
-rw-r--r-- | tests/performance/utils.js | 177 | ||||
-rw-r--r-- | tests/performance/wp-content/mu-plugins/server-timing.php | 53 |
16 files changed, 683 insertions, 646 deletions
diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 62663c730b..d79ae5f801 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -66,38 +66,45 @@ jobs: # - Install WordPress. # - Install WordPress Importer plugin. # - Import mock data. + # - Deactivate WordPress Importer plugin. # - Update permalink structure. + # - Install additional languages. + # - Disable external HTTP requests. + # - Disable cron. + # - List defined constants. # - Install MU plugin. # - Run performance tests (current commit). - # - Print performance tests results. - # - Check out target commit (target branch or previous commit). - # - Switch Node.js versions if necessary. - # - Install npm dependencies. - # - Build WordPress. + # - Download previous build artifact (target branch or previous commit). + # - Download artifact. + # - Unzip the build. # - Run any database upgrades. + # - Flush cache. + # - Delete expired transients. # - Run performance tests (previous/target commit). - # - Print target performance tests results. - # - Reset to original commit. - # - Switch Node.js versions if necessary. - # - Install npm dependencies. # - Set the environment to the baseline version. # - Run any database upgrades. + # - Flush cache. + # - Delete expired transients. # - Run baseline performance tests. - # - Print baseline performance tests results. - # - Compare results with base. + # - Archive artifacts. + # - Compare results. # - Add workflow summary. # - Set the base sha. # - Set commit details. # - Publish performance results. # - Ensure version-controlled files are not modified or deleted. - # - Dispatch workflow run. performance: - name: Run performance tests + name: Run performance tests ${{ matrix.memcached && '(with memcached)' || '' }} runs-on: ubuntu-latest permissions: contents: read if: ${{ ( github.repository == 'WordPress/wordpress-develop' || github.event_name == 'pull_request' ) && ! contains( github.event.before, '00000000' ) }} - + strategy: + fail-fast: false + matrix: + memcached: [ true, false ] + env: + LOCAL_PHP_MEMCACHED: ${{ matrix.memcached }} steps: - name: Configure environment variables run: | @@ -127,14 +134,17 @@ jobs: run: npm ci - name: Install Playwright browsers - run: npx playwright install --with-deps + run: npx playwright install --with-deps chromium - name: Build WordPress run: npm run build - name: Start Docker environment - run: | - npm run env:start + run: npm run env:start + + - name: Install object cache drop-in + if: ${{ matrix.memcached }} + run: cp src/wp-content/object-cache.php build/wp-content/object-cache.php - name: Log running Docker containers run: docker ps -a @@ -160,9 +170,11 @@ jobs: npm run env:cli -- import themeunittestdata.wordpress.xml --authors=create --path=/var/www/${{ env.LOCAL_DIR }} rm themeunittestdata.wordpress.xml + - name: Deactivate WordPress Importer plugin + run: npm run env:cli -- plugin deactivate wordpress-importer --path=/var/www/${{ env.LOCAL_DIR }} + - name: Update permalink structure - run: | - npm run env:cli -- rewrite structure '/%year%/%monthnum%/%postname%/' --path=/var/www/${{ env.LOCAL_DIR }} + run: npm run env:cli -- rewrite structure '/%year%/%monthnum%/%postname%/' --path=/var/www/${{ env.LOCAL_DIR }} - name: Install additional languages run: | @@ -170,6 +182,17 @@ jobs: npm run env:cli -- language plugin install de_DE --all --path=/var/www/${{ env.LOCAL_DIR }} npm run env:cli -- language theme install de_DE --all --path=/var/www/${{ env.LOCAL_DIR }} + # Prevent background update checks from impacting test stability. + - name: Disable external HTTP requests + run: npm run env:cli -- config set WP_HTTP_BLOCK_EXTERNAL true --raw --type=constant --path=/var/www/${{ env.LOCAL_DIR }} + + # Prevent background tasks from impacting test stability. + - name: Disable cron + run: npm run env:cli -- config set DISABLE_WP_CRON true --raw --type=constant --path=/var/www/${{ env.LOCAL_DIR }} + + - name: List defined constants + run: npm run env:cli -- config list --path=/var/www/${{ env.LOCAL_DIR }} + - name: Install MU plugin run: | mkdir ./${{ env.LOCAL_DIR }}/wp-content/mu-plugins @@ -178,74 +201,93 @@ jobs: - name: Run performance tests (current commit) run: npm run test:performance - - name: Print performance tests results - run: node ./tests/performance/results.js + - name: Download previous build artifact (target branch or previous commit) + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + id: get-previous-build + with: + script: | + const artifacts = await github.rest.actions.listArtifactsForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'wordpress-build-' + process.env.TARGET_SHA, + }); + + const matchArtifact = artifacts.data.artifacts[0]; - - name: Check out target commit (target branch or previous commit) - run: | - if [[ -z "$TARGET_REF" ]]; then - git fetch -n origin $TARGET_SHA - else - git fetch -n origin $TARGET_REF - fi - git reset --hard $TARGET_SHA + if ( ! matchArtifact ) { + core.setFailed( 'No artifact found!' ); + return false; + } - - name: Set up Node.js - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 - with: - node-version-file: '.nvmrc' - cache: npm + const download = await github.rest.actions.downloadArtifact( { + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + } ); - - name: Install npm dependencies - run: npm ci + const fs = require( 'fs' ); + fs.writeFileSync( '${{ github.workspace }}/before.zip', Buffer.from( download.data ) ) - - name: Build WordPress - run: npm run build + return true; + + - name: Unzip the build + if: ${{ steps.get-previous-build.outputs.result }} + run: | + unzip ${{ github.workspace }}/before.zip + unzip -o ${{ github.workspace }}/wordpress.zip - name: Run any database upgrades + if: ${{ steps.get-previous-build.outputs.result }} run: npm run env:cli -- core update-db --path=/var/www/${{ env.LOCAL_DIR }} - - name: Run target performance tests (base/previous commit) - env: - TEST_RESULTS_PREFIX: before - run: npm run test:performance + - name: Flush cache + if: ${{ steps.get-previous-build.outputs.result }} + run: npm run env:cli -- cache flush --path=/var/www/${{ env.LOCAL_DIR }} + + - name: Delete expired transients + if: ${{ steps.get-previous-build.outputs.result }} + run: npm run env:cli -- transient delete --expired --path=/var/www/${{ env.LOCAL_DIR }} - - name: Print target performance tests results + - name: Run target performance tests (previous/target commit) + if: ${{ steps.get-previous-build.outputs.result }} env: TEST_RESULTS_PREFIX: before - run: node ./tests/performance/results.js - - - name: Reset to original commit - run: git reset --hard $GITHUB_SHA - - - name: Set up Node.js - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 - with: - node-version-file: '.nvmrc' - cache: npm - - - name: Install npm dependencies - run: npm ci + run: npm run test:performance - name: Set the environment to the baseline version + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/trunk' }} run: | npm run env:cli -- core update --version=${{ env.BASE_TAG }} --force --path=/var/www/${{ env.LOCAL_DIR }} npm run env:cli -- core version --path=/var/www/${{ env.LOCAL_DIR }} - name: Run any database upgrades + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/trunk' }} run: npm run env:cli -- core update-db --path=/var/www/${{ env.LOCAL_DIR }} + - name: Flush cache + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/trunk' }} + run: npm run env:cli -- cache flush --path=/var/www/${{ env.LOCAL_DIR }} + + - name: Delete expired transients + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/trunk' }} + run: npm run env:cli -- transient delete --expired --path=/var/www/${{ env.LOCAL_DIR }} + - name: Run baseline performance tests + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/trunk' }} env: TEST_RESULTS_PREFIX: base run: npm run test:performance - - name: Print baseline performance tests results - env: - TEST_RESULTS_PREFIX: base - run: node ./tests/performance/results.js + - name: Archive artifacts + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + if: always() + with: + name: performance-artifacts${{ matrix.memcached && '-memcached' || '' }}-${{ github.run_id }} + path: artifacts + if-no-files-found: ignore - - name: Compare results with base + - name: Compare results run: node ./tests/performance/compare-results.js ${{ runner.temp }}/summary.md - name: Add workflow summary @@ -253,7 +295,7 @@ jobs: - name: Set the base sha # Only needed when publishing results. - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/trunk' }} + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/trunk' && ! matrix.memcached }} uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 id: base-sha with: @@ -264,7 +306,7 @@ jobs: - name: Set commit details # Only needed when publishing results. - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/trunk' }} + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/trunk' && ! matrix.memcached }} uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 id: commit-timestamp with: @@ -275,7 +317,7 @@ jobs: - name: Publish performance results # Only publish results on pushes to trunk. - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/trunk' }} + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/trunk' && ! matrix.memcached }} env: BASE_SHA: ${{ steps.base-sha.outputs.result }} COMMITTED_AT: ${{ steps.commit-timestamp.outputs.result }} diff --git a/tests/performance/compare-results.js b/tests/performance/compare-results.js index 6af85a2f12..c9d51e1a11 100644 --- a/tests/performance/compare-results.js +++ b/tests/performance/compare-results.js @@ -3,193 +3,155 @@ /** * External dependencies. */ -const fs = require( 'node:fs' ); -const path = require( 'node:path' ); +const { readFileSync, writeFileSync, existsSync } = require( 'node:fs' ); +const { join } = require( 'node:path' ); /** * Internal dependencies */ -const { median } = require( './utils' ); - -/** - * Parse test files into JSON objects. - * - * @param {string} fileName The name of the file. - * @returns An array of parsed objects from each file. - */ -const parseFile = ( fileName ) => - JSON.parse( - fs.readFileSync( path.join( __dirname, '/specs/', fileName ), 'utf8' ) - ); - -// The list of test suites to log. -const testSuites = [ - 'admin', - 'admin-l10n', - 'home-block-theme', - 'home-block-theme-l10n', - 'home-classic-theme', - 'home-classic-theme-l10n', -]; - -// The current commit's results. -const testResults = Object.fromEntries( - testSuites - .filter( ( key ) => fs.existsSync( path.join( __dirname, '/specs/', `${ key }.test.results.json` ) ) ) - .map( ( key ) => [ key, parseFile( `${ key }.test.results.json` ) ] ) -); - -// The previous commit's results. -const prevResults = Object.fromEntries( - testSuites - .filter( ( key ) => fs.existsSync( path.join( __dirname, '/specs/', `before-${ key }.test.results.json` ) ) ) - .map( ( key ) => [ key, parseFile( `before-${ key }.test.results.json` ) ] ) -); +const { + median, + formatAsMarkdownTable, + formatValue, + linkToSha, + standardDeviation, + medianAbsoluteDeviation, + accumulateValues, +} = require( './utils' ); + +process.env.WP_ARTIFACTS_PATH ??= join( process.cwd(), 'artifacts' ); const args = process.argv.slice( 2 ); - const summaryFile = args[ 0 ]; /** - * Formats an array of objects as a Markdown table. - * - * For example, this array: - * - * [ - * { - * foo: 123, - * bar: 456, - * baz: 'Yes', - * }, - * { - * foo: 777, - * bar: 999, - * baz: 'No', - * } - * ] - * - * Will result in the following table: - * - * | foo | bar | baz | - * |-----|-----|-----| - * | 123 | 456 | Yes | - * | 777 | 999 | No | + * Parse test files into JSON objects. * - * @param {Array<Object>} rows Table rows. - * @returns {string} Markdown table content. + * @param {string} fileName The name of the file. + * @return {Array<{file: string, title: string, results: Record<string,number[]>[]}>} Parsed object. */ -function formatAsMarkdownTable( rows ) { - let result = ''; - const headers = Object.keys( rows[ 0 ] ); - for ( const header of headers ) { - result += `| ${ header } `; - } - result += '|\n'; - for ( const header of headers ) { - result += '| ------ '; - } - result += '|\n'; - - for ( const row of rows ) { - for ( const value of Object.values( row ) ) { - result += `| ${ value } `; - } - result += '|\n'; +function parseFile( fileName ) { + const file = join( process.env.WP_ARTIFACTS_PATH, fileName ); + if ( ! existsSync( file ) ) { + return []; } - return result; + return JSON.parse( readFileSync( file, 'utf8' ) ); } /** - * Returns a Markdown link to a Git commit on the current GitHub repository. - * - * For example, turns `a5c3785ed8d6a35868bc169f07e40e889087fd2e` - * into (https://github.com/wordpress/wordpress-develop/commit/36fe58a8c64dcc83fc21bddd5fcf054aef4efb27)[36fe58a]. - * - * @param {string} sha Commit SHA. - * @return string Link + * @type {Array<{file: string, title: string, results: Record<string,number[]>[]}>} */ -function linkToSha(sha) { - const repoName = process.env.GITHUB_REPOSITORY || 'wordpress/wordpress-develop'; +const beforeStats = parseFile( 'before-performance-results.json' ); - return `[${sha.slice(0, 7)}](https://github.com/${repoName}/commit/${sha})`; -} +/** + * @type {Array<{file: string, title: string, results: Record<string,number[]>[]}>} + */ +const afterStats = parseFile( 'performance-results.json' ); -let summaryMarkdown = `# Performance Test Results\n\n`; - -if ( process.env.GITHUB_SHA ) { - summaryMarkdown += `🛎️ Performance test results for ${ linkToSha( process.env.GITHUB_SHA ) } are in!\n\n`; -} else { - summaryMarkdown += `🛎️ Performance test results are in!\n\n`; -} +let summaryMarkdown = `## Performance Test Results\n\n`; if ( process.env.TARGET_SHA ) { - summaryMarkdown += `This compares the results from this commit with the ones from ${ linkToSha( process.env.TARGET_SHA ) }.\n\n`; + if ( beforeStats.length > 0 ) { + if (process.env.GITHUB_SHA) { + summaryMarkdown += `This compares the results from this commit (${linkToSha( + process.env.GITHUB_SHA + )}) with the ones from ${linkToSha(process.env.TARGET_SHA)}.\n\n`; + } else { + summaryMarkdown += `This compares the results from this commit with the ones from ${linkToSha( + process.env.TARGET_SHA + )}.\n\n`; + } + } else { + summaryMarkdown += `Note: no build was found for the target commit ${linkToSha(process.env.TARGET_SHA)}. No comparison is possible.\n\n`; + } } +const numberOfRepetitions = afterStats[ 0 ].results.length; +const numberOfIterations = Object.values( afterStats[ 0 ].results[ 0 ] )[ 0 ] + .length; + +const repetitions = `${ numberOfRepetitions } ${ + numberOfRepetitions === 1 ? 'repetition' : 'repetitions' +}`; +const iterations = `${ numberOfIterations } ${ + numberOfIterations === 1 ? 'iteration' : 'iterations' +}`; + +summaryMarkdown += `All numbers are median values over ${ repetitions } with ${ iterations } each.\n\n`; + if ( process.env.GITHUB_SHA ) { summaryMarkdown += `**Note:** Due to the nature of how GitHub Actions work, some variance in the results is expected.\n\n`; } console.log( 'Performance Test Results\n' ); -console.log( 'Note: Due to the nature of how GitHub Actions work, some variance in the results is expected.\n' ); - -/** - * Nicely formats a given value. - * - * @param {string} metric Metric. - * @param {number} value - */ -function formatValue( metric, value) { - if ( null === value ) { - return 'N/A'; - } - if ( 'wpMemoryUsage' === metric ) { - return `${ ( value / Math.pow( 10, 6 ) ).toFixed( 2 ) } MB`; - } +console.log( + `All numbers are median values over ${ repetitions } with ${ iterations } each.\n` +); - return `${ value.toFixed( 2 ) } ms`; +if ( process.env.GITHUB_SHA ) { + console.log( + 'Note: Due to the nature of how GitHub Actions work, some variance in the results is expected.\n' + ); } -for ( const key of testSuites ) { - const current = testResults[ key ] || {}; - const prev = prevResults[ key ] || {}; - - const title = ( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) ).replace( - /-+/g, - ' ' - ); +for ( const { title, results } of afterStats ) { + const prevStat = beforeStats.find( ( s ) => s.title === title ); + /** + * @type {Array<Record<string, string>>} + */ const rows = []; - for ( const [ metric, values ] of Object.entries( current ) ) { - const value = median( values ); - const prevValue = prev[ metric ] ? median( prev[ metric ] ) : null; + const newResults = accumulateValues( results ); + // Only do comparison if the number of results is the same. + const prevResults = + prevStat && prevStat.results.length === results.length + ? accumulateValues( prevStat.results ) + : {}; + + for ( const [ metric, values ] of Object.entries( newResults ) ) { + const prevValues = prevResults[ metric ] ? prevResults[ metric ] : null; - const delta = null !== prevValue ? value - prevValue : 0 + const value = median( values ); + const prevValue = prevValues ? median( prevValues ) : 0; + const delta = value - prevValue; const percentage = ( delta / value ) * 100; + const showDiff = + metric !== 'wpExtObjCache' && ! Number.isNaN( percentage ); + rows.push( { Metric: metric, - Before: formatValue( metric, prevValue ), + Before: prevValues ? formatValue( metric, prevValue ) : 'N/A', After: formatValue( metric, value ), - 'Diff abs.': formatValue( metric, delta ), - 'Diff %': `${ percentage.toFixed( 2 ) } %`, + 'Diff abs.': showDiff ? formatValue( metric, delta ) : '', + 'Diff %': showDiff ? `${ percentage.toFixed( 2 ) } %` : '', + STD: showDiff + ? formatValue( metric, standardDeviation( values ) ) + : '', + MAD: showDiff + ? formatValue( metric, medianAbsoluteDeviation( values ) ) + : '', } ); } + console.log( title ); if ( rows.length > 0 ) { - summaryMarkdown += `## ${ title }\n\n`; - summaryMarkdown += `${ formatAsMarkdownTable( rows ) }\n`; - - console.log( title ); console.table( rows ); + } else { + console.log( '(no results)' ); } + + summaryMarkdown += `**${ title }**\n\n`; + summaryMarkdown += `${ formatAsMarkdownTable( rows ) }\n`; } +writeFileSync( + join( process.env.WP_ARTIFACTS_PATH, '/performance-results.md' ), + summaryMarkdown +); + if ( summaryFile ) { - fs.writeFileSync( - summaryFile, - summaryMarkdown - ); + writeFileSync( summaryFile, summaryMarkdown ); } diff --git a/tests/performance/config/global-setup.js b/tests/performance/config/global-setup.js index f3a0a4f26a..25e99a47d8 100644 --- a/tests/performance/config/global-setup.js +++ b/tests/performance/config/global-setup.js @@ -30,9 +30,7 @@ async function globalSetup( config ) { await requestUtils.setupRest(); // Reset the test environment before running the tests. - await Promise.all( [ - requestUtils.activateTheme( 'twentytwentyone' ), - ] ); + await Promise.all( [ requestUtils.activateTheme( 'twentytwentyone' ) ] ); await requestContext.dispose(); } diff --git a/tests/performance/config/performance-reporter.js b/tests/performance/config/performance-reporter.js index e557faa135..9617625eed 100644 --- a/tests/performance/config/performance-reporter.js +++ b/tests/performance/config/performance-reporter.js @@ -1,13 +1,8 @@ /** * External dependencies */ -import { join, dirname, basename } from 'node:path'; -import { writeFileSync } from 'node:fs'; - -/** - * Internal dependencies - */ -import { getResultsFilename } from '../utils'; +import { join } from 'node:path'; +import { writeFileSync, existsSync, mkdirSync } from 'node:fs'; /** * @implements {import('@playwright/test/reporter').Reporter} @@ -15,6 +10,15 @@ import { getResultsFilename } from '../utils'; class PerformanceReporter { /** * + * @type {Record<string,{title: string; results: Record< string, number[] >[];}>} + */ + allResults = {}; + + /** + * Called after a test has been finished in the worker process. + * + * Used to add test results to the final summary of all tests. + * * @param {import('@playwright/test/reporter').TestCase} test * @param {import('@playwright/test/reporter').TestResult} result */ @@ -24,15 +28,59 @@ class PerformanceReporter { ); if ( performanceResults?.body ) { - writeFileSync( - join( - dirname( test.location.file ), - getResultsFilename( basename( test.location.file, '.js' ) ) - ), - performanceResults.body.toString( 'utf-8' ) + // 0 = empty, 1 = browser, 2 = file name, 3 = test suite name, 4 = test name. + const titlePath = test.titlePath(); + const title = `${ titlePath[ 3 ] } › ${ titlePath[ 4 ] }`; + + // results is an array in case repeatEach is > 1. + + this.allResults[ title ] ??= { + file: test.location.file, // Unused, but useful for debugging. + results: [], + }; + + this.allResults[ title ].results.push( + JSON.parse( performanceResults.body.toString( 'utf-8' ) ) ); } } + + /** + * Called after all tests have been run, or testing has been interrupted. + * + * Writes all raw numbers to a file for further processing, + * for example to compare with a previous run. + * + * @param {import('@playwright/test/reporter').FullResult} result + */ + onEnd( result ) { + const summary = []; + + for ( const [ title, { file, results } ] of Object.entries( + this.allResults + ) ) { + summary.push( { + file, + title, + results, + } ); + } + + if ( ! existsSync( process.env.WP_ARTIFACTS_PATH ) ) { + mkdirSync( process.env.WP_ARTIFACTS_PATH ); + } + + const prefix = process.env.TEST_RESULTS_PREFIX; + const fileNamePrefix = prefix ? `${ prefix }-` : ''; + + writeFileSync( + join( + process.env.WP_ARTIFACTS_PATH, + `${ fileNamePrefix }performance-results.json` + ), + JSON.stringify( summary, null, 2 ) + ); + } } export default PerformanceReporter; diff --git a/tests/performance/log-results.js b/tests/performance/log-results.js index 14c836ff67..66fe1e5291 100644 --- a/tests/performance/log-results.js +++ b/tests/performance/log-results.js @@ -1,71 +1,92 @@ #!/usr/bin/env node +/* + * Get the test results and format them in the way required by the API. + * + * Contains some backward compatibility logic for the original test suite format, + * see #59900 for details. + */ + /** * External dependencies. */ -const fs = require( 'fs' ); -const path = require( 'path' ); const https = require( 'https' ); -const [ token, branch, hash, baseHash, timestamp, host ] = process.argv.slice( 2 ); -const { median } = require( './utils' ); - -// The list of test suites to log. -const testSuites = [ - 'admin', - 'admin-l10n', - 'home-block-theme', - 'home-block-theme-l10n', - 'home-classic-theme', - 'home-classic-theme-l10n', -]; - -// A list of results to parse based on test suites. -const testResults = testSuites.map(( key ) => ({ - key, - file: `${ key }.test.results.json`, -})); - -// A list of base results to parse based on test suites. -const baseResults = testSuites.map(( key ) => ({ - key, - file: `base-${ key }.test.results.json`, -})); +const [ token, branch, hash, baseHash, timestamp, host ] = + process.argv.slice( 2 ); +const { median, parseFile, accumulateValues } = require( './utils' ); + +const testSuiteMap = { + 'Admin › Locale: en_US': 'admin', + 'Admin › Locale: de_DE': 'admin-l10n', + 'Front End › Theme: twentytwentyone, Locale: en_US': 'home-classic-theme', + 'Front End › Theme: twentytwentyone, Locale: de_DE': + 'home-classic-theme-l10n', + 'Front End › Theme: twentytwentythree, Locale: en_US': 'home-block-theme', + 'Front End › Theme: twentytwentythree, Locale: de_DE': + 'home-block-theme-l10n', +}; /** - * Parse test files into JSON objects. - * - * @param {string} fileName The name of the file. - * @returns An array of parsed objects from each file. + * @type {Array<{file: string, title: string, results: Record<string,number[]>[]}>} */ -const parseFile = ( fileName ) => ( - JSON.parse( - fs.readFileSync( path.join( __dirname, '/specs/', fileName ), 'utf8' ) - ) -); +const afterStats = parseFile( 'performance-results.json' ); + +/** + * @type {Array<{file: string, title: string, results: Record<string,number[]>[]}>} + */ +const baseStats = parseFile( 'base-performance-results.json' ); + +/** + * @type {Record<string, number>} + */ +const metrics = {}; +/** + * @type {Record<string, number>} + */ +const baseMetrics = {}; + +for ( const { title, results } of afterStats ) { + const testSuiteName = testSuiteMap[ title ]; + if ( ! testSuiteName ) { + continue; + } + + const baseStat = baseStats.find( ( s ) => s.title === title ); + + const currResults = accumulateValues( results ); + const baseResults = accumulateValues( baseStat.results ); + + for ( const [ metric, values ] of Object.entries( currResults ) ) { + metrics[ `${ testSuiteName }-${ metric }` ] = median( values ); + } + + for ( const [ metric, values ] of Object.entries( baseResults ) ) { + baseMetrics[ `${ testSuiteName }-${ metric }` ] = median( values ); + } +} + +process.exit( 0 ); /** * Gets the array of metrics from a list of results. * * @param {Object[]} results A list of results to format. - * @return {Object[]} Metrics. + * @return {Object} Metrics. */ const formatResults = ( results ) => { - return results.reduce( - ( result, { key, file } ) => { - return { - ...result, - ...Object.fromEntries( - Object.entries( - parseFile( file ) ?? {} - ).map( ( [ metric, value ] ) => [ + return results.reduce( ( result, { key, file } ) => { + return { + ...result, + ...Object.fromEntries( + Object.entries( parseFile( file ) ?? {} ).map( + ( [ metric, value ] ) => [ key + '-' + metric, - median ( value ), - ] ) - ), - }; - }, - {} - ); + median( value ), + ] + ) + ), + }; + }, {} ); }; const data = new TextEncoder().encode( diff --git a/tests/performance/playwright.config.js b/tests/performance/playwright.config.js index 1d2781f73c..c4df0e2872 100644 --- a/tests/performance/playwright.config.js +++ b/tests/performance/playwright.config.js @@ -23,9 +23,11 @@ const config = defineConfig( { forbidOnly: !! process.env.CI, workers: 1, retries: 0, + repeatEach: 2, timeout: parseInt( process.env.TIMEOUT || '', 10 ) || 600_000, // Defaults to 10 minutes. // Don't report slow test "files", as we will be running our tests in serial. reportSlowTests: null, + preserveOutput: 'never', webServer: { ...baseConfig.webServer, command: 'npm run env:start', @@ -37,4 +39,3 @@ const config = defineConfig( { } ); export default config; - diff --git a/tests/performance/results.js b/tests/performance/results.js deleted file mode 100644 index d9f981f5e7..0000000000 --- a/tests/performance/results.js +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env node - -/** - * External dependencies. - */ -const fs = require( 'node:fs' ); -const { join } = require( 'node:path' ); -const { median, getResultsFilename } = require( './utils' ); - -const testSuites = [ - 'admin', - 'admin-l10n', - 'home-classic-theme', - 'home-classic-theme-l10n', - 'home-block-theme', - 'home-block-theme-l10n', -]; - -console.log( '\n>> 🎉 Results 🎉 \n' ); - -for ( const testSuite of testSuites ) { - const resultsFileName = getResultsFilename( testSuite + '.test' ); - const resultsPath = join( __dirname, '/specs/', resultsFileName ); - fs.readFile( resultsPath, "utf8", ( err, data ) => { - if ( err ) { - console.log( "File read failed:", err ); - return; - } - const convertString = testSuite.charAt( 0 ).toUpperCase() + testSuite.slice( 1 ); - console.log( convertString.replace( /[-]+/g, " " ) + ':' ); - - tableData = JSON.parse( data ); - const rawResults = []; - - for ( var key in tableData ) { - if ( tableData.hasOwnProperty( key ) ) { - rawResults[ key ] = median( tableData[ key ] ); - } - } - console.table( rawResults ); - }); -} diff --git a/tests/performance/specs/admin-l10n.test.js b/tests/performance/specs/admin-l10n.test.js deleted file mode 100644 index a8c9be0997..0000000000 --- a/tests/performance/specs/admin-l10n.test.js +++ /dev/null @@ -1,52 +0,0 @@ -/** - * WordPress dependencies - */ -import { test } from '@wordpress/e2e-test-utils-playwright'; - -/** - * Internal dependencies - */ -import { camelCaseDashes } from '../utils'; - -const results = { - timeToFirstByte: [], -}; - -test.describe( 'Admin (L10N)', () => { - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'twentytwentyone' ); - await requestUtils.updateSiteSettings( { - language: 'de_DE', - } ); - } ); - - test.afterAll( async ( { requestUtils }, testInfo ) => { - await testInfo.attach( 'results', { - body: JSON.stringify( results, null, 2 ), - contentType: 'application/json', - } ); - await requestUtils.updateSiteSettings( { - language: '', - } ); - } ); - - const iterations = Number( process.env.TEST_RUNS ); - for ( let i = 1; i <= iterations; i++ ) { - test( `Measure load time metrics (${ i } of ${ iterations })`, async ( { - admin, - metrics, - } ) => { - await admin.visitAdminPage( '/' ); - - const serverTiming = await metrics.getServerTiming(); - - for ( const [ key, value ] of Object.entries( serverTiming ) ) { - results[ camelCaseDashes( key ) ] ??= []; - results[ camelCaseDashes( key ) ].push( value ); - } - - const ttfb = await metrics.getTimeToFirstByte(); - results.timeToFirstByte.push( ttfb ); - } ); - } -} ); diff --git a/tests/performance/specs/admin.test.js b/tests/performance/specs/admin.test.js index 9860229114..36bdd9c628 100644 --- a/tests/performance/specs/admin.test.js +++ b/tests/performance/specs/admin.test.js @@ -12,35 +12,59 @@ const results = { timeToFirstByte: [], }; +const locales = [ 'en_US', 'de_DE' ]; + test.describe( 'Admin', () => { - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'twentytwentyone' ); - } ); - - test.afterAll( async ( {}, testInfo ) => { - await testInfo.attach( 'results', { - body: JSON.stringify( results, null, 2 ), - contentType: 'application/json', - } ); - } ); + for ( const locale of locales ) { + test.describe( `Locale: ${ locale }`, () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + await requestUtils.updateSiteSettings( { + language: 'en_US' === locale ? '' : locale, + } ); + } ); - const iterations = Number( process.env.TEST_RUNS ); - for ( let i = 1; i <= iterations; i++ ) { - test( `Measure load time metrics (${ i } of ${ iterations })`, async ( { - admin, - metrics, - } ) => { - await admin.visitAdminPage( '/' ); + test.afterAll( async ( { requestUtils }, testInfo ) => { + await testInfo.attach( 'results', { + body: JSON.stringify( results, null, 2 ), + contentType: 'application/json', + } ); - const serverTiming = await metrics.getServerTiming(); + await requestUtils.updateSiteSettings( { + language: '', + } ); - for ( const [ key, value ] of Object.entries( serverTiming ) ) { - results[ camelCaseDashes( key ) ] ??= []; - results[ camelCaseDashes( key ) ].push( value ); - } + results.timeToFirstByte = []; + } ); + + test.afterAll( async ( {}, testInfo ) => { + await testInfo.attach( 'results', { + body: JSON.stringify( results, null, 2 ), + contentType: 'application/json', + } ); + } ); - const ttfb = await metrics.getTimeToFirstByte(); - results.timeToFirstByte.push( ttfb ); + const iterations = Number( process.env.TEST_RUNS ); + for ( let i = 1; i <= iterations; i++ ) { + test( `Measure load time metrics (${ i } of ${ iterations })`, async ( { + admin, + metrics, + } ) => { + await admin.visitAdminPage( '/' ); + + const serverTiming = await metrics.getServerTiming(); + + for ( const [ key, value ] of Object.entries( + serverTiming + ) ) { + results[ camelCaseDashes( key ) ] ??= []; + results[ camelCaseDashes( key ) ].push( value ); + } + + const ttfb = await metrics.getTimeToFirstByte(); + results.timeToFirstByte.push( ttfb ); + } ); + } } ); } } ); diff --git a/tests/performance/specs/home-block-theme-l10n.test.js b/tests/performance/specs/home-block-theme-l10n.test.js deleted file mode 100644 index 591925056f..0000000000 --- a/tests/performance/specs/home-block-theme-l10n.test.js +++ /dev/null @@ -1,63 +0,0 @@ -/** - * WordPress dependencies - */ -import { test } from '@wordpress/e2e-test-utils-playwright'; - -/** - * Internal dependencies - */ -import { camelCaseDashes } from '../utils'; - -const results = { - timeToFirstByte: [], - largestContentfulPaint: [], - lcpMinusTtfb: [], -}; - -test.describe( 'Front End - Twenty Twenty Three (L10N)', () => { - test.use( { - storageState: {}, // User will be logged out. - } ); - - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'twentytwentythree' ); - await requestUtils.updateSiteSettings( { - language: 'de_DE', - } ); - } ); - - test.afterAll( async ( { requestUtils }, testInfo ) => { - await testInfo.attach( 'results', { - body: JSON.stringify( results, null, 2 ), - contentType: 'application/json', - } ); - await requestUtils.activateTheme( 'twentytwentyone' ); - await requestUtils.updateSiteSettings( { - language: '', - } ); - } ); - - const iterations = Number( process.env.TEST_RUNS ); - for ( let i = 1; i <= iterations; i++ ) { - test( `Measure load time metrics (${ i } of ${ iterations })`, async ( { - page, - metrics, - } ) => { - await page.goto( '/' ); - - const serverTiming = await metrics.getServerTiming(); - - for ( const [ key, value ] of Object.entries( serverTiming ) ) { - results[ camelCaseDashes( key ) ] ??= []; - results[ camelCaseDashes( key ) ].push( value ); - } - - const ttfb = await metrics.getTimeToFirstByte(); - const lcp = await metrics.getLargestContentfulPaint(); - - results.largestContentfulPaint.push( lcp ); - results.timeToFirstByte.push( ttfb ); - results.lcpMinusTtfb.push( lcp - ttfb ); - } ); - } -} ); diff --git a/tests/performance/specs/home-block-theme.test.js b/tests/performance/specs/home-block-theme.test.js deleted file mode 100644 index 00bccc6996..0000000000 --- a/tests/performance/specs/home-block-theme.test.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * WordPress dependencies - */ -import { test } from '@wordpress/e2e-test-utils-playwright'; - -/** - * Internal dependencies - */ -import { camelCaseDashes } from '../utils'; - -const results = { - timeToFirstByte: [], - largestContentfulPaint: [], - lcpMinusTtfb: [], -}; - -test.describe( 'Front End - Twenty Twenty Three', () => { - test.use( { - storageState: {}, // User will be logged out. - } ); - - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'twentytwentythree' ); - } ); - - test.afterAll( async ( { requestUtils }, testInfo ) => { - await testInfo.attach( 'results', { - body: JSON.stringify( results, null, 2 ), - contentType: 'application/json', - } ); - await requestUtils.activateTheme( 'twentytwentyone' ); - } ); - - const iterations = Number( process.env.TEST_RUNS ); - for ( let i = 1; i <= iterations; i++ ) { - test( `Measure load time metrics (${ i } of ${ iterations })`, async ( { - page, - metrics, - } ) => { - await page.goto( '/' ); - - const serverTiming = await metrics.getServerTiming(); - - for ( const [ key, value ] of Object.entries( serverTiming ) ) { - results[ camelCaseDashes( key ) ] ??= []; - results[ camelCaseDashes( key ) ].push( value ); - } - - const ttfb = await metrics.getTimeToFirstByte(); - const lcp = await metrics.getLargestContentfulPaint(); - - results.largestContentfulPaint.push( lcp ); - results.timeToFirstByte.push( ttfb ); - results.lcpMinusTtfb.push( lcp - ttfb ); - } ); - } -} ); diff --git a/tests/performance/specs/home-classic-theme-l10n.test.js b/tests/performance/specs/home-classic-theme-l10n.test.js deleted file mode 100644 index e6f6e1cbb9..0000000000 --- a/tests/performance/specs/home-classic-theme-l10n.test.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * WordPress dependencies - */ -import { test } from '@wordpress/e2e-test-utils-playwright'; - -/** - * Internal dependencies - */ -import { camelCaseDashes } from '../utils'; - -const results = { - timeToFirstByte: [], - largestContentfulPaint: [], - lcpMinusTtfb: [], -}; - -test.describe( 'Front End - Twenty Twenty One (L10N)', () => { - test.use( { - storageState: {}, // User will be logged out. - } ); - - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'twentytwentyone' ); - await requestUtils.updateSiteSettings( { - language: 'de_DE', - } ); - } ); - - test.afterAll( async ( { requestUtils }, testInfo ) => { - await testInfo.attach( 'results', { - body: JSON.stringify( results, null, 2 ), - contentType: 'application/json', - } ); - await requestUtils.updateSiteSettings( { - language: '', - } ); - } ); - - const iterations = Number( process.env.TEST_RUNS ); - for ( let i = 1; i <= iterations; i++ ) { - test( `Measure load time metrics (${ i } of ${ iterations })`, async ( { - page, - metrics, - } ) => { - await page.goto( '/' ); - - const serverTiming = await metrics.getServerTiming(); - - for ( const [ key, value ] of Object.entries( serverTiming ) ) { - results[ camelCaseDashes( key ) ] ??= []; - results[ camelCaseDashes( key ) ].push( value ); - } - - const ttfb = await metrics.getTimeToFirstByte(); - const lcp = await metrics.getLargestContentfulPaint(); - - results.largestContentfulPaint.push( lcp ); - results.timeToFirstByte.push( ttfb ); - results.lcpMinusTtfb.push( lcp - ttfb ); - } ); - } -} ); diff --git a/tests/performance/specs/home-classic-theme.test.js b/tests/performance/specs/home-classic-theme.test.js deleted file mode 100644 index a95e50fa06..0000000000 --- a/tests/performance/specs/home-classic-theme.test.js +++ /dev/null @@ -1,56 +0,0 @@ -/** - * WordPress dependencies - */ -import { test } from '@wordpress/e2e-test-utils-playwright'; - -/** - * Internal dependencies - */ -import { camelCaseDashes } from '../utils'; - -const results = { - timeToFirstByte: [], - largestContentfulPaint: [], - lcpMinusTtfb: [], -}; - -test.describe( 'Front End - Twenty Twenty One', () => { - test.use( { - storageState: {}, // User will be logged out. - } ); - - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'twentytwentyone' ); - } ); - - test.afterAll( async ( {}, testInfo ) => { - await testInfo.attach( 'results', { - body: JSON.stringify( results, null, 2 ), - contentType: 'application/json', - } ); - } ); - - const iterations = Number( process.env.TEST_RUNS ); - for ( let i = 1; i <= iterations; i++ ) { - test( `Measure load time metrics (${ i } of ${ iterations })`, async ( { - page, - metrics, - } ) => { - await page.goto( '/' ); - - const serverTiming = await metrics.getServerTiming(); - - for ( const [ key, value ] of Object.entries( serverTiming ) ) { - results[ camelCaseDashes( key ) ] ??= []; - results[ camelCaseDashes( key ) ].push( value ); - } - - const ttfb = await metrics.getTimeToFirstByte(); - const lcp = await metrics.getLargestContentfulPaint(); - - results.largestContentfulPaint.push( lcp ); - results.timeToFirstByte.push( ttfb ); - results.lcpMinusTtfb.push( lcp - ttfb ); - } ); - } -} ); diff --git a/tests/performance/specs/home.test.js b/tests/performance/specs/home.test.js new file mode 100644 index 0000000000..b88b6adb9f --- /dev/null +++ b/tests/performance/specs/home.test.js @@ -0,0 +1,79 @@ +/** + * WordPress dependencies + */ +import { test } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import { camelCaseDashes } from '../utils'; + +const results = { + timeToFirstByte: [], + largestContentfulPaint: [], + lcpMinusTtfb: [], +}; + +const themes = [ 'twentytwentyone', 'twentytwentythree', 'twentytwentyfour' ]; + +const locales = [ 'en_US', 'de_DE' ]; + +test.describe( 'Front End', () => { + test.use( { + storageState: {}, // User will be logged out. + } ); + + for ( const theme of themes ) { + for ( const locale of locales ) { + test.describe( `Theme: ${ theme }, Locale: ${ locale }`, () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( theme ); + await requestUtils.updateSiteSettings( { + language: 'en_US' === locale ? '' : locale, + } ); + } ); + + test.afterAll( async ( { requestUtils }, testInfo ) => { + await testInfo.attach( 'results', { + body: JSON.stringify( results, null, 2 ), + contentType: 'application/json', + } ); + + await requestUtils.updateSiteSettings( { + language: '', + } ); + + results.largestContentfulPaint = []; + results.timeToFirstByte = []; + results.lcpMinusTtfb = []; + } ); + + const iterations = Number( process.env.TEST_RUNS ); + for ( let i = 1; i <= iterations; i++ ) { + test( `Measure load time metrics (${ i } of ${ iterations })`, async ( { + page, + metrics, + } ) => { + await page.goto( '/' ); + + const serverTiming = await metrics.getServerTiming(); + + for ( const [ key, value ] of Object.entries( + serverTiming + ) ) { + results[ camelCaseDashes( key ) ] ??= []; + results[ camelCaseDashes( key ) ].push( value ); + } + + const ttfb = await metrics.getTimeToFirstByte(); + const lcp = await metrics.getLargestContentfulPaint(); + + results.largestContentfulPaint.push( lcp ); + results.timeToFirstByte.push( ttfb ); + results.lcpMinusTtfb.push( lcp - ttfb ); + } ); + } + } ); + } + } +} ); diff --git a/tests/performance/utils.js b/tests/performance/utils.js index f56380e9c2..4d023be586 100644 --- a/tests/performance/utils.js +++ b/tests/performance/utils.js @@ -1,4 +1,27 @@ /** + * External dependencies. + */ +const { readFileSync, existsSync } = require( 'node:fs' ); +const { join } = require( 'node:path' ); + +process.env.WP_ARTIFACTS_PATH ??= join( process.cwd(), 'artifacts' ); + +/** + * Parse test files into JSON objects. + * + * @param {string} fileName The name of the file. + * @return {Array<{file: string, title: string, results: Record<string,number[]>[]}>} Parsed object. + */ +function parseFile( fileName ) { + const file = join( process.env.WP_ARTIFACTS_PATH, fileName ); + if ( ! existsSync( file ) ) { + return []; + } + + return JSON.parse( readFileSync( file, 'utf8' ) ); +} + +/** * Computes the median number from an array numbers. * * @param {number[]} array @@ -13,27 +36,157 @@ function median( array ) { : ( numbers[ mid - 1 ] + numbers[ mid ] ) / 2; } +function camelCaseDashes( str ) { + return str.replace( /-([a-z])/g, function ( g ) { + return g[ 1 ].toUpperCase(); + } ); +} + /** - * Gets the result file name. + * Formats an array of objects as a Markdown table. + * + * For example, this array: * - * @param {string} fileName File name. + * [ + * { + * foo: 123, + * bar: 456, + * baz: 'Yes', + * }, + * { + * foo: 777, + * bar: 999, + * baz: 'No', + * } + * ] * - * @return {string} Result file name. + * Will result in the following table: + * + * | foo | bar | baz | + * |-----|-----|-----| + * | 123 | 456 | Yes | + * | 777 | 999 | No | + * + * @param {Array<Object>} rows Table rows. + * @returns {string} Markdown table content. */ -function getResultsFilename( fileName ) { - const prefix = process.env.TEST_RESULTS_PREFIX; - const fileNamePrefix = prefix ? `${ prefix }-` : ''; - return `${fileNamePrefix + fileName}.results.json`; +function formatAsMarkdownTable( rows ) { + let result = ''; + + if ( ! rows.length ) { + return result; + } + + const headers = Object.keys( rows[ 0 ] ); + for ( const header of headers ) { + result += `| ${ header } `; + } + result += '|\n'; + for ( const header of headers ) { + result += '| ------ '; + } + result += '|\n'; + + for ( const row of rows ) { + for ( const value of Object.values( row ) ) { + result += `| ${ value } `; + } + result += '|\n'; + } + + return result; } -function camelCaseDashes( str ) { - return str.replace( /-([a-z])/g, function( g ) { - return g[ 1 ].toUpperCase(); - } ); +/** + * Nicely formats a given value. + * + * @param {string} metric Metric. + * @param {number} value + */ +function formatValue( metric, value ) { + if ( null === value ) { + return 'N/A'; + } + + if ( 'wpMemoryUsage' === metric ) { + return `${ ( value / Math.pow( 10, 6 ) ).toFixed( 2 ) } MB`; + } + + if ( 'wpExtObjCache' === metric ) { + return 1 === value ? 'yes' : 'no'; + } + + if ( 'wpDbQueries' === metric ) { + return value; + } + + return `${ value.toFixed( 2 ) } ms`; +} + +/** + * Returns a Markdown link to a Git commit on the current GitHub repository. + * + * For example, turns `a5c3785ed8d6a35868bc169f07e40e889087fd2e` + * into (https://github.com/wordpress/wordpress-develop/commit/36fe58a8c64dcc83fc21bddd5fcf054aef4efb27)[36fe58a]. + * + * @param {string} sha Commit SHA. + * @return string Link + */ +function linkToSha( sha ) { + const repoName = + process.env.GITHUB_REPOSITORY || 'wordpress/wordpress-develop'; + + return `[${ sha.slice( + 0, + 7 + ) }](https://github.com/${ repoName }/commit/${ sha })`; +} + +function standardDeviation( array = [] ) { + if ( ! array.length ) { + return 0; + } + + const mean = array.reduce( ( a, b ) => a + b ) / array.length; + return Math.sqrt( + array + .map( ( x ) => Math.pow( x - mean, 2 ) ) + .reduce( ( a, b ) => a + b ) / array.length + ); +} + +function medianAbsoluteDeviation( array = [] ) { + if ( ! array.length ) { + return 0; + } + + const med = median( array ); + return median( array.map( ( a ) => Math.abs( a - med ) ) ); +} + +/** + * + * @param {Array<Record<string, number[]>>} results + * @returns {Record<string, number[]>} + */ +function accumulateValues( results ) { + return results.reduce( ( acc, result ) => { + for ( const [ metric, values ] of Object.entries( result ) ) { + acc[ metric ] = acc[ metric ] ?? []; + acc[ metric ].push( ...values ); + } + return acc; + }, {} ); } module.exports = { + parseFile, median, - getResultsFilename, camelCaseDashes, + formatAsMarkdownTable, + formatValue, + linkToSha, + standardDeviation, + medianAbsoluteDeviation, + accumulateValues, }; diff --git a/tests/performance/wp-content/mu-plugins/server-timing.php b/tests/performance/wp-content/mu-plugins/server-timing.php index 53f83fea79..abf166fcc8 100644 --- a/tests/performance/wp-content/mu-plugins/server-timing.php +++ b/tests/performance/wp-content/mu-plugins/server-timing.php @@ -4,7 +4,7 @@ add_filter( 'template_include', static function ( $template ) { - global $timestart; + global $timestart, $wpdb; $server_timing_values = array(); $template_start = microtime( true ); @@ -15,10 +15,7 @@ add_filter( add_action( 'shutdown', - static function () use ( $server_timing_values, $template_start ) { - - global $timestart; - + static function () use ( $server_timing_values, $template_start, $wpdb ) { $output = ob_get_clean(); $server_timing_values['template'] = microtime( true ) - $template_start; @@ -30,7 +27,9 @@ add_filter( * any numeric value can actually be passed. * This is a nice little trick as it allows to easily get this information in JS. */ - $server_timing_values['memory-usage'] = memory_get_usage(); + $server_timing_values['memory-usage'] = memory_get_usage(); + $server_timing_values['db-queries'] = $wpdb->num_queries; + $server_timing_values['ext-obj-cache'] = wp_using_ext_object_cache() ? 1 : 0; $header_values = array(); foreach ( $server_timing_values as $slug => $value ) { @@ -50,3 +49,45 @@ add_filter( }, PHP_INT_MAX ); + +add_action( + 'admin_init', + static function () { + global $timestart, $wpdb; + + ob_start(); + + add_action( + 'shutdown', + static function () use ( $wpdb, $timestart ) { + $output = ob_get_clean(); + + $server_timing_values = array(); + + $server_timing_values['total'] = microtime( true ) - $timestart; + + /* + * While values passed via Server-Timing are intended to be durations, + * any numeric value can actually be passed. + * This is a nice little trick as it allows to easily get this information in JS. + */ + $server_timing_values['memory-usage'] = memory_get_usage(); + $server_timing_values['db-queries'] = $wpdb->num_queries; + $server_timing_values['ext-obj-cache'] = wp_using_ext_object_cache() ? 1 : 0; + + $header_values = array(); + foreach ( $server_timing_values as $slug => $value ) { + if ( is_float( $value ) ) { + $value = round( $value * 1000.0, 2 ); + } + $header_values[] = sprintf( 'wp-%1$s;dur=%2$s', $slug, $value ); + } + header( 'Server-Timing: ' . implode( ', ', $header_values ) ); + + echo $output; + }, + PHP_INT_MIN + ); + }, + PHP_INT_MAX +); |