= github.getOctokit(token)
215 | await attachComment(octokit, checkName, updateComment, table, detailTable, flakyTable, checkInfos, prId)
216 | }
217 |
218 | core.setOutput('summary', buildTable(table))
219 | core.setOutput('detailed_summary', buildTable(detailTable))
220 | core.setOutput('flaky_summary', buildTable(flakyTable))
221 |
222 | // Set report URLs as output (newline-separated for multiple reports)
223 | const reportUrls = checkInfos.map(info => info.url).join('\n')
224 | core.setOutput('report_url', reportUrls)
225 |
226 | if (failOnFailure && conclusion === 'failure') {
227 | core.setFailed(`❌ Tests reported ${mergedResult.failed} failures`)
228 | }
229 |
230 | core.endGroup()
231 | } catch (error: any /* eslint-disable-line @typescript-eslint/no-explicit-any */) {
232 | core.setFailed(error.message)
233 | }
234 | }
235 |
236 | run()
237 |
--------------------------------------------------------------------------------
/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 (C) 2022 Mike Penz
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 |
203 |
204 | -----------------
205 |
206 | # github-actions-template
207 |
208 | The MIT License (MIT)
209 |
210 | Copyright (c) 2018 GitHub, Inc. and contributors
211 |
212 | Permission is hereby granted, free of charge, to any person obtaining a copy
213 | of this software and associated documentation files (the "Software"), to deal
214 | in the Software without restriction, including without limitation the rights
215 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
216 | copies of the Software, and to permit persons to whom the Software is
217 | furnished to do so, subject to the following conditions:
218 |
219 | The above copyright notice and this permission notice shall be included in
220 | all copies or substantial portions of the Software.
221 |
222 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
223 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
224 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
225 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
226 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
227 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
228 | THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | :octocat:
3 |
4 |
5 | action-junit-report
6 |
7 |
8 |
9 | ... reports JUnit test results as GitHub pull request check.
10 |
11 |
12 |
13 |

14 |
15 |
16 |
21 |
22 |
23 | -------
24 |
25 |
26 | What's included 🚀 •
27 | Setup 🛠️ •
28 | Sample 🖥️ •
29 | Contribute 🧬 •
30 | License 📓
31 |
32 |
33 | -------
34 |
35 | ### What's included 🚀
36 |
37 | - Flexible JUnit parser with wide support
38 | - Supports nested test suites
39 | - Blazingly fast execution
40 | - Lighweight
41 | - Rich build log output
42 |
43 | This action processes JUnit XML test reports on pull requests and shows the result as a PR check with summary and
44 | annotations.
45 |
46 | Based on action for [Surefire Reports by ScaCap](https://github.com/ScaCap/action-surefire-report)
47 |
48 | ## Setup
49 |
50 | ### Configure the workflow
51 |
52 | ```yml
53 | name: build
54 | on:
55 | pull_request:
56 |
57 | jobs:
58 | build:
59 | name: Build and Run Tests
60 | runs-on: ubuntu-latest
61 | steps:
62 | - name: Checkout Code
63 | uses: actions/checkout@v4
64 | - name: Build and Run Tests
65 | run: # execute your tests generating test results
66 | - name: Publish Test Report
67 | uses: mikepenz/action-junit-report@v5
68 | if: success() || failure() # always run even if the previous step fails
69 | with:
70 | report_paths: '**/build/test-results/test/TEST-*.xml'
71 | ```
72 |
73 | ### Inputs
74 |
75 | | **Input** | **Description** |
76 | |------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
77 | | `report_paths` | Optional. [Glob](https://github.com/actions/toolkit/tree/master/packages/glob) expression to junit report paths. Defaults to: `**/junit-reports/TEST-*.xml`. |
78 | | `token` | Optional. GitHub token for creating a check run. Set to `${{ github.token }}` by default. |
79 | | `group_reports` | Optional. Defines if different reports found by a single `report_paths` glob expression are grouped together. Defaults to `true`. |
80 | | `test_files_prefix` | Optional. Prepends the provided prefix to test file paths within the report when annotating on GitHub. |
81 | | `exclude_sources` | Optional. Provide `,` seperated array of folders to ignore for source lookup. Defaults to: `/build/,/__pycache__/` |
82 | | `check_name` | Optional. Check name to use when creating a check run. The default is `JUnit Test Report`. |
83 | | `suite_regex` | REMOVED (as of v5). Instead use `check_title_template` and configure: `{{BREAD_CRUMB}}{{SUITE_NAME}}/{{TEST_NAME}}` |
84 | | `commit` | Optional. The commit SHA to update the status. This is useful when you run it with `workflow_run`. |
85 | | `fail_on_failure` | Optional. Fail the build in case of a test failure. |
86 | | `fail_on_parse_error` | Optional. Fail the build if the test report file cannot be parsed. |
87 | | `require_tests` | Optional. Fail if no test are found. |
88 | | `require_passed_tests` | Optional. Fail if no passed test are found. (This is stricter than `require_tests`, which accepts skipped tests). |
89 | | `include_passed` | Optional. By default the action will skip passed items for the annotations. Enable this flag to include them. |
90 | | `include_skipped` | Optional. Controls whether skipped tests are included in the detailed summary table. Defaults to `true`. |
91 | | `check_retries` | Optional. If a testcase is retried, ignore the original failure. |
92 | | `check_title_template` | Optional. Template to configure the title format. Placeholders: {{FILE_NAME}}, {{SUITE_NAME}}, {{TEST_NAME}}, {{CLASS_NAME}}, {{BREAD_CRUMB}}. |
93 | | `bread_crumb_delimiter` | Optional. Defines the delimiter characters between the breadcrumb elements. Defaults to: `/`. |
94 | | `summary` | Optional. Additional text to summary output |
95 | | `check_annotations` | Optional. Defines if the checks will include annotations. If disabled skips all annotations for the check. (This does not affect `annotate_only`, which uses no checks). |
96 | | `update_check` | Optional. Uses an alternative API to update checks, use for cases with more than 50 annotations. Default: `false`. |
97 | | `annotate_only` | Optional. Will only annotate the results on the files, won't create a check run. Defaults to `false`. |
98 | | `transformers` | Optional. Array of `Transformer`s offering the ability to adjust the fileName. Defaults to: `[{"searchValue":"::","replaceValue":"/"}]` |
99 | | `job_summary` | Optional. Enables the publishing of the job summary for the results. Defaults to `true`. May be required to disable [Enterprise Server](https://github.com/mikepenz/action-junit-report/issues/637) |
100 | | `job_summary_text` | Optional. Additional text to include in the job summary prior to the tables. Defaults to empty string. |
101 | | `detailed_summary` | Optional. Include table with all test results in the summary (Also applies to comment). Defaults to `false`. |
102 | | `flaky_summary` | Optional. Include table with all flaky results in the summary (Also applies to comment). Defaults to `false`. |
103 | | `verbose_summary` | Optional. Detail table will note if there were no test annotations for a test suite (Also applies to comment). Defaults to `true`. |
104 | | `skip_success_summary` | Optional. Skips the summary table if only successful tests were detected (Also applies to comment). Defaults to `false`. |
105 | | `include_empty_in_summary` | Optional. Include entries in summaries that have 0 count. Defaults to `true`. |
106 | | `include_time_in_summary` | Optional. Include spent time in summaries. Defaults to `false`. |
107 | | `simplified_summary` | Optional. Use icons instead of text to indicate status in summary. Defaults to `false`. |
108 | | `group_suite` | Optional. If enabled, will group the testcases by test suite in the `detailed_summary`. Defaults to `false`. |
109 | | `comment` | Optional. Enables a comment being added to the PR with the summary tables (Respects the summary configuration flags). Defaults to `false`. |
110 | | `updateComment` | Optional. If a prior action run comment exists, it is updated. If disabled, new comments are creted for each run. Defaults to `true`. |
111 | | `annotate_notice` | Optional. Annotate passed test results along with warning/failed ones. Defaults to `false`. (Changed in v3.5.0) |
112 | | `follow_symlink` | Optional. Enables to follow symlinks when searching test files via the globber. Defaults to `false`. |
113 | | `job_name` | Optional. Specify the name of a check to update |
114 | | `annotations_limit` | Optional. Specify the limit for annotations. This will also interrupt parsing all test-suites if the limit is reached. Defaults to: `No Limit`. |
115 | | `skip_annotations` | Optional. Setting this flag will result in no annotations being added to the run. Defaults to `false`. |
116 | | `truncate_stack_traces` | Optional. Truncate stack traces from test output to 2 lines in annotations. Defaults to `true`. |
117 | | `resolve_ignore_classname` | Optional. Force ignore test case classname from the xml report (This can help fix issues with some tools/languages). Defaults to `false`. |
118 | | `skip_comment_without_tests` | Optional. Disable commenting if no tests are detected. Defaults to `false`. |
119 | | `pr_id` | Optional. PR number to comment on (useful for workflow_run contexts where the action runs outside the PR context). When provided, overrides the automatic PR detection. |
120 |
121 | ### Common Configurations
122 |
123 | Common report_paths
124 |
125 |
126 | - Surefire:
127 | `**/target/surefire-reports/TEST-*.xml`
128 | - sbt:
129 | `**/target/test-reports/*.xml`
130 |
131 |
132 |
133 |
134 | If you observe out-of-memory errors, follow the below configuration suggestion.
135 |
136 | > [!TIP]
137 | > FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
138 |
139 | Increase Node Heap Memory
140 |
141 |
142 | If you encounter an out-of-memory from Node, such as
143 |
144 | ```
145 | FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
146 | ```
147 |
148 | you can increase the memory allocation by setting an environment variable
149 |
150 | ```yaml
151 | - name: Publish Test Report
152 | uses: mikepenz/action-junit-report@v5
153 | env:
154 | NODE_OPTIONS: "--max-old-space-size=4096"
155 | if: success() || failure() # always run even if the previous step fails
156 | with:
157 | report_paths: '**/build/test-results/test/TEST-*.xml'
158 | ```
159 |
160 |
161 |
162 |
163 | ### Action outputs
164 |
165 | After action execution it will return the test counts as output.
166 |
167 | ```yml
168 | # ${{steps.{CHANGELOG_STEP_ID}.outputs.total}}
169 | ```
170 |
171 | A full set list of possible output values for this action.
172 |
173 | | **Output** | **Description** |
174 | |----------------------------|---------------------------------------------------------------------------------------------------------------------|
175 | | `outputs.total` | The total number of test cases covered by this test-step. |
176 | | `outputs.passed` | The number of passed test cases. |
177 | | `outputs.skipped` | The number of skipped test cases. |
178 | | `outputs.retried` | The number of retried test cases. |
179 | | `outputs.failed` | The number of failed test cases. |
180 | | `outputs.summary` | The short summary of the junit report. In html format (as also constructed by GitHub for the summary). |
181 | | `outputs.detailed_summary` | The full table with all test results in a summary. In html format (as also constructed by GitHub for the summary). |
182 | | `outputs.flaky_summary` | The full table with all flaky results in a summary. In html format (as also constructed by GitHub for the summary). |
183 | | `outputs.report_url` | The URL(s) to the test report(s). If multiple reports are created, they are separated by newlines. |
184 |
185 | ### PR run permissions
186 |
187 | The action requires `write` permission on the checks. If the GA token is `read-only` (this is a repository
188 | configuration) please enable `write` permission via:
189 |
190 | ```yml
191 | permissions:
192 | checks: write
193 | pull-requests: write # only required if `comment: true` was enabled
194 | ```
195 |
196 | Additionally for [security reasons], the github token used for `pull_request` workflows is [marked as read-only].
197 | If you want to post checks to a PR from an external repository, you will need to use a separate workflow
198 | which has a read/write token, or use a PAT with elevated permissions.
199 |
200 | [security reasons]: https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
201 |
202 | [marked as read-only]: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
203 |
204 | Example
205 |
206 |
207 | ```yml
208 | name: build
209 | on:
210 | pull_request:
211 |
212 | jobs:
213 | build:
214 | name: Build and Run Tests
215 | runs-on: ubuntu-latest
216 | steps:
217 | - name: Checkout Code
218 | uses: actions/checkout@v3
219 | - name: Build and Run Tests
220 | run: # execute your tests generating test results
221 | - name: Upload Test Report
222 | uses: actions/upload-artifact@v3
223 | if: always() # always run even if the previous step fails
224 | with:
225 | name: junit-test-results
226 | path: '**/build/test-results/test/TEST-*.xml'
227 | retention-days: 1
228 |
229 | ---
230 | name: report
231 | on:
232 | workflow_run:
233 | workflows: [ build ]
234 | types: [ completed ]
235 |
236 | permissions:
237 | checks: write
238 |
239 | jobs:
240 | checks:
241 | runs-on: ubuntu-latest
242 | steps:
243 | - name: Download Test Report
244 | uses: dawidd6/action-download-artifact@v2
245 | with:
246 | name: junit-test-results
247 | workflow: ${{ github.event.workflow.id }}
248 | run_id: ${{ github.event.workflow_run.id }}
249 | - name: Publish Test Report
250 | uses: mikepenz/action-junit-report@v5
251 | with:
252 | commit: ${{github.event.workflow_run.head_sha}}
253 | report_paths: '**/build/test-results/test/TEST-*.xml'
254 | # Optional: if you want to add PR comments from workflow_run context
255 | # comment: true
256 | # pr_id: ${{ github.event.workflow_run.pull_requests[0].number }}
257 | ```
258 |
259 | This will securely post the check results from the privileged workflow onto the PR's checks report.
260 |
261 | > [!TIP]
262 | > When running from `workflow_run` context, use the `pr_id` parameter to enable PR comments: `pr_id: ${{ github.event.workflow_run.pull_requests[0].number }}`
263 |
264 |
265 |
266 |
267 | In environments that do not allow `checks: write`, the action can be configured to leverage the annotate\_only option.
268 |
269 | Example
270 |
271 |
272 | ```yml
273 | name: pr
274 |
275 | on:
276 | pull_request:
277 |
278 | jobs:
279 | unit_test:
280 | runs-on: ubuntu-latest
281 | steps:
282 | - name: Checkout Code
283 | uses: actions/checkout@v4
284 |
285 | - name: Build and Run Tests
286 | run: # execute your tests generating test results
287 |
288 | - name: Write out Unit Test report annotation for forked repo
289 | if: ${{ failure() && (github.event.pull_request.head.repo.full_name != github.repository) }}
290 | uses: mikepenz/action-junit-report@v5
291 | with:
292 | annotate_only: true # forked repo cannot write to checks so just do annotations
293 | ```
294 |
295 | This will selectively use different methods for forked and unforked repos.
296 |
297 |
298 |
299 | ## Sample 🖥️
300 |
301 |
302 |

303 |
304 |
305 |
306 |

307 |
308 |
309 | ## Contribute 🧬
310 |
311 | ```bash
312 | # Install the dependencies
313 | $ npm install
314 |
315 | # Verify lint is happy
316 | $ npm run lint -- --fix
317 |
318 | # Format
319 | $ npm run format
320 |
321 | # Build the typescript and package it for distribution
322 | $ npm run build && npm run package
323 |
324 | # Run the tests, use to debug, and test it out
325 | $ npm test
326 | ```
327 |
328 | ### Credits
329 |
330 | Original idea and GitHub Actions by: https://github.com/ScaCap/action-surefire-report
331 |
332 | ## Other actions
333 |
334 | - [release-changelog-builder-action](https://github.com/mikepenz/release-changelog-builder-action)
335 | - [xray-action](https://github.com/mikepenz/xray-action/)
336 | - [jira-release-composition-action](https://github.com/mikepenz/jira-release-composite-action)
337 |
338 | ## License
339 |
340 | Copyright (C) 2025 Mike Penz
341 |
342 | Licensed under the Apache License, Version 2.0 (the "License");
343 | you may not use this file except in compliance with the License.
344 | You may obtain a copy of the License at
345 |
346 | http://www.apache.org/licenses/LICENSE-2.0
347 |
348 | Unless required by applicable law or agreed to in writing, software
349 | distributed under the License is distributed on an "AS IS" BASIS,
350 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
351 | See the License for the specific language governing permissions and
352 | limitations under the License.
353 |
--------------------------------------------------------------------------------
/src/testParser.ts:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import * as glob from '@actions/glob'
3 | import * as fs from 'fs'
4 | import * as parser from 'xml-js'
5 | import * as pathHelper from 'path'
6 | import {applyTransformer, removePrefix} from './utils.js'
7 |
8 | export interface ActualTestResult {
9 | name: string
10 | totalCount: number
11 | skippedCount: number
12 | failedCount: number
13 | passedCount: number
14 | retriedCount: number
15 | time: number
16 | annotations: Annotation[]
17 | globalAnnotations: Annotation[]
18 | testResults: ActualTestResult[]
19 | }
20 |
21 | interface TestCasesResult {
22 | totalCount: number
23 | skippedCount: number
24 | failedCount: number
25 | passedCount: number
26 | retriedCount: number
27 | time: number
28 | annotations: Annotation[]
29 | }
30 |
31 | export interface TestResult {
32 | checkName: string
33 | summary: string
34 | totalCount: number
35 | skipped: number
36 | failed: number
37 | passed: number
38 | retried: number
39 | time: number
40 | foundFiles: number
41 | globalAnnotations: Annotation[]
42 | testResults: ActualTestResult[]
43 | }
44 |
45 | export interface Annotation {
46 | path: string
47 | start_line: number
48 | end_line: number
49 | start_column: number
50 | end_column: number
51 | retries: number
52 | annotation_level: 'failure' | 'notice' | 'warning'
53 | status: 'success' | 'failure' | 'skipped'
54 | title: string
55 | message: string
56 | raw_details: string
57 | time: number
58 | }
59 |
60 | export interface Position {
61 | fileName: string
62 | line: number
63 | }
64 |
65 | export interface Transformer {
66 | searchValue: string
67 | replaceValue: string
68 | regex?: RegExp
69 | }
70 |
71 | /**
72 | * Copyright 2020 ScaCap
73 | * https://github.com/ScaCap/action-surefire-report/blob/master/utils.js#L6
74 | *
75 | * Modification Copyright 2022 Mike Penz
76 | * https://github.com/mikepenz/action-junit-report/
77 | */
78 | export async function resolveFileAndLine(
79 | file: string | null,
80 | line: string | null,
81 | className: string,
82 | output: string
83 | ): Promise {
84 | let fileName = file ? file : className.split('.').slice(-1)[0]
85 | const lineNumber = safeParseInt(line)
86 | try {
87 | if (fileName && lineNumber) {
88 | return {fileName, line: lineNumber}
89 | }
90 |
91 | const escapedFileName = fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace('::', '/') // Rust test output contains colons between package names - See: https://github.com/mikepenz/action-junit-report/pull/359
92 |
93 | const matches = output.match(new RegExp(` [^ ]*${escapedFileName}.*?:\\d+`, 'g'))
94 | if (!matches) return {fileName, line: lineNumber || 1}
95 |
96 | const [lastItem] = matches.slice(-1)
97 | const lineTokens = lastItem.split(':')
98 | line = lineTokens.pop() || '0'
99 |
100 | // check, if the error message is from a rust file -- this way we have the chance to find
101 | // out the involved test file
102 | // See: https://github.com/mikepenz/action-junit-report/pull/360
103 | {
104 | const lineNumberPrefix = lineTokens.pop() || ''
105 | if (lineNumberPrefix.endsWith('.rs')) {
106 | fileName = lineNumberPrefix.split(' ').pop() || ''
107 | }
108 | }
109 |
110 | core.debug(`Resolved file ${fileName} and line ${line}`)
111 |
112 | return {fileName, line: safeParseInt(line) || -1}
113 | } catch (error: unknown) {
114 | core.warning(`⚠️ Failed to resolve file (${file}) and/or line (${line}) for ${className} (${error})`)
115 | return {fileName, line: safeParseInt(line) || -1}
116 | }
117 | }
118 |
119 | /**
120 | * Parse the provided string line number, and return its value, or null if it is not available or NaN.
121 | */
122 | function safeParseInt(line: string | null): number | null {
123 | if (!line) return null
124 | const parsed = parseInt(line)
125 | if (isNaN(parsed)) return null
126 | return parsed
127 | }
128 |
129 | /**
130 | * Copyright 2020 ScaCap
131 | * https://github.com/ScaCap/action-surefire-report/blob/master/utils.js#L18
132 | *
133 | * Modification Copyright 2022 Mike Penz
134 | * https://github.com/mikepenz/action-junit-report/
135 | */
136 | const resolvePathCache: {[key: string]: string} = {}
137 |
138 | /**
139 | * Resolves the path of a given file, optionally following symbolic links.
140 | *
141 | * @param {string} workspace - The optional workspace directory.
142 | * @param {string} transformedFileName - The transformed file name to find.
143 | * @param {string[]} excludeSources - List of source paths to exclude.
144 | * @param {boolean} [followSymlink=false] - Whether to follow symbolic links.
145 | * @returns {Promise} - The resolved file path.
146 | */
147 | export async function resolvePath(
148 | workspace: string,
149 | transformedFileName: string,
150 | excludeSources: string[],
151 | followSymlink = false
152 | ): Promise {
153 | const fileName: string = removePrefix(transformedFileName, workspace)
154 | if (resolvePathCache[fileName]) {
155 | return resolvePathCache[fileName]
156 | }
157 |
158 | let workspacePath: string
159 | if (workspace.length === 0 || workspace.endsWith('/')) {
160 | workspacePath = workspace
161 | } else {
162 | workspacePath = `${workspace}/`
163 | }
164 |
165 | core.debug(`Resolving path for ${fileName} in ${workspacePath}`)
166 | const normalizedFilename = fileName.replace(/^\.\//, '') // strip relative prefix (./)
167 | const globber = await glob.create(`${workspacePath}**/${normalizedFilename}.*`, {
168 | followSymbolicLinks: followSymlink
169 | })
170 | const searchPath = globber.getSearchPaths() ? globber.getSearchPaths()[0] : ''
171 | for await (const result of globber.globGenerator()) {
172 | core.debug(`Matched file: ${result}`)
173 |
174 | const found = excludeSources.find(v => result.includes(v))
175 | if (!found) {
176 | const path = result.slice(searchPath.length + 1)
177 | core.debug(`Resolved path: ${path}`)
178 | resolvePathCache[fileName] = path
179 | return path
180 | }
181 | }
182 | resolvePathCache[fileName] = normalizedFilename
183 | return normalizedFilename
184 | }
185 |
186 | /**
187 | * Copyright 2020 ScaCap
188 | * https://github.com/ScaCap/action-surefire-report/blob/master/utils.js#L43
189 | *
190 | * Modification Copyright 2022 Mike Penz
191 | * https://github.com/mikepenz/action-junit-report/
192 | */
193 | export async function parseFile(
194 | file: string,
195 | suiteRegex = '', // no-op
196 | includePassed = false,
197 | annotateNotice = false,
198 | checkRetries = false,
199 | excludeSources: string[] = ['/build/', '/__pycache__/'],
200 | checkTitleTemplate: string | undefined = undefined,
201 | breadCrumbDelimiter = '/',
202 | testFilesPrefix = '',
203 | transformer: Transformer[] = [],
204 | followSymlink = false,
205 | annotationsLimit = -1,
206 | truncateStackTraces = true,
207 | failOnParseError = false,
208 | globalAnnotations: Annotation[] = [],
209 | resolveIgnoreClassname = false
210 | ): Promise {
211 | core.debug(`Parsing file ${file}`)
212 |
213 | const data: string = fs.readFileSync(file, 'utf8')
214 |
215 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
216 | let report: any
217 | try {
218 | report = JSON.parse(parser.xml2json(data, {compact: true}))
219 | } catch (error) {
220 | core.error(`⚠️ Failed to parse file (${file}) with error ${error}`)
221 | if (failOnParseError) throw Error(`⚠️ Failed to parse file (${file}) with error ${error}`)
222 | return undefined
223 | }
224 |
225 | // parse child test suites
226 | const testsuite = report.testsuites ? report.testsuites : report.testsuite
227 |
228 | if (!testsuite) {
229 | core.error(`⚠️ Failed to retrieve root test suite from file (${file})`)
230 | return undefined
231 | }
232 |
233 | const testResult = await parseSuite(
234 | testsuite,
235 | suiteRegex, // no-op
236 | '',
237 | breadCrumbDelimiter,
238 | includePassed,
239 | annotateNotice,
240 | checkRetries,
241 | excludeSources,
242 | checkTitleTemplate,
243 | testFilesPrefix,
244 | transformer,
245 | followSymlink,
246 | annotationsLimit,
247 | truncateStackTraces,
248 | globalAnnotations,
249 | resolveIgnoreClassname
250 | )
251 |
252 | if (testResult !== undefined && !testResult.name) {
253 | testResult.name = pathHelper.basename(file)
254 | }
255 |
256 | return testResult
257 | }
258 |
259 | function templateVar(varName: string): string {
260 | return `{{${varName}}}`
261 | }
262 |
263 | async function parseSuite(
264 | /* eslint-disable @typescript-eslint/no-explicit-any */
265 | suite: any,
266 | suiteRegex: string, // no-op
267 | breadCrumb: string,
268 | breadCrumbDelimiter = '/',
269 | includePassed = false,
270 | annotateNotice = false,
271 | checkRetries = false,
272 | excludeSources: string[],
273 | checkTitleTemplate: string | undefined = undefined,
274 | testFilesPrefix = '',
275 | transformer: Transformer[],
276 | followSymlink: boolean,
277 | annotationsLimit: number,
278 | truncateStackTraces: boolean,
279 | globalAnnotations: Annotation[],
280 | resolveIgnoreClassname = false
281 | ): Promise {
282 | if (!suite) {
283 | // not a valid suite, return fast
284 | return undefined
285 | }
286 |
287 | let suiteName = ''
288 | if (suite._attributes && suite._attributes.name) {
289 | suiteName = suite._attributes.name
290 | }
291 |
292 | let totalCount = 0
293 | let skippedCount = 0
294 | let failedCount = 0
295 | let passedCount = 0
296 | let retriedCount = 0
297 | let time = 0
298 | const annotations: Annotation[] = []
299 |
300 | // parse testCases
301 | if (suite.testcase) {
302 | const testcases = Array.isArray(suite.testcase) ? suite.testcase : suite.testcase ? [suite.testcase] : []
303 | const suiteFile = suite._attributes !== undefined ? suite._attributes.file : null
304 | const suiteLine = suite._attributes !== undefined ? suite._attributes.line : null
305 | const limit = annotationsLimit >= 0 ? annotationsLimit - globalAnnotations.length : annotationsLimit
306 | const parsedTestCases = await parseTestCases(
307 | suiteName,
308 | suiteFile,
309 | suiteLine,
310 | breadCrumb,
311 | testcases,
312 | includePassed,
313 | annotateNotice,
314 | checkRetries,
315 | excludeSources,
316 | checkTitleTemplate,
317 | testFilesPrefix,
318 | transformer,
319 | followSymlink,
320 | truncateStackTraces,
321 | limit,
322 | resolveIgnoreClassname
323 | )
324 |
325 | // expand global annotations array
326 | totalCount += parsedTestCases.totalCount
327 | skippedCount += parsedTestCases.skippedCount
328 | failedCount += parsedTestCases.failedCount
329 | passedCount += parsedTestCases.passedCount
330 | retriedCount += parsedTestCases.retriedCount
331 | time += parsedTestCases.time
332 | annotations.push(...parsedTestCases.annotations)
333 | globalAnnotations.push(...parsedTestCases.annotations)
334 | }
335 | // if we have a limit, and we are above the limit, return fast
336 | if (annotationsLimit > 0 && globalAnnotations.length >= annotationsLimit) {
337 | return {
338 | name: suiteName,
339 | totalCount,
340 | skippedCount,
341 | failedCount,
342 | passedCount,
343 | retriedCount,
344 | time,
345 | annotations,
346 | globalAnnotations,
347 | testResults: []
348 | }
349 | }
350 |
351 | // parse child test suites
352 | const childTestSuites = suite.testsuite
353 | ? Array.isArray(suite.testsuite)
354 | ? suite.testsuite
355 | : [suite.testsuite]
356 | : Array.isArray(suite.testsuites)
357 | ? suite.testsuites
358 | : [suite.testsuites]
359 |
360 | const childSuiteResults: ActualTestResult[] = []
361 | const childBreadCrumb = suiteName ? `${breadCrumb}${suiteName}${breadCrumbDelimiter}` : breadCrumb
362 | for (const childSuite of childTestSuites) {
363 | const childSuiteResult = await parseSuite(
364 | childSuite,
365 | suiteRegex,
366 | childBreadCrumb,
367 | breadCrumbDelimiter,
368 | includePassed,
369 | annotateNotice,
370 | checkRetries,
371 | excludeSources,
372 | checkTitleTemplate,
373 | testFilesPrefix,
374 | transformer,
375 | followSymlink,
376 | annotationsLimit,
377 | truncateStackTraces,
378 | globalAnnotations,
379 | resolveIgnoreClassname
380 | )
381 |
382 | if (childSuiteResult) {
383 | childSuiteResults.push(childSuiteResult)
384 | totalCount += childSuiteResult.totalCount
385 | skippedCount += childSuiteResult.skippedCount
386 | failedCount += childSuiteResult.failedCount
387 | passedCount += childSuiteResult.passedCount
388 | retriedCount += childSuiteResult.retriedCount
389 | time += childSuiteResult.time
390 | }
391 |
392 | // skip out if we reached our annotations limit
393 | if (annotationsLimit > 0 && globalAnnotations.length >= annotationsLimit) {
394 | return {
395 | name: suiteName,
396 | totalCount,
397 | skippedCount,
398 | failedCount,
399 | passedCount,
400 | retriedCount,
401 | time,
402 | annotations,
403 | globalAnnotations,
404 | testResults: childSuiteResults
405 | }
406 | }
407 | }
408 |
409 | return {
410 | name: suiteName,
411 | totalCount,
412 | skippedCount,
413 | failedCount,
414 | passedCount,
415 | retriedCount,
416 | time,
417 | annotations,
418 | globalAnnotations,
419 | testResults: childSuiteResults
420 | }
421 | }
422 |
423 | /**
424 | * Helper function to create an annotation for a test case
425 | */
426 | async function createTestCaseAnnotation(
427 | testcase: any,
428 | failure: any | null,
429 | failureIndex: number,
430 | totalFailures: number,
431 | suiteName: string,
432 | suiteFile: string | null,
433 | suiteLine: string | null,
434 | breadCrumb: string,
435 | testTime: number,
436 | skip: boolean,
437 | success: boolean,
438 | annotationLevel: 'failure' | 'notice' | 'warning',
439 | flakyFailuresCount: number,
440 | annotateNotice: boolean,
441 | failed: boolean,
442 | excludeSources: string[],
443 | checkTitleTemplate: string | undefined,
444 | testFilesPrefix: string,
445 | transformer: Transformer[],
446 | followSymlink: boolean,
447 | truncateStackTraces: boolean,
448 | resolveIgnoreClassname: boolean
449 | ): Promise {
450 | // Extract stack trace based on whether we have a failure or error
451 | const stackTrace: string = (
452 | (failure && failure._cdata) ||
453 | (failure && failure._text) ||
454 | (testcase.error && testcase.error._cdata) ||
455 | (testcase.error && testcase.error._text) ||
456 | ''
457 | )
458 | .toString()
459 | .trim()
460 |
461 | const stackTraceMessage = truncateStackTraces ? stackTrace.split('\n').slice(0, 2).join('\n') : stackTrace
462 |
463 | // Extract message based on failure or error
464 | const message: string = (
465 | (failure && failure._attributes && failure._attributes.message) ||
466 | (testcase.error && testcase.error._attributes && testcase.error._attributes.message) ||
467 | stackTraceMessage ||
468 | testcase._attributes.name
469 | ).trim()
470 |
471 | // Determine class name for resolution
472 | let resolveClassname = testcase._attributes.name
473 | if (!resolveIgnoreClassname && testcase._attributes.classname) {
474 | resolveClassname = testcase._attributes.classname
475 | }
476 |
477 | // Resolve file and line information
478 | const pos = await resolveFileAndLine(
479 | testcase._attributes.file || failure?._attributes?.file || suiteFile,
480 | testcase._attributes.line || failure?._attributes?.line || suiteLine,
481 | resolveClassname,
482 | stackTrace
483 | )
484 |
485 | // Apply transformations to filename
486 | let transformedFileName = pos.fileName
487 | for (const r of transformer) {
488 | transformedFileName = applyTransformer(r, transformedFileName)
489 | }
490 |
491 | // Resolve the full path
492 | const githubWorkspacePath = process.env['GITHUB_WORKSPACE']
493 | let resolvedPath: string = transformedFileName
494 | if (failed || (annotateNotice && success)) {
495 | if (fs.existsSync(transformedFileName)) {
496 | resolvedPath = transformedFileName
497 | } else if (githubWorkspacePath && fs.existsSync(`${githubWorkspacePath}${transformedFileName}`)) {
498 | resolvedPath = `${githubWorkspacePath}${transformedFileName}`
499 | } else {
500 | resolvedPath = await resolvePath(githubWorkspacePath || '', transformedFileName, excludeSources, followSymlink)
501 | }
502 | }
503 |
504 | core.debug(`Path prior to stripping: ${resolvedPath}`)
505 | if (githubWorkspacePath) {
506 | resolvedPath = resolvedPath.replace(`${githubWorkspacePath}/`, '') // strip workspace prefix, make the path relative
507 | }
508 |
509 | // Generate title
510 | let title = ''
511 | if (checkTitleTemplate) {
512 | // ensure to not duplicate the test_name if file_name is equal
513 | const fileName = pos.fileName !== testcase._attributes.name ? pos.fileName : ''
514 | const baseClassName = testcase._attributes.classname ? testcase._attributes.classname : testcase._attributes.name
515 | const className = baseClassName.split('.').slice(-1)[0]
516 | title = checkTitleTemplate
517 | .replace(templateVar('FILE_NAME'), fileName)
518 | .replace(templateVar('BREAD_CRUMB'), breadCrumb ?? '')
519 | .replace(templateVar('SUITE_NAME'), suiteName ?? '')
520 | .replace(templateVar('TEST_NAME'), testcase._attributes.name)
521 | .replace(templateVar('CLASS_NAME'), className)
522 | } else if (pos.fileName !== testcase._attributes.name) {
523 | // special handling to use class name only for title in case class name was ignored for `resolveClassname`
524 | if (resolveIgnoreClassname && testcase._attributes.classname) {
525 | title = `${testcase._attributes.classname}.${testcase._attributes.name}`
526 | } else {
527 | title = `${pos.fileName}.${testcase._attributes.name}`
528 | }
529 | } else {
530 | title = `${testcase._attributes.name}`
531 | }
532 |
533 | // Add failure index to title if multiple failures exist
534 | if (totalFailures > 1) {
535 | title = `${title} (failure ${failureIndex + 1}/${totalFailures})`
536 | }
537 |
538 | // optionally attach the prefix to the path
539 | resolvedPath = testFilesPrefix ? pathHelper.join(testFilesPrefix, resolvedPath) : resolvedPath
540 |
541 | const testTimeString = testTime > 0 ? `${testTime}s` : ''
542 | core.info(`${resolvedPath}:${pos.line} | ${message.split('\n', 1)[0]}${testTimeString}`)
543 |
544 | return {
545 | path: resolvedPath,
546 | start_line: pos.line,
547 | end_line: pos.line,
548 | start_column: 0,
549 | end_column: 0,
550 | retries: (testcase.retries || 0) + flakyFailuresCount,
551 | annotation_level: annotationLevel,
552 | status: skip ? 'skipped' : success ? 'success' : 'failure',
553 | title: escapeEmoji(title),
554 | message: escapeEmoji(message),
555 | raw_details: escapeEmoji(stackTrace),
556 | time: testTime
557 | }
558 | }
559 |
560 | async function parseTestCases(
561 | suiteName: string,
562 | suiteFile: string | null,
563 | suiteLine: string | null,
564 | breadCrumb: string,
565 | testcases: any[],
566 | includePassed = false,
567 | annotateNotice = false,
568 | checkRetries = false,
569 | excludeSources: string[],
570 | checkTitleTemplate: string | undefined = undefined,
571 | testFilesPrefix = '',
572 | transformer: Transformer[],
573 | followSymlink: boolean,
574 | truncateStackTraces: boolean,
575 | limit = -1,
576 | resolveIgnoreClassname = false
577 | ): Promise {
578 | const annotations: Annotation[] = []
579 | let totalCount = 0
580 | let skippedCount = 0
581 | let retriedCount = 0
582 | let time = 0
583 | if (checkRetries) {
584 | // identify duplicates in case of flaky tests, and remove them
585 | const testcaseMap = new Map()
586 | for (const testcase of testcases) {
587 | const key = testcase._attributes.name
588 | if (testcaseMap.get(key) !== undefined) {
589 | // testcase with matching name exists
590 | const failed = testcase.failure || testcase.error
591 | const previous = testcaseMap.get(key)
592 | const previousFailed = previous.failure || previous.error
593 | if (failed && !previousFailed) {
594 | // previous is a success, drop failure
595 | previous.retries = (previous.retries || 0) + 1
596 | retriedCount += 1
597 | core.debug(`Drop flaky test failure for (1): ${key}`)
598 | } else if (!failed && previousFailed) {
599 | // previous failed, new one not, replace
600 | testcase.retries = (previous.retries || 0) + 1
601 | testcaseMap.set(key, testcase)
602 | retriedCount += 1
603 | core.debug(`Drop flaky test failure for (2): ${JSON.stringify(testcase)}`)
604 | }
605 | } else {
606 | testcaseMap.set(key, testcase)
607 | }
608 | }
609 | testcases = Array.from(testcaseMap.values())
610 | }
611 |
612 | let testCaseFailedCount = 0 // Track number of test cases that failed
613 |
614 | for (const testcase of testcases) {
615 | totalCount++
616 |
617 | // fish the time-taken out of the test case attributes, if present
618 | const testTime = testcase._attributes.time === undefined ? 0 : parseFloat(testcase._attributes.time)
619 | time += testTime
620 |
621 | const testFailure = testcase.failure || testcase.error // test failed
622 | const skip =
623 | testcase.skipped || testcase._attributes.status === 'disabled' || testcase._attributes.status === 'ignored'
624 | const failed = testFailure && !skip // test failure, but was skipped -> don't fail if a ignored test failed
625 | const success = !testFailure // not a failure -> thus a success
626 | const annotationLevel = success || skip ? 'notice' : 'failure' // a skipped test shall not fail the run
627 |
628 | if (skip) {
629 | skippedCount++
630 | }
631 |
632 | // Count this test case as failed if it has any failures (regardless of how many)
633 | if (failed) {
634 | testCaseFailedCount++
635 | }
636 |
637 | // If this isn't reported as a failure and processing all passed tests
638 | // isn't enabled, then skip the rest of the processing.
639 | if (annotationLevel !== 'failure' && !includePassed) {
640 | continue
641 | }
642 |
643 | // in some definitions `failure` may be an array
644 | const failures = testcase.failure ? (Array.isArray(testcase.failure) ? testcase.failure : [testcase.failure]) : []
645 |
646 | // identify the number of flaky failures
647 | const flakyFailuresCount = testcase.flakyFailure
648 | ? Array.isArray(testcase.flakyFailure)
649 | ? testcase.flakyFailure.length
650 | : 1
651 | : 0
652 |
653 | // Handle multiple failures or single case (success/skip/error)
654 | const failuresToProcess = failures.length > 0 ? failures : [null] // Process at least once for non-failure cases
655 |
656 | for (let failureIndex = 0; failureIndex < failuresToProcess.length; failureIndex++) {
657 | const failure = failuresToProcess[failureIndex]
658 |
659 | const annotation = await createTestCaseAnnotation(
660 | testcase,
661 | failure,
662 | failureIndex,
663 | failures.length,
664 | suiteName,
665 | suiteFile,
666 | suiteLine,
667 | breadCrumb,
668 | testTime,
669 | skip,
670 | success,
671 | annotationLevel,
672 | flakyFailuresCount,
673 | annotateNotice,
674 | failed,
675 | excludeSources,
676 | checkTitleTemplate,
677 | testFilesPrefix,
678 | transformer,
679 | followSymlink,
680 | truncateStackTraces,
681 | resolveIgnoreClassname
682 | )
683 |
684 | annotations.push(annotation)
685 |
686 | if (limit >= 0 && annotations.length >= limit) break
687 | }
688 |
689 | // Break from the outer testcase loop if we've reached the limit
690 | if (limit >= 0 && annotations.length >= limit) break
691 | }
692 |
693 | const failedCount = testCaseFailedCount // Use test case count, not annotation count
694 | const passedCount = totalCount - failedCount - skippedCount
695 | return {
696 | totalCount,
697 | skippedCount,
698 | failedCount,
699 | passedCount,
700 | retriedCount,
701 | time,
702 | annotations
703 | }
704 | }
705 |
706 | /**
707 | * Copyright 2020 ScaCap
708 | * https://github.com/ScaCap/action-surefire-report/blob/master/utils.js#L113
709 | *
710 | * Modification Copyright 2022 Mike Penz
711 | * https://github.com/mikepenz/action-junit-report/
712 | */
713 | export async function parseTestReports(
714 | checkName: string,
715 | summary: string,
716 | reportPaths: string,
717 | suiteRegex: string, // no-op
718 | includePassed = false,
719 | annotateNotice = false,
720 | checkRetries = false,
721 | excludeSources: string[],
722 | checkTitleTemplate: string | undefined = undefined,
723 | breadCrumbDelimiter: string,
724 | testFilesPrefix = '',
725 | transformer: Transformer[] = [],
726 | followSymlink = false,
727 | annotationsLimit = -1,
728 | truncateStackTraces = true,
729 | failOnParseError = false,
730 | resolveIgnoreClassname = false
731 | ): Promise {
732 | core.debug(`Process test report for: ${reportPaths} (${checkName})`)
733 | const globber = await glob.create(reportPaths, {followSymbolicLinks: followSymlink})
734 | const globalAnnotations: Annotation[] = []
735 | const testResults: ActualTestResult[] = []
736 | let totalCount = 0
737 | let skipped = 0
738 | let failed = 0
739 | let passed = 0
740 | let retried = 0
741 | let time = 0
742 | let foundFiles = 0
743 | for await (const file of globber.globGenerator()) {
744 | foundFiles++
745 | core.debug(`Parsing report file: ${file}`)
746 |
747 | const testResult = await parseFile(
748 | file,
749 | suiteRegex,
750 | includePassed,
751 | annotateNotice,
752 | checkRetries,
753 | excludeSources,
754 | checkTitleTemplate,
755 | breadCrumbDelimiter,
756 | testFilesPrefix,
757 | transformer,
758 | followSymlink,
759 | annotationsLimit,
760 | truncateStackTraces,
761 | failOnParseError,
762 | globalAnnotations,
763 | resolveIgnoreClassname
764 | )
765 |
766 | if (!testResult) continue
767 | const {totalCount: c, skippedCount: s, failedCount: f, passedCount: p, retriedCount: r, time: t} = testResult
768 | totalCount += c
769 | skipped += s
770 | failed += f
771 | passed += p
772 | retried += r
773 | time += t
774 | testResults.push(testResult)
775 |
776 | if (annotationsLimit > 0 && globalAnnotations.length >= annotationsLimit) {
777 | break
778 | }
779 | }
780 |
781 | return {
782 | checkName,
783 | summary,
784 | totalCount,
785 | skipped,
786 | failed,
787 | passed,
788 | retried,
789 | time,
790 | foundFiles,
791 | globalAnnotations,
792 | testResults
793 | }
794 | }
795 |
796 | /**
797 | * Escape emoji sequences.
798 | */
799 | export function escapeEmoji(input: string): string {
800 | const regex =
801 | /[\u{1f300}-\u{1f5ff}\u{1f900}-\u{1f9ff}\u{1f600}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{2600}-\u{26ff}\u{2700}-\u{27bf}\u{1f1e6}-\u{1f1ff}\u{1f191}-\u{1f251}\u{1f004}\u{1f0cf}\u{1f170}-\u{1f171}\u{1f17e}-\u{1f17f}\u{1f18e}\u{3030}\u{2b50}\u{2b55}\u{2934}-\u{2935}\u{2b05}-\u{2b07}\u{2b1b}-\u{2b1c}\u{3297}\u{3299}\u{303d}\u{00a9}\u{00ae}\u{2122}\u{23f3}\u{24c2}\u{23e9}-\u{23ef}\u{25b6}\u{23f8}-\u{23fa}]/gu
802 | return input.replace(regex, ``) // replace emoji with empty string (\\u${(match.codePointAt(0) || "").toString(16)})
803 | }
804 |
--------------------------------------------------------------------------------