├── .gitattributes ├── .github ├── dependabot.yml ├── release.yml └── workflows │ ├── auto-merge.yml │ ├── codeql-analysis.yml │ ├── test-basic.yml │ ├── test-comparison.yml │ ├── test-complex.yml │ ├── test-matrix.yml │ └── test-pr-comment.yml ├── .gitignore ├── LICENSE ├── README.md ├── action.yml ├── env ├── .nvmrc ├── blueprints │ └── setup.json ├── package-lock.json ├── package.json ├── tests │ └── performance │ │ ├── cli │ │ └── results.js │ │ ├── config │ │ ├── global-setup.ts │ │ └── performance-reporter.ts │ │ ├── playwright.config.ts │ │ ├── specs │ │ └── main.spec.ts │ │ └── utils │ │ └── index.ts ├── tsconfig.json └── wp-content │ └── mu-plugins │ ├── reset-helper.php │ └── server-timing-enhancements.php └── tests ├── blueprint-complex.json └── dummy-plugin └── dummy-plugin.php /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/** -diff linguist-generated=true 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | 8 | - package-ecosystem: npm 9 | directory: /env 10 | schedule: 11 | interval: weekly 12 | groups: 13 | wp-packages: 14 | patterns: 15 | - '@wordpress/*' 16 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@08eff52bf64351f401fb50d4972fa95b9f2c2d1b # v1.3.3 16 | with: 17 | github-token: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | - name: Approve a PR 20 | run: gh pr review --approve "$PR_URL" 21 | env: 22 | PR_URL: ${{github.event.pull_request.html_url}} 23 | GH_TOKEN: ${{secrets.PR_COMMENT_TOKEN}} 24 | 25 | - name: Enable auto-merge for Dependabot PRs 26 | if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch'}} 27 | run: gh pr merge --auto --squash "$PR_URL" 28 | env: 29 | PR_URL: ${{github.event.pull_request.html_url}} 30 | GH_TOKEN: ${{secrets.PR_COMMENT_TOKEN}} 31 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '31 7 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'TypeScript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | source-root: env 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v3 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v3 72 | -------------------------------------------------------------------------------- /.github/workflows/test-basic.yml: -------------------------------------------------------------------------------- 1 | name: 'Basic Tests' 2 | on: # rebuild any PRs and main branch changes 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | - 'releases/*' 8 | 9 | jobs: 10 | basic: 11 | name: 'Run tests' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Run performance tests 18 | uses: ./ 19 | with: 20 | urls: | 21 | / 22 | /sample-page/ 23 | -------------------------------------------------------------------------------- /.github/workflows/test-comparison.yml: -------------------------------------------------------------------------------- 1 | name: 'Test with comparison' 2 | on: # rebuild any PRs and main branch changes 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | - 'releases/*' 8 | 9 | jobs: 10 | comparison-same: 11 | name: 'Run tests with same version' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Run performance tests (before) 18 | id: before 19 | uses: ./ 20 | with: 21 | urls: | 22 | / 23 | /sample-page/ 24 | plugins: | 25 | ./tests/dummy-plugin 26 | blueprint: './tests/blueprint-complex.json' 27 | iterations: 5 28 | repetitions: 1 29 | wp-version: 'latest' 30 | print-results: false 31 | upload-artifacts: false 32 | 33 | - name: Run performance tests (after) 34 | id: after 35 | uses: ./ 36 | with: 37 | urls: | 38 | / 39 | /sample-page/ 40 | plugins: | 41 | ./tests/dummy-plugin 42 | blueprint: './tests/blueprint-complex.json' 43 | iterations: 5 44 | repetitions: 1 45 | wp-version: 'latest' 46 | previous-results: ${{ steps.before.outputs.results }} 47 | print-results: true 48 | upload-artifacts: false 49 | comparison-different: 50 | name: 'Run tests with different versions' 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: Checkout 54 | uses: actions/checkout@v4 55 | 56 | - name: Run performance tests (before) 57 | id: before 58 | uses: ./ 59 | with: 60 | urls: | 61 | / 62 | /sample-page/ 63 | plugins: | 64 | ./tests/dummy-plugin 65 | blueprint: './tests/blueprint-complex.json' 66 | iterations: 5 67 | repetitions: 1 68 | wp-version: '6.7' 69 | print-results: false 70 | upload-artifacts: false 71 | 72 | - name: Run performance tests (after) 73 | id: after 74 | uses: ./ 75 | with: 76 | urls: | 77 | / 78 | /sample-page/ 79 | plugins: | 80 | ./tests/dummy-plugin 81 | blueprint: './tests/blueprint-complex.json' 82 | iterations: 5 83 | repetitions: 1 84 | wp-version: '6.6' 85 | previous-results: ${{ steps.before.outputs.results }} 86 | print-results: true 87 | upload-artifacts: false 88 | -------------------------------------------------------------------------------- /.github/workflows/test-complex.yml: -------------------------------------------------------------------------------- 1 | name: 'Complex Tests' 2 | on: # rebuild any PRs and main branch changes 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | - 'releases/*' 8 | 9 | jobs: 10 | complex: 11 | name: 'Run tests' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Run performance tests 18 | uses: ./ 19 | with: 20 | urls: | 21 | / 22 | /sample-page/ 23 | plugins: | 24 | ./tests/dummy-plugin 25 | blueprint: './tests/blueprint-complex.json' 26 | iterations: 5 27 | repetitions: 1 28 | wp-version: 'trunk' 29 | php-version: '7.4' 30 | -------------------------------------------------------------------------------- /.github/workflows/test-matrix.yml: -------------------------------------------------------------------------------- 1 | name: 'Matrix Tests' 2 | on: # rebuild any PRs and main branch changes 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | - 'releases/*' 8 | 9 | jobs: 10 | matrix: 11 | name: 'Run tests' 12 | timeout-minutes: 60 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | shard: [1/4, 2/4, 3/4, 4/4] 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Run performance tests 22 | uses: ./ 23 | id: run-tests 24 | with: 25 | urls: | 26 | / 27 | /sample-page/ 28 | plugins: | 29 | ./tests/dummy-plugin 30 | shard: ${{ matrix.shard }} 31 | 32 | merge-reports: 33 | name: 'Merge reports' 34 | # Merge reports after playwright-tests, even if some shards have failed 35 | if: always() 36 | needs: [matrix] 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v4 40 | 41 | - name: Merge performance test results 42 | uses: ./ 43 | with: 44 | action: 'merge' 45 | -------------------------------------------------------------------------------- /.github/workflows/test-pr-comment.yml: -------------------------------------------------------------------------------- 1 | name: 'Test with PR comment' 2 | on: # rebuild any PRs and main branch changes 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | - 'releases/*' 8 | 9 | jobs: 10 | basic: 11 | name: 'Run tests' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Run performance tests 18 | uses: ./ 19 | with: 20 | urls: | 21 | / 22 | /sample-page/ 23 | create-comment: true 24 | github-token: ${{ secrets.PR_COMMENT_TOKEN }} 25 | repetitions: 5 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wp-performance-action 2 | 3 | A GitHub action to measure performance metrics of WordPress sites. 4 | 5 | Results are posted as comments to pull requests and as [GitHub Action job summaries](https://github.blog/2022-05-09-supercharging-github-actions-with-job-summaries/). 6 | 7 | It collects data from the `Server-Timing` header and runs Lighthouse on a given set of URLs. 8 | 9 | **Note:** Tests are run using [WordPress Playground](https://wordpress.org/playground/), which means you can use [blueprints](https://wordpress.github.io/wordpress-playground/blueprints) to prepare the test environment suitable to your needs. 10 | 11 | ## Example 12 | 13 | Screenshot of a GitHub Action job summary output by this action 14 | 15 | ## Usage 16 | 17 | See [action.yml](action.yml) 18 | 19 | 20 | ```yaml 21 | - uses: swissspidy/wp-performance-action@v2 22 | with: 23 | # Personal access token (PAT) used to comment on pull requests. 24 | # 25 | # [Learn more about creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) 26 | # 27 | # Default: ${{ github.token }} 28 | github-token: '' 29 | 30 | # Whether to create PR comments with performance results. 31 | # 32 | # Might require a custom `github-token` to be set. 33 | # 34 | # Default: false 35 | create-comment: '' 36 | 37 | # Whether to add results to the workflow summary. 38 | # 39 | # Default: true 40 | print-results: '' 41 | 42 | # Whether to upload any artifacts. 43 | # 44 | # Default: true 45 | upload-artifacts: '' 46 | 47 | # Whether to log additional debugging information 48 | # 49 | # Default: ${{ runner.debug == '1' }} 50 | debug: '' 51 | 52 | # List of URLs on the WordPress site to test. 53 | # 54 | # Each URL should be separated with new lines. 55 | # 56 | # Default: '' 57 | urls: '' 58 | 59 | # List of plugin directories to mount. 60 | # 61 | # Each plugin should be separated with new lines. 62 | # Needs to be a path to a local directory. 63 | # For installing plugins from the plugin directory 64 | # or a ZIP file, use a blueprint. 65 | # 66 | # Default: '' 67 | plugins: '' 68 | 69 | # List of theme directories to mount. 70 | # 71 | # Each theme should be separated with new lines. 72 | # Needs to be a path to a local directory. 73 | # For installing themes from the theme directory 74 | # or a ZIP file, use a blueprint. 75 | # 76 | # Default: '' 77 | themes: '' 78 | 79 | # Blueprint to use for setting up the environment. 80 | # 81 | # Use this to install or activate additional plugins, defining constants, 82 | # and much more. 83 | # 84 | # See https://wordpress.github.io/wordpress-playground/blueprints for more information. 85 | # 86 | # Default: '' 87 | blueprint: '' 88 | 89 | # WordPress version to use. 90 | # 91 | # Loads the specified WordPress version. 92 | # Accepts the last four major WordPress versions. 93 | # You can also use the generic values 'latest', 'nightly', or 'beta'. 94 | # 95 | # Default: 'latest' 96 | wp-version: '' 97 | 98 | # PHP version to use. 99 | # 100 | # Accepts 7.0, 7.1, 7.2, 7.3, 7.4, 8.0, 8.1, 8.2, 8.3. 101 | # 102 | # Default: 'latest' 103 | php-version: '' 104 | 105 | # Number of times the tests should be repeated. 106 | # 107 | # Default: 2 108 | repetitions: '' 109 | 110 | # Number of iterations (loops) within a single run. 111 | # 112 | # Default: 20 113 | iterations: '' 114 | 115 | # Shard to use if running tests in parallel. 116 | # Valid values are 1/2, 1/4, etc. 117 | # 118 | # Default: '' 119 | shard: '' 120 | 121 | # Action to perform, can be either "test" or "merge". 122 | # Merging is needed when running tests in parallel 123 | # in a test matrix, where you later need to merge 124 | # the results from the individual jobs together. 125 | # 126 | # Default: 'test' 127 | action: '' 128 | 129 | # Path to a file with previous performance results for comparison. 130 | # Useful when running tests for a pull request and 131 | # the target branch, so that the performance impact can be measured. 132 | # 133 | # Default: '' 134 | previous-results: '' 135 | ``` 136 | 137 | 138 | ### Basic 139 | 140 | Add a workflow (`.github/workflows/build-test.yml`): 141 | 142 | ```yaml 143 | steps: 144 | - name: Checkout 145 | uses: actions/checkout@v4 146 | 147 | - name: Run performance tests 148 | uses: swissspidy/wp-performance-action@v2 149 | with: 150 | plugins: | 151 | ./my-awesome-plugin 152 | urls: | 153 | / 154 | /sample-page/ 155 | ``` 156 | 157 | ### Advanced 158 | 159 | Add a workflow (`.github/workflows/build-test.yml`): 160 | 161 | ```yaml 162 | steps: 163 | - name: Checkout 164 | uses: actions/checkout@v4 165 | 166 | - name: Run performance tests 167 | uses: swissspidy/wp-performance-action@v2 168 | with: 169 | urls: | 170 | / 171 | /sample-page/ 172 | plugins: | 173 | ./my-awesome-plugin 174 | blueprint: ./my-custom-blueprint.json 175 | iterations: 5 176 | repetitions: 1 177 | ``` 178 | 179 | Add a blueprint (`my-custom-blueprint.json`): 180 | 181 | ```json 182 | { 183 | "$schema": "https://playground.wordpress.net/blueprint-schema.json", 184 | "plugins": [ 185 | "performant-translations", 186 | "akismet" 187 | ], 188 | "steps": [ 189 | { 190 | "step": "defineWpConfigConsts", 191 | "consts": { 192 | "WP_DEBUG": true 193 | } 194 | }, 195 | { 196 | "step": "activatePlugin", 197 | "pluginName": "My Awesome Plugin", 198 | "pluginPath": "/wordpress/wp-content/plugins/my-awesome-plugin" 199 | } 200 | ] 201 | } 202 | 203 | ``` 204 | 205 | ### Running tests in parallel (sharding) 206 | 207 | ```yaml 208 | jobs: 209 | matrix: 210 | timeout-minutes: 60 211 | runs-on: ubuntu-latest 212 | strategy: 213 | fail-fast: false 214 | matrix: 215 | shard: [1/4, 2/4, 3/4, 4/4] 216 | steps: 217 | - uses: actions/checkout@v4 218 | 219 | - name: Run performance tests 220 | uses: swissspidy/wp-performance-action@v2 221 | id: run-tests 222 | with: 223 | urls: | 224 | / 225 | /sample-page/ 226 | plugins: | 227 | ./my-awesome-plugin 228 | shard: ${{ matrix.shard }} 229 | 230 | merge-reports: 231 | if: always() 232 | needs: [matrix] 233 | runs-on: ubuntu-latest 234 | steps: 235 | - uses: actions/checkout@v4 236 | 237 | - name: Merge performance test results 238 | uses: swissspidy/wp-performance-action@v2 239 | with: 240 | action: 'merge' 241 | ``` 242 | 243 | ### Performance results output 244 | 245 | The `results` step output contains information regarding where the raw performance results numbers are stored. 246 | This output can be used for a variety of purposes such as logging or for a comparison with previous results. 247 | 248 | In addition to that, the raw results are also uploaded as a [workflow artifact](https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts). 249 | 250 | ```yaml 251 | steps: 252 | - name: Checkout 253 | uses: actions/checkout@v4 254 | 255 | - name: Run performance tests 256 | uses: swissspidy/wp-performance-action@v2 257 | id: performance-tests 258 | with: 259 | plugins: | 260 | ./my-awesome-plugin 261 | urls: | 262 | / 263 | /sample-page/ 264 | 265 | - name: 'Echo results path' 266 | run: echo ${{steps.performance-tests.outputs.results}} 267 | ``` 268 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'WP Performance Tests' 2 | description: 'Measure performance metrics for your WordPress project' 3 | author: 'Pascal Birchler' 4 | branding: 5 | color: 'blue' 6 | icon: 'trending-up' 7 | inputs: 8 | github-token: 9 | description: 'The GitHub token used to create PR comments.' 10 | required: false 11 | default: ${{ github.token }} 12 | create-comment: 13 | description: 'Whether to create PR comments with performance results.' 14 | required: false 15 | default: '' 16 | print-results: 17 | description: 'Whether to add results to the workflow summary' 18 | required: false 19 | default: '' 20 | upload-artifacts: 21 | description: 'Whether to upload any artifacts' 22 | required: false 23 | default: '' 24 | debug: 25 | description: 'Whether to log additional debugging information.' 26 | default: ${{ runner.debug == '1' }} 27 | urls: 28 | required: true 29 | description: 'URLs to test, separated by newline.' 30 | default: '' 31 | plugins: 32 | required: false 33 | description: 'List of plugin directory paths to mount, separated by newline.' 34 | default: '' 35 | themes: 36 | required: false 37 | description: 'List of theme directory paths to mount, separated by newline.' 38 | default: '' 39 | blueprint: 40 | required: false 41 | description: 'Blueprint to use for setting up the environment.' 42 | default: '' 43 | wp-version: 44 | required: false 45 | description: 'WordPress version to use.' 46 | default: 'latest' 47 | php-version: 48 | required: false 49 | description: 'PHP version to use.' 50 | default: '8.2' 51 | shard: 52 | required: false 53 | description: 'Shard to use if running tests in parallel in a matrix.' 54 | default: '' 55 | repetitions: 56 | required: false 57 | description: 'Number of times the tests should be repeated.' 58 | default: '2' 59 | iterations: 60 | required: false 61 | description: 'Number of iterations (loops) within a single run.' 62 | default: '20' 63 | action: 64 | description: 'Action to perform, can be either "test" or "merge".' 65 | required: false 66 | default: 'test' 67 | previous-results: 68 | description: 'Path to a file with previous performance results for comparison.' 69 | required: false 70 | default: '' 71 | outputs: 72 | results: 73 | description: "Path to a file with raw results" 74 | value: ${{ steps.share-results.outputs.results }} 75 | blob-report: 76 | description: "Path to the blob report folder" 77 | value: ${{ steps.share-blob-report.outputs.blob-report }} 78 | runs: 79 | using: "composite" 80 | steps: 81 | # Works around https://github.com/actions/upload-artifact/issues/176 82 | - name: Resolve action_path 83 | run: echo "ABS_ACTION_PATH=$(realpath ${{ github.action_path }})" >> $GITHUB_ENV 84 | shell: 'bash' 85 | 86 | # setup-node expects paths relative to checked-out repo, 87 | # but we want to use .nvmrc from action repo. 88 | # So we provide a path relative to $GITHUB_WORKSPACE 89 | # See https://github.com/actions/setup-node/issues/852 90 | - name: Get relative action_path 91 | run: | 92 | REL_ACTION_PATH=$(node -p 'require("path").relative(process.env.GITHUB_WORKSPACE, process.env.ABS_ACTION_PATH)') 93 | REL_ACTION_PATH=${REL_ACTION_PATH:-.} 94 | echo "REL_ACTION_PATH=$REL_ACTION_PATH" >> $GITHUB_ENV 95 | shell: 'bash' 96 | 97 | - name: Setup Node 98 | uses: actions/setup-node@v4 99 | with: 100 | node-version-file: ${{ env.REL_ACTION_PATH }}/env/.nvmrc 101 | # Disable caching as it is not supported outside $GITHUB_WORKSPACE 102 | cache: '' 103 | 104 | - name: Install dependencies 105 | run: | 106 | echo "::group::Install dependencies" 107 | npm ci ${{ inputs.debug != 'true' && '--silent' || '' }} 108 | echo "::endgroup::" 109 | shell: 'bash' 110 | working-directory: ${{ github.action_path }}/env 111 | 112 | - name: Install Playwright browsers 113 | run: | 114 | echo "::group::Install Playwright browsers" 115 | npx ${{ inputs.debug != 'true' && '--silent' || '' }} playwright install --with-deps 116 | echo "::endgroup::" 117 | if: ${{ inputs.action == 'test' }} 118 | shell: 'bash' 119 | working-directory: ${{ github.action_path }}/env 120 | 121 | - name: Set up WordPress Playground 122 | run: | 123 | ARGS=() 124 | 125 | echo "::group::Mounting plugin directories" 126 | 127 | ARGS+=("--mount=./wp-content/mu-plugins:/wordpress/wp-content/mu-plugins") 128 | echo "./wp-content/mu-plugins:/wordpress/wp-content/mu-plugins" 129 | 130 | PLUGINS=${PLUGINS%$'\n'} 131 | THEMES=${THEMES%$'\n'} 132 | IFS=$'\n'; 133 | PLUGINS=($PLUGINS) 134 | THEMES=($THEMES) 135 | 136 | for i in "${PLUGINS[@]}"; do 137 | if [ ! -z i ]; then 138 | PLUGIN_REALPATH=$(realpath "${{ github.workspace }}/$i"); 139 | PLUGIN_BASENAME=$(basename $PLUGIN_REALPATH); 140 | 141 | ARGS+=("--mount=$PLUGIN_REALPATH:/wordpress/wp-content/plugins/$PLUGIN_BASENAME") 142 | echo "$PLUGIN_REALPATH:/wordpress/wp-content/plugins/$PLUGIN_BASENAME" 143 | fi 144 | done 145 | 146 | echo "::endgroup::" 147 | 148 | echo "::group::Mounting theme directories" 149 | 150 | for i in "${THEMES[@]}"; do 151 | if [ ! -z i ]; then 152 | THEME_REALPATH=$(realpath "${{ github.workspace }}/$i"); 153 | THEME_BASENAME=$(basename $THEME_REALPATH); 154 | 155 | ARGS+=("--mount=$THEME_REALPATH:/wordpress/wp-content/themes/$THEME_BASENAME") 156 | echo "$THEME_REALPATH:/wordpress/wp-content/themes/$THEME_BASENAME" 157 | fi 158 | done 159 | 160 | echo "::endgroup::" 161 | 162 | unset IFS; 163 | 164 | echo "::group::Prepare blueprint" 165 | 166 | if [ ! -z $BLUEPRINT ]; then 167 | BLUEPRINT=$(realpath "${{ github.workspace }}/$BLUEPRINT"); 168 | echo "Provided blueprint file: $BLUEPRINT" 169 | jq -s 'map(to_entries)|flatten|group_by(.key)|map({(.[0].key):map(.value)|add})|add' ./blueprints/setup.json $BLUEPRINT > ./blueprints/tmp-merged.json 170 | ARGS+=(--blueprint=./blueprints/tmp-merged.json) 171 | cat ./blueprints/tmp-merged.json | jq '.' 172 | else 173 | ARGS+=(--blueprint=./blueprints/setup.json) 174 | cat ./blueprints/setup.json | jq '.' 175 | fi 176 | 177 | echo "::endgroup::" 178 | 179 | ARGS+=(--wp=$WP_VERSION) 180 | ARGS+=(--php=$PHP_VERSION) 181 | 182 | IFS=, 183 | echo "Providing arguments: ${ARGS[*]}" 184 | unset IFS; 185 | 186 | echo "Start Playground server..." 187 | ./node_modules/@wp-playground/cli/wp-playground.js server "${ARGS[@]}" & 188 | env: 189 | PLUGINS: ${{ inputs.plugins }} 190 | THEMES: ${{ inputs.themes }} 191 | BLUEPRINT: ${{ inputs.blueprint }} 192 | WP_VERSION: ${{ inputs.wp-version }} 193 | PHP_VERSION: ${{ inputs.php-version }} 194 | shell: 'bash' 195 | working-directory: ${{ github.action_path }}/env 196 | 197 | - name: Run tests 198 | run: npm run ${{ inputs.debug != 'true' && '--silent' || '' }} test:performance $ADDITIONAL_ARGS 199 | if: ${{ inputs.action == 'test' }} 200 | env: 201 | WP_BASE_URL: 'http://127.0.0.1:9400' 202 | WP_ARTIFACTS_PATH: ${{ github.action_path }}/env/artifacts 203 | BLOB_REPORT_PATH: ${{ github.action_path }}/env/blob-report 204 | SHARD: ${{ inputs.shard != '' && inputs.shard || '' }} 205 | ADDITIONAL_ARGS: ${{ inputs.shard != '' && format('-- --shard={0}', inputs.shard) || '' }} 206 | URLS_TO_TEST: ${{ inputs.urls }} 207 | DEBUG: ${{ inputs.debug == 'true' }} 208 | TEST_ITERATIONS: ${{ inputs.iterations }} 209 | TEST_REPETITIONS: ${{ inputs.repetitions }} 210 | shell: 'bash' 211 | working-directory: ${{ github.action_path }}/env 212 | 213 | - name: Stop server 214 | run: npm run ${{ inputs.debug != 'true' && '--silent' || '' }} stop-server 215 | shell: 'bash' 216 | working-directory: ${{ github.action_path }}/env 217 | 218 | - name: Download blob reports from GitHub Actions Artifacts 219 | uses: actions/download-artifact@v4 220 | if: ${{ inputs.action == 'merge' }} 221 | id: download 222 | with: 223 | pattern: performance-blob-report-* 224 | merge-multiple: true 225 | 226 | - name: Merge into single performance report 227 | run: npm run ${{ inputs.debug != 'true' && '--silent' || '' }} test:performance:merge-reports ${{ steps.download.outputs.download-path }} 228 | if: ${{ inputs.action == 'merge' }} 229 | shell: 'bash' 230 | working-directory: ${{ github.action_path }}/env 231 | 232 | - name: Rename results file 233 | id: prepare-results 234 | run: | 235 | UUID=$(uuidgen) 236 | mv $WP_ARTIFACTS_PATH/performance-results.json $RUNNER_TEMP/performance-results-$UUID.json 237 | echo "results=$RUNNER_TEMP/performance-results-$UUID.json" >> $GITHUB_OUTPUT 238 | if: ${{ ( inputs.action == 'test' || inputs.action == 'merge' ) }} 239 | env: 240 | WP_ARTIFACTS_PATH: ${{ github.action_path }}/env/artifacts 241 | shell: 'bash' 242 | 243 | - name: Log results 244 | run: | 245 | if [ ! -z $PREVIOUS_RESULTS ] && [ -f $PREVIOUS_RESULTS ]; then 246 | npm run ${{ inputs.debug != 'true' && '--silent' || '' }} test:performance:results $RESULTS_FILE $PREVIOUS_RESULTS 247 | else 248 | npm run ${{ inputs.debug != 'true' && '--silent' || '' }} test:performance:results $RESULTS_FILE 249 | fi; 250 | if: ${{ inputs.action == 'test' || inputs.action == 'merge' }} 251 | env: 252 | RESULTS_FILE: ${{ steps.prepare-results.outputs.results }} 253 | PREVIOUS_RESULTS: ${{ inputs.previous-results }} 254 | REPOSITORY_URL: ${{ github.server_url }}/${{ github.repository }} 255 | shell: 'bash' 256 | working-directory: ${{ github.action_path }}/env 257 | 258 | - name: Add workflow summary 259 | run: cat $WP_ARTIFACTS_PATH/performance-results.md >> $GITHUB_STEP_SUMMARY 260 | if: ${{ ( inputs.action == 'test' || inputs.action == 'merge' ) && ! inputs.shard && inputs.print-results != 'false' }} 261 | env: 262 | WP_ARTIFACTS_PATH: ${{ github.action_path }}/env/artifacts 263 | shell: 'bash' 264 | working-directory: ${{ github.action_path }}/env 265 | 266 | - name: Check if a comment was already made 267 | id: find-comment 268 | if: ${{ github.event_name == 'pull_request' && inputs.create-comment == 'true' }} 269 | uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e 270 | with: 271 | issue-number: ${{ github.event.pull_request.number }} 272 | body-includes: Performance test results for 273 | 274 | - name: Comment on PR with test results 275 | uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 276 | if: ${{ github.event_name == 'pull_request' && inputs.create-comment == 'true' }} 277 | with: 278 | issue-number: ${{ github.event.pull_request.number }} 279 | comment-id: ${{ steps.find-comment.outputs.comment-id }} 280 | edit-mode: replace 281 | body-path: ${{ github.action_path }}/env/artifacts/performance-results.md 282 | token: ${{ inputs.github-token }} 283 | 284 | - name: Share raw results 285 | id: share-results 286 | run: echo "results=$RESULTS_FILE" >> $GITHUB_OUTPUT 287 | if: ${{ ( inputs.action == 'test' || inputs.action == 'merge' ) && ! inputs.shard }} 288 | env: 289 | RESULTS_FILE: ${{ steps.prepare-results.outputs.results }} 290 | shell: 'bash' 291 | 292 | - name: Share blob report 293 | id: share-blob-report 294 | run: echo "blob-report=$BLOB_REPORT_PATH" >> $GITHUB_OUTPUT 295 | if: ${{ inputs.shard != '' }} 296 | env: 297 | BLOB_REPORT_PATH: ${{ github.action_path }}/env/blob-report 298 | shell: 'bash' 299 | 300 | - name: Upload performance results 301 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 302 | if: ${{ success() && ( inputs.action == 'test' || inputs.action == 'merge' ) && ! inputs.shard && inputs.upload-artifacts != 'false' }} 303 | with: 304 | name: performance-results 305 | path: ${{ steps.share-results.outputs.results }} 306 | 307 | - name: Get blob artifact name 308 | if: ${{ success() && inputs.shard != '' && inputs.upload-artifacts != 'false' }} 309 | run: | 310 | ARTIFACT_NAME=${ARTIFACT_NAME//\//-} 311 | echo "ARTIFACT_NAME=${ARTIFACT_NAME}" >> $GITHUB_ENV 312 | env: 313 | ARTIFACT_NAME: performance-blob-report-${{ inputs.shard }} 314 | shell: 'bash' 315 | 316 | - name: Upload blob report 317 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 318 | if: ${{ success() && inputs.shard != '' && inputs.upload-artifacts != 'false' }} 319 | with: 320 | name: ${{ env.ARTIFACT_NAME }} 321 | path: ${{ env.ABS_ACTION_PATH }}/env/blob-report 322 | retention-days: 1 323 | -------------------------------------------------------------------------------- /env/.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /env/blueprints/setup.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://playground.wordpress.net/blueprint-schema.json", 3 | "landingPage": "/wp-admin/", 4 | "preferredVersions": { 5 | "php": "8.0", 6 | "wp": "latest" 7 | }, 8 | "phpExtensionBundles": [ "kitchen-sink" ], 9 | "features": { 10 | "networking": false 11 | }, 12 | "plugins": [ "performance-lab" ], 13 | "constants": { 14 | "WP_HTTP_BLOCK_EXTERNAL": "true" 15 | }, 16 | "login": true, 17 | "siteOptions": { 18 | "permalink_structure": "/%postname%/" 19 | }, 20 | "steps": [ 21 | { 22 | "step": "defineWpConfigConsts", 23 | "consts": { 24 | "DISABLE_WP_CRON": true 25 | } 26 | }, 27 | { 28 | "step": "installTheme", 29 | "themeData": { 30 | "resource": "wordpress.org/themes", 31 | "slug": "twentytwentyone" 32 | }, 33 | "ifAlreadyInstalled": "skip", 34 | "options": { 35 | "activate": true, 36 | "importStarterContent": false 37 | } 38 | }, 39 | { 40 | "step": "installTheme", 41 | "themeData": { 42 | "resource": "wordpress.org/themes", 43 | "slug": "twentytwentythree" 44 | }, 45 | "ifAlreadyInstalled": "skip", 46 | "options": { 47 | "activate": false, 48 | "importStarterContent": false 49 | } 50 | }, 51 | { 52 | "step": "importWxr", 53 | "file": { 54 | "resource": "url", 55 | "url": "https://raw.githubusercontent.com/WordPress/theme-test-data/b47acf980696897936265182cb684dca648476c7/themeunittestdata.wordpress.xml" 56 | } 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /env/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@swissspidy/wp-performance-action", 3 | "private": true, 4 | "description": "Example repository demonstrating how to set up performance testing in a WordPress project.", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/swissspidy/wp-performance-testing" 8 | }, 9 | "author": "swissspidy", 10 | "license": "Apache-2.0", 11 | "bugs": { 12 | "url": "https://github.com/swissspidy/wp-performance-action/issues" 13 | }, 14 | "homepage": "https://github.com/swissspidy/wp-performance-action#readme", 15 | "dependencies": { 16 | "@playwright/test": "^1.52.0", 17 | "@wordpress/e2e-test-utils-playwright": "^1.24.0", 18 | "@wp-playground/cli": "^1.0.29", 19 | "kill-port": "^2.0.1", 20 | "tablemark": "^3.1.0" 21 | }, 22 | "devDependencies": { 23 | "@wordpress/scripts": "^30.17.0", 24 | "eslint-plugin-playwright": "^2.2.0", 25 | "prettier": "npm:wp-prettier@^3.0.3" 26 | }, 27 | "scripts": { 28 | "lint": "wp-scripts lint-js", 29 | "format": "wp-scripts format", 30 | "test:performance": "wp-scripts test-playwright --config tests/performance/playwright.config.ts", 31 | "test:performance:merge-reports": "playwright merge-reports --reporter tests/performance/config/performance-reporter.ts", 32 | "test:performance:results": "node tests/performance/cli/results.js", 33 | "playground": "./node_modules/@wp-playground/cli/wp-playground.js", 34 | "stop-server": "kill-port 9400" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /env/tests/performance/cli/results.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * External dependencies 5 | */ 6 | const { existsSync, readFileSync, writeFileSync } = require( 'node:fs' ); 7 | const { join } = require( 'node:path' ); 8 | 9 | process.env.WP_ARTIFACTS_PATH ??= join( process.cwd(), 'artifacts' ); 10 | 11 | const args = process.argv.slice( 2 ); 12 | 13 | const beforeFile = args[ 1 ]; 14 | const afterFile = args[ 0 ]; 15 | 16 | if ( ! existsSync( afterFile ) ) { 17 | console.error( `File not found: ${ afterFile }` ); 18 | process.exit( 1 ); 19 | } 20 | 21 | if ( beforeFile && ! existsSync( beforeFile ) ) { 22 | console.error( `File not found: ${ beforeFile }` ); 23 | process.exit( 1 ); 24 | } 25 | 26 | /** 27 | * @param {unknown} v 28 | * @return {string} Formatted value. 29 | */ 30 | function formatTableValue( v ) { 31 | if ( v === true || v === 'true' ) return '✅'; 32 | if ( ! v || v === 'false' ) return ''; 33 | return v?.toString() || String( v ); 34 | } 35 | 36 | /** 37 | * Simple way to format an array of objects as a Markdown table. 38 | * 39 | * For example, this array: 40 | * 41 | * [ 42 | * { 43 | * foo: 123, 44 | * bar: 456, 45 | * baz: 'Yes', 46 | * }, 47 | * { 48 | * foo: 777, 49 | * bar: 999, 50 | * baz: 'No', 51 | * } 52 | * ] 53 | * 54 | * Will result in the following table: 55 | * 56 | * | foo | bar | baz | 57 | * |-----|-----|-----| 58 | * | 123 | 456 | Yes | 59 | * | 777 | 999 | No | 60 | * 61 | * @param {Array} rows Table rows. 62 | * @return {string} Markdown table content. 63 | */ 64 | function formatAsMarkdownTable( rows ) { 65 | let result = ''; 66 | const headers = Object.keys( rows[ 0 ] ); 67 | for ( const header of headers ) { 68 | result += `| ${ header } `; 69 | } 70 | result += '|\n'; 71 | for ( const header of headers ) { 72 | const dashes = '-'.repeat( header.length ); 73 | result += `| ${ dashes } `; 74 | } 75 | result += '|\n'; 76 | 77 | for ( const row of rows ) { 78 | for ( const [ key, value ] of Object.entries( row ) ) { 79 | result += `| ${ formatTableValue( value ).padStart( 80 | key.length, 81 | ' ' 82 | ) } `; 83 | } 84 | result += '|\n'; 85 | } 86 | 87 | return result; 88 | } 89 | 90 | /** 91 | * Computes the median number from an array numbers. 92 | * 93 | * @todo Import this from utils/index.ts once this file is converted to TS. 94 | * 95 | * @param {number[]} array List of numbers. 96 | * @return {number} Median. 97 | */ 98 | function median( array ) { 99 | const mid = Math.floor( array.length / 2 ); 100 | const numbers = [ ...array ].sort( ( a, b ) => a - b ); 101 | const result = 102 | array.length % 2 !== 0 103 | ? numbers[ mid ] 104 | : ( numbers[ mid - 1 ] + numbers[ mid ] ) / 2; 105 | 106 | return Number( result.toFixed( 2 ) ); 107 | } 108 | 109 | /** 110 | * @type {Record< string, Array< Record< string, number[] > > >} 111 | */ 112 | let beforeStats = {}; 113 | 114 | /** 115 | * @type {Record< string, Array< Record< string, number[] > > >} 116 | */ 117 | let afterStats; 118 | 119 | if ( beforeFile ) { 120 | try { 121 | beforeStats = JSON.parse( 122 | readFileSync( beforeFile, { encoding: 'utf-8' } ) 123 | ); 124 | } catch {} 125 | } 126 | 127 | try { 128 | afterStats = JSON.parse( readFileSync( afterFile, { encoding: 'utf-8' } ) ); 129 | } catch { 130 | console.error( `Could not read file: ${ afterFile }` ); 131 | process.exit( 1 ); 132 | } 133 | 134 | /** 135 | * Returns a Markdown link to a Git commit on the current GitHub repository. 136 | * 137 | * For example, turns `a5c3785ed8d6a35868bc169f07e40e889087fd2e` 138 | * into (https://github.com/wordpress/wordpress-develop/commit/36fe58a8c64dcc83fc21bddd5fcf054aef4efb27)[36fe58a]. 139 | * 140 | * @param {string} sha Commit SHA. 141 | * @return string Link 142 | */ 143 | function linkToSha( sha ) { 144 | const url = process.env.REPOSITORY_URL; 145 | return `[${ sha.slice( 0, 7 ) }](${ url }/commit/${ sha })`; 146 | } 147 | 148 | let summaryMarkdown = `**Performance Test Results**\n\n`; 149 | 150 | if ( process.env.GITHUB_SHA ) { 151 | summaryMarkdown += `Performance test results for ${ linkToSha( 152 | process.env.GITHUB_SHA 153 | ) } are in 🛎️!\n\n`; 154 | } else { 155 | summaryMarkdown += `Performance test results are in 🛎️!\n\n`; 156 | } 157 | 158 | if ( beforeFile ) { 159 | summaryMarkdown += `Note: the numbers in parentheses show the difference to the previous (baseline) test run. Differences below 2% or 0.5 in absolute values are not shown.\n\n`; 160 | } 161 | 162 | console.log( 'Performance Test Results\n' ); 163 | 164 | if ( beforeFile ) { 165 | console.log( 166 | 'Note: the numbers in parentheses show the difference to the previous (baseline) test run. Differences below 2% or 0.5 in absolute values are not shown.\n' 167 | ); 168 | } 169 | 170 | const DELTA_VARIANCE = 0.5; 171 | const PERCENTAGE_VARIANCE = 2; 172 | 173 | /** 174 | * Format value and add unit. 175 | * 176 | * Turns bytes into MB (base 10). 177 | * 178 | * @todo Dynamic formatting based on definition in result.json. 179 | * 180 | * @param {number} value Value. 181 | * @param {string} key Key. 182 | * @return {string} Formatted value. 183 | */ 184 | function formatValue( value, key ) { 185 | if ( key === '%' ) { 186 | return `${ value.toFixed( 0 ) }%`; 187 | } 188 | 189 | if ( key === 'CLS' ) { 190 | return value.toFixed( 2 ); 191 | } 192 | 193 | if ( key === 'wpDbQueries' ) { 194 | return value.toFixed( 0 ); 195 | } 196 | 197 | if ( key === 'wpMemoryUsage' ) { 198 | return `${ ( value / Math.pow( 10, 6 ) ).toFixed( 2 ) } MB`; 199 | } 200 | 201 | return `${ value.toFixed( 2 ) } ms`; 202 | } 203 | 204 | /** 205 | * Format key for readability. 206 | * 207 | * @param {string} key Key. 208 | * @return {string} Formatted key. 209 | */ 210 | function formatKey( key ) { 211 | switch ( key ) { 212 | // Server-Timing. 213 | 214 | case 'wpDbQueries': 215 | return 'DB Queries'; 216 | case 'wpMemoryUsage': 217 | return 'Memory'; 218 | case 'wpBeforeTemplate': 219 | return 'Before Template'; 220 | case 'wpTemplate': 221 | return 'Template'; 222 | case 'wpTotal': 223 | return 'WP Total'; 224 | 225 | // Web vitals. 226 | 227 | case 'timeToFirstByte': 228 | return 'TTFB'; 229 | case 'lcpMinusTtfb': 230 | return 'LCP - TTFB'; 231 | case 'largestContentfulPaint': 232 | return 'LCP'; 233 | 234 | default: 235 | return key; 236 | } 237 | } 238 | 239 | for ( const [ url, results ] of Object.entries( afterStats ) ) { 240 | const prevStat = beforeStats[ url ]; 241 | 242 | /** 243 | * @type {Array< Record< string, string | number | boolean > >} 244 | */ 245 | const diffResults = []; 246 | 247 | for ( const i in results ) { 248 | const newResult = results[ i ]; 249 | 250 | /** 251 | * @type {Record} 252 | */ 253 | const diffResult = { 254 | Run: i, 255 | }; 256 | 257 | for ( const [ key, values ] of Object.entries( newResult ) ) { 258 | // Only do comparison if the number of results is the same. 259 | const prevValues = 260 | prevStat && prevStat.length === results.length 261 | ? prevStat[ i ][ key ] 262 | : null; 263 | 264 | const value = median( values ); 265 | const prevValue = prevValues ? median( prevValues ) : 0; 266 | const delta = value - prevValue; 267 | const percentage = ( delta / value ) * 100; 268 | 269 | // Skip if there is not a significant delta or none at all. 270 | if ( 271 | ! prevValues || 272 | ! percentage || 273 | Math.abs( percentage ) <= PERCENTAGE_VARIANCE || 274 | ! delta || 275 | Math.abs( delta ) <= DELTA_VARIANCE 276 | ) { 277 | diffResult[ formatKey( key ) ] = formatValue( 278 | /** @type {number} */ ( value ), 279 | key 280 | ); 281 | continue; 282 | } 283 | 284 | const prefix = delta > 0 ? '+' : ''; 285 | 286 | diffResult[ formatKey( key ) ] = `${ formatValue( 287 | value, 288 | key 289 | ) } (${ prefix }${ formatValue( 290 | delta, 291 | key 292 | ) } / ${ prefix }${ formatValue( percentage, '%' ) } )`; 293 | } 294 | 295 | diffResults.push( diffResult ); 296 | } 297 | 298 | console.log( `URL: \`${ url }\`` ); 299 | console.table( diffResults ); 300 | 301 | summaryMarkdown += `**URL: \`${ url }\`**\n\n`; 302 | summaryMarkdown += `${ formatAsMarkdownTable( diffResults ) }\n`; 303 | } 304 | 305 | writeFileSync( 306 | join( process.env.WP_ARTIFACTS_PATH, '/performance-results.md' ), 307 | summaryMarkdown 308 | ); 309 | -------------------------------------------------------------------------------- /env/tests/performance/config/global-setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import { request } from '@playwright/test'; 5 | import type { FullConfig } from '@playwright/test/reporter'; 6 | 7 | /** 8 | * WordPress dependencies 9 | */ 10 | import { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; 11 | 12 | async function globalSetup( config: FullConfig ) { 13 | const { storageState, baseURL } = config.projects[ 0 ].use; 14 | const storageStatePath = 15 | typeof storageState === 'string' ? storageState : undefined; 16 | 17 | const requestContext = await request.newContext( { 18 | baseURL, 19 | } ); 20 | 21 | const requestUtils = new RequestUtils( requestContext, { 22 | storageStatePath, 23 | } ); 24 | 25 | // Authenticate and save the storageState to disk. 26 | await requestUtils.setupRest(); 27 | 28 | await requestContext.dispose(); 29 | } 30 | 31 | module.exports = globalSetup; 32 | -------------------------------------------------------------------------------- /env/tests/performance/config/performance-reporter.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path'; 2 | import { writeFileSync, existsSync, mkdirSync } from 'node:fs'; 3 | import type { 4 | FullConfig, 5 | FullResult, 6 | Reporter, 7 | TestCase, 8 | TestResult, 9 | } from '@playwright/test/reporter'; 10 | import { median } from '../utils'; 11 | 12 | process.env.WP_ARTIFACTS_PATH ??= join( process.cwd(), 'artifacts' ); 13 | 14 | class PerformanceReporter implements Reporter { 15 | private shard?: FullConfig[ 'shard' ]; 16 | 17 | allResults: Record< string, Array< Record< string, number[] > > > = {}; 18 | 19 | onBegin( config: FullConfig ) { 20 | if ( config.shard ) { 21 | this.shard = config.shard; 22 | } 23 | } 24 | 25 | /** 26 | * Called after a test has been finished in the worker process. 27 | * 28 | * Used to add test results to the final summary of all tests. 29 | * 30 | * @param test 31 | * @param result 32 | */ 33 | onTestEnd( test: TestCase, result: TestResult ) { 34 | const performanceResults = result.attachments.find( 35 | ( attachment ) => attachment.name === 'results' 36 | ); 37 | 38 | if ( performanceResults?.body ) { 39 | const resultsByUrl = JSON.parse( 40 | performanceResults.body.toString( 'utf-8' ) 41 | ) as Record< string, Record< string, number[] > >; 42 | 43 | for ( const [ url, results ] of Object.entries( resultsByUrl ) ) { 44 | this.allResults[ url ] ??= []; 45 | 46 | this.allResults[ url ].push( results ); 47 | } 48 | } 49 | } 50 | 51 | /** 52 | * Called after all tests have been run, or testing has been interrupted. 53 | * 54 | * Provides a quick summary and writes all raw numbers to a file 55 | * for further processing, for example to compare with a previous run. 56 | * 57 | * @param result 58 | */ 59 | onEnd( result: FullResult ) { 60 | if ( Object.keys( this.allResults ).length > 0 ) { 61 | if ( this.shard ) { 62 | console.log( 63 | `\nPerformance Test Results ${ this.shard.current }/${ this.shard.total }` 64 | ); 65 | } else { 66 | console.log( `\nPerformance Test Results` ); 67 | } 68 | console.log( `Status: ${ result.status }` ); 69 | } 70 | 71 | for ( const [ url, results ] of Object.entries( this.allResults ) ) { 72 | console.log( `\nURL: \`${ url }\`\n` ); 73 | console.table( 74 | results.map( ( r ) => 75 | Object.fromEntries( 76 | Object.entries( r ).map( ( [ key, value ] ) => [ 77 | key, 78 | median( value ), 79 | ] ) 80 | ) 81 | ) 82 | ); 83 | } 84 | 85 | if ( ! existsSync( process.env.WP_ARTIFACTS_PATH as string ) ) { 86 | mkdirSync( process.env.WP_ARTIFACTS_PATH as string ); 87 | } 88 | 89 | writeFileSync( 90 | join( 91 | process.env.WP_ARTIFACTS_PATH as string, 92 | 'performance-results.json' 93 | ), 94 | JSON.stringify( this.allResults, null, 2 ) 95 | ); 96 | } 97 | } 98 | 99 | export default PerformanceReporter; 100 | -------------------------------------------------------------------------------- /env/tests/performance/playwright.config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * External dependencies 3 | */ 4 | import { join } from 'node:path'; 5 | import { defineConfig, type ReporterDescription } from '@playwright/test'; 6 | 7 | /** 8 | * WordPress dependencies 9 | */ 10 | // @ts-ignore 11 | import baseConfig from '@wordpress/scripts/config/playwright.config.js'; 12 | 13 | process.env.BLOB_REPORT_PATH ??= join( process.cwd(), 'blob-report' ); 14 | process.env.WP_ARTIFACTS_PATH ??= join( process.cwd(), 'artifacts' ); 15 | process.env.STORAGE_STATE_PATH ??= join( 16 | process.env.WP_ARTIFACTS_PATH, 17 | 'storage-states/admin.json' 18 | ); 19 | process.env.TEST_ITERATIONS ??= '20'; 20 | process.env.TEST_REPETITIONS ??= '2'; 21 | 22 | const reporter: ReporterDescription[] = [ 23 | [ './config/performance-reporter.ts' ], 24 | ]; 25 | 26 | if ( process.env.SHARD !== '' ) { 27 | reporter.unshift( [ 'blob', { outputDir: process.env.BLOB_REPORT_PATH } ] ); 28 | } 29 | 30 | if ( process.env.DEBUG == 'true' ) { 31 | reporter.unshift( [ 'list' ] ); 32 | } 33 | 34 | const config = defineConfig( { 35 | ...baseConfig, 36 | globalSetup: require.resolve( './config/global-setup.ts' ), 37 | reporter, 38 | forbidOnly: !! process.env.CI, 39 | workers: 1, 40 | retries: 0, 41 | repeatEach: Number( process.env.TEST_REPETITIONS ), 42 | timeout: parseInt( process.env.TIMEOUT || '', 10 ) || 600_000, // Defaults to 10 minutes. 43 | // Don't report slow test "files", as we will be running our tests in serial. 44 | reportSlowTests: null, 45 | webServer: { 46 | ...baseConfig.webServer, 47 | port: 9400, 48 | command: undefined, 49 | }, 50 | use: { 51 | ...baseConfig.use, 52 | baseURL: 'http://127.0.0.1:9400', 53 | video: 'off', 54 | trace: 'off', 55 | }, 56 | } ); 57 | 58 | export default config; 59 | -------------------------------------------------------------------------------- /env/tests/performance/specs/main.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@wordpress/e2e-test-utils-playwright'; 2 | import { camelCaseDashes } from '../utils'; 3 | 4 | const results: Record< string, Record< string, number[] > > = {}; 5 | 6 | test.describe( 'Tests', () => { 7 | test.use( { 8 | // @ts-ignore 9 | storageState: {}, // User will be logged out. 10 | } ); 11 | 12 | // Run *once* before *all* iterations. 13 | // Ideal for setting up the site for this particular test. 14 | test.beforeAll( async ( { requestUtils } ) => { 15 | await requestUtils.request.get( 16 | `${ requestUtils.baseURL }/?reset_helper` 17 | ); 18 | } ); 19 | 20 | // After all results are processed, attach results for further processing. 21 | // For easier handling, only one attachment per file. 22 | test.afterAll( async ( {}, testInfo ) => { 23 | await testInfo.attach( 'results', { 24 | body: JSON.stringify( results, null, 2 ), 25 | contentType: 'application/json', 26 | } ); 27 | } ); 28 | 29 | const urlsToTest = ( process.env.URLS_TO_TEST || '' ) 30 | .split( '\n' ) 31 | .map( ( url ) => url.trim() ) 32 | .filter( Boolean ); 33 | 34 | const iterations = Number( process.env.TEST_ITERATIONS ); 35 | 36 | for ( const url of urlsToTest ) { 37 | for ( let i = 1; i <= iterations; i++ ) { 38 | test( `URL: "${ url }" (${ i } of ${ iterations })`, async ( { 39 | page, 40 | metrics, 41 | } ) => { 42 | await page.goto( `${ url.replace( /\/$/, '' ) }/?i=${ i }` ); 43 | 44 | const serverTiming = await metrics.getServerTiming(); 45 | 46 | results[ url ] ??= {}; 47 | 48 | for ( const [ key, value ] of Object.entries( serverTiming ) ) { 49 | results[ url ][ camelCaseDashes( key ) ] ??= []; 50 | results[ url ][ camelCaseDashes( key ) ].push( value ); 51 | } 52 | 53 | const ttfb = await metrics.getTimeToFirstByte(); 54 | const lcp = await metrics.getLargestContentfulPaint(); 55 | 56 | results[ url ].largestContentfulPaint ??= []; 57 | results[ url ].largestContentfulPaint.push( lcp ); 58 | results[ url ].timeToFirstByte ??= []; 59 | results[ url ].timeToFirstByte.push( ttfb ); 60 | results[ url ].lcpMinusTtfb ??= []; 61 | results[ url ].lcpMinusTtfb.push( lcp - ttfb ); 62 | } ); 63 | } 64 | } 65 | } ); 66 | -------------------------------------------------------------------------------- /env/tests/performance/utils/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper function to camel case the letter after dashes, removing the dashes. 3 | * 4 | * @param str 5 | */ 6 | export function camelCaseDashes( str: string ) { 7 | return str.replace( /-([a-z])/g, function ( g ) { 8 | return g[ 1 ].toUpperCase(); 9 | } ); 10 | } 11 | 12 | /** 13 | * Computes the median number from an array numbers. 14 | * 15 | * @param array List of numbers. 16 | * @return Median. 17 | */ 18 | export function median( array: number[] ) { 19 | const mid = Math.floor( array.length / 2 ); 20 | const numbers = [ ...array ].sort( ( a, b ) => a - b ); 21 | const result = 22 | array.length % 2 !== 0 23 | ? numbers[ mid ] 24 | : ( numbers[ mid - 1 ] + numbers[ mid ] ) / 2; 25 | 26 | return Number( result.toFixed( 2 ) ); 27 | } 28 | -------------------------------------------------------------------------------- /env/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 4 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 5 | "outDir": "./lib" /* Redirect output structure to the directory. */, 6 | "rootDir": "." /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 7 | "strict": true /* Enable all strict type-checking options. */, 8 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 9 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 10 | }, 11 | "exclude": [ "node_modules", "**/*.test.ts" ] 12 | } 13 | -------------------------------------------------------------------------------- /env/wp-content/mu-plugins/reset-helper.php: -------------------------------------------------------------------------------- 1 | function( Perflab_Server_Timing_Metric $metric ) { 41 | add_action( 42 | 'perflab_server_timing_send_header', 43 | static function() use ( $metric ) { 44 | global $wpdb; 45 | $metric->set_value( $wpdb->num_queries ); 46 | } 47 | ); 48 | }, 49 | 'access_cap' => 'exist', 50 | ) 51 | ); 52 | 53 | // Add memory usage. 54 | perflab_server_timing_register_metric( 55 | 'memory-usage', 56 | array( 57 | 'measure_callback' => function( Perflab_Server_Timing_Metric $metric ) { 58 | add_action( 59 | 'perflab_server_timing_send_header', 60 | static function() use ( $metric ) { 61 | $metric->set_value( memory_get_usage() ); 62 | } 63 | ); 64 | }, 65 | 'access_cap' => 'exist', 66 | ) 67 | ); 68 | } 69 | ); 70 | -------------------------------------------------------------------------------- /tests/blueprint-complex.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://playground.wordpress.net/blueprint-schema.json", 3 | "plugins": [ 4 | "performant-translations", 5 | "akismet" 6 | ], 7 | "steps": [ 8 | { 9 | "step": "defineWpConfigConsts", 10 | "consts": { 11 | "WP_DEBUG": true 12 | } 13 | }, 14 | { 15 | "step": "activatePlugin", 16 | "pluginName": "Dummy Plugin", 17 | "pluginPath": "/wordpress/wp-content/plugins/dummy-plugin" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tests/dummy-plugin/dummy-plugin.php: -------------------------------------------------------------------------------- 1 |