├── .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 |
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