├── .eslintignore ├── .gitattributes ├── .github ├── dependabot.yml ├── linters │ ├── .eslintrc.yml │ ├── .yaml-lint.yml │ └── tsconfig.json └── workflows │ ├── check-dist.yml │ ├── ci.yml │ ├── codeql.yml │ └── linter.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __tests__ ├── __fixtures__ │ ├── report-invalid.json │ ├── report-nested.json │ ├── report-sharded.json │ ├── report-valid.json │ └── report-without-duration.json ├── __snapshots__ │ └── report.test.ts.snap ├── formatting.test.ts ├── fs.test.ts ├── github.test.ts ├── icons.test.ts ├── index.test.ts └── report.test.ts ├── action.yml ├── assets ├── comment-failed.png └── comment-passed.png ├── dist ├── index.js └── licenses.txt ├── package-lock.json ├── package.json ├── src ├── formatting.ts ├── fs.ts ├── github.ts ├── icons.ts ├── index.ts └── report.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | dist/ 3 | node_modules/ 4 | coverage/ 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/** -diff linguist-generated=true -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | labels: 6 | - dependencies 7 | schedule: 8 | interval: monthly 9 | - package-ecosystem: github-actions 10 | directory: / 11 | labels: 12 | - dependencies 13 | schedule: 14 | interval: monthly 15 | -------------------------------------------------------------------------------- /.github/linters/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | node: true 3 | es6: true 4 | jest: true 5 | 6 | globals: 7 | Atomics: readonly 8 | SharedArrayBuffer: readonly 9 | 10 | ignorePatterns: 11 | - '!.*' 12 | - '**/node_modules/.*' 13 | - '**/dist/.*' 14 | - '**/coverage/.*' 15 | - '*.json' 16 | 17 | parser: '@typescript-eslint/parser' 18 | 19 | parserOptions: 20 | ecmaVersion: 2023 21 | sourceType: module 22 | project: 23 | - './.github/linters/tsconfig.json' 24 | - './tsconfig.json' 25 | 26 | plugins: 27 | - jest 28 | - '@typescript-eslint' 29 | 30 | extends: 31 | - eslint:recommended 32 | - plugin:@typescript-eslint/eslint-recommended 33 | - plugin:@typescript-eslint/recommended 34 | - plugin:github/recommended 35 | - plugin:jest/recommended 36 | 37 | rules: 38 | { 39 | 'camelcase': 'off', 40 | 'eslint-comments/no-use': 'off', 41 | 'eslint-comments/no-unused-disable': 'off', 42 | 'i18n-text/no-en': 'off', 43 | 'import/no-namespace': 'off', 44 | 'no-console': 'off', 45 | 'no-unused-vars': 'off', 46 | 'prettier/prettier': 'error', 47 | 'semi': 'off', 48 | 'no-shadow': 'off', 49 | 'github/no-then': 'off', 50 | 'no-irregular-whitespace': 'off', 51 | '@typescript-eslint/array-type': 'error', 52 | '@typescript-eslint/await-thenable': 'error', 53 | '@typescript-eslint/ban-ts-comment': 'error', 54 | '@typescript-eslint/consistent-type-assertions': 'error', 55 | '@typescript-eslint/explicit-member-accessibility': 56 | ['error', { 'accessibility': 'no-public' }], 57 | '@typescript-eslint/explicit-function-return-type': 58 | ['error', { 'allowExpressions': true }], 59 | '@typescript-eslint/func-call-spacing': ['error', 'never'], 60 | '@typescript-eslint/no-array-constructor': 'error', 61 | '@typescript-eslint/no-empty-interface': 'error', 62 | '@typescript-eslint/no-explicit-any': 'warn', 63 | '@typescript-eslint/no-extraneous-class': 'error', 64 | '@typescript-eslint/no-for-in-array': 'error', 65 | '@typescript-eslint/no-inferrable-types': 'error', 66 | '@typescript-eslint/no-misused-new': 'error', 67 | '@typescript-eslint/no-namespace': 'error', 68 | '@typescript-eslint/no-non-null-assertion': 'warn', 69 | '@typescript-eslint/no-require-imports': 'error', 70 | '@typescript-eslint/no-unnecessary-qualifier': 'error', 71 | '@typescript-eslint/no-unnecessary-type-assertion': 'error', 72 | '@typescript-eslint/no-unused-vars': 'error', 73 | '@typescript-eslint/no-useless-constructor': 'error', 74 | '@typescript-eslint/no-var-requires': 'error', 75 | '@typescript-eslint/prefer-for-of': 'warn', 76 | '@typescript-eslint/prefer-function-type': 'warn', 77 | '@typescript-eslint/prefer-includes': 'error', 78 | '@typescript-eslint/prefer-string-starts-ends-with': 'error', 79 | '@typescript-eslint/promise-function-async': 'error', 80 | '@typescript-eslint/require-array-sort-compare': 'error', 81 | '@typescript-eslint/restrict-plus-operands': 'error', 82 | '@typescript-eslint/semi': ['error', 'never'], 83 | '@typescript-eslint/space-before-function-paren': 'off', 84 | '@typescript-eslint/type-annotation-spacing': 'error', 85 | '@typescript-eslint/unbound-method': 'error' 86 | } 87 | -------------------------------------------------------------------------------- /.github/linters/.yaml-lint.yml: -------------------------------------------------------------------------------- 1 | rules: 2 | document-end: disable 3 | document-start: 4 | level: warning 5 | present: false 6 | line-length: 7 | level: warning 8 | max: 80 9 | allow-non-breakable-words: true 10 | allow-non-breakable-inline-mappings: true 11 | -------------------------------------------------------------------------------- /.github/linters/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "noEmit": true 6 | }, 7 | "include": ["../../__tests__/**/*", "../../src/**/*"], 8 | "exclude": ["../../dist", "../../node_modules", "../../coverage", "*.json"] 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/check-dist.yml: -------------------------------------------------------------------------------- 1 | # In TypeScript actions, `dist/index.js` is a special file. When you reference 2 | # an action with `uses:`, `dist/index.js` is the code that will be run. For this 3 | # project, the `dist/index.js` file is generated from other source files through 4 | # the build process. We need to make sure that the checked-in `dist/index.js` 5 | # file matches what is expected from the build. 6 | # 7 | # This workflow will fail if the checked-in `dist/index.js` file does not match 8 | # what is expected from the build. 9 | 10 | name: Check dist 11 | 12 | on: 13 | push: 14 | branches: 15 | - main 16 | paths-ignore: 17 | - '**.md' 18 | pull_request: 19 | paths-ignore: 20 | - '**.md' 21 | workflow_dispatch: 22 | 23 | jobs: 24 | check-dist: 25 | name: Check dist/ 26 | runs-on: ubuntu-latest 27 | 28 | permissions: 29 | contents: read 30 | statuses: write 31 | 32 | steps: 33 | - name: Checkout 34 | id: checkout 35 | uses: actions/checkout@v4 36 | 37 | - name: Setup Node.js 38 | uses: actions/setup-node@v3 39 | with: 40 | node-version: 18 41 | cache: npm 42 | 43 | - name: Install Dependencies 44 | id: install 45 | run: npm ci 46 | 47 | - name: Build dist/ Directory 48 | id: build 49 | run: npm run bundle 50 | 51 | - name: Compare Expected and Actual Directories 52 | id: diff 53 | run: | 54 | if [ "$(git diff --ignore-space-at-eol --text dist/ | wc -l)" -gt "0" ]; then 55 | echo "Detected uncommitted changes after build. See status below:" 56 | git diff --ignore-space-at-eol --text dist/ 57 | exit 1 58 | fi 59 | 60 | # If index.js was different than expected, upload the expected version as 61 | # a workflow artifact. 62 | - uses: actions/upload-artifact@v4 63 | if: ${{ failure() && steps.diff.conclusion == 'failure' }} 64 | with: 65 | name: dist 66 | path: dist/ 67 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | - 'releases/*' 9 | 10 | jobs: 11 | test-typescript: 12 | name: TypeScript Tests 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js 20 | id: setup-node 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: 18 24 | cache: npm 25 | 26 | - name: Install Dependencies 27 | id: npm-ci 28 | run: npm ci 29 | 30 | - name: Check Format 31 | id: npm-format-check 32 | run: npm run format:check 33 | 34 | - name: Lint 35 | id: npm-lint 36 | run: npm run lint 37 | 38 | # - name: Test 39 | # id: npm-ci-test 40 | # run: npm run ci-test 41 | 42 | # test-action: 43 | # name: GitHub Actions Test 44 | # runs-on: ubuntu-latest 45 | 46 | # steps: 47 | # - name: Checkout 48 | # uses: actions/checkout@v4 49 | 50 | # - name: Test Local Action 51 | # id: test-action 52 | # uses: ./ 53 | # with: 54 | # milliseconds: 1000 55 | 56 | # - name: Print Output 57 | # id: output 58 | # run: echo "${{ steps.test-action.outputs.time }}" 59 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | schedule: 11 | - cron: '31 7 * * 3' 12 | 13 | jobs: 14 | analyze: 15 | name: Analyze 16 | runs-on: ubuntu-latest 17 | 18 | permissions: 19 | actions: read 20 | checks: write 21 | contents: read 22 | security-events: write 23 | 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | language: 28 | - TypeScript 29 | 30 | steps: 31 | - name: Checkout 32 | id: checkout 33 | uses: actions/checkout@v4 34 | 35 | - name: Initialize CodeQL 36 | id: initialize 37 | uses: github/codeql-action/init@v2 38 | with: 39 | languages: ${{ matrix.language }} 40 | source-root: src 41 | 42 | - name: Autobuild 43 | id: autobuild 44 | uses: github/codeql-action/autobuild@v2 45 | 46 | - name: Perform CodeQL Analysis 47 | id: analyze 48 | uses: github/codeql-action/analyze@v2 49 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: Linter 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches-ignore: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | name: Lint Code Base 14 | runs-on: ubuntu-latest 15 | 16 | permissions: 17 | contents: read 18 | packages: read 19 | statuses: write 20 | 21 | steps: 22 | - name: Checkout 23 | id: checkout 24 | uses: actions/checkout@v4 25 | 26 | - name: Setup Node.js 27 | id: setup-node 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: 18 31 | cache: npm 32 | 33 | - name: Install Dependencies 34 | id: install 35 | run: npm ci 36 | 37 | - name: Lint Code Base 38 | id: super-linter 39 | uses: super-linter/super-linter/slim@v5 40 | env: 41 | DEFAULT_BRANCH: main 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | TYPESCRIPT_DEFAULT_STYLE: prettier 44 | VALIDATE_JSCPD: false 45 | VALIDATE_MARKDOWN: false 46 | VALIDATE_NATURAL_LANGUAGE: false 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # next.js build output 76 | .next 77 | 78 | # nuxt.js build output 79 | .nuxt 80 | 81 | # vuepress build output 82 | .vuepress/dist 83 | 84 | # Serverless directories 85 | .serverless/ 86 | 87 | # FuseBox cache 88 | .fusebox/ 89 | 90 | # DynamoDB Local files 91 | .dynamodb/ 92 | 93 | # OS metadata 94 | .DS_Store 95 | Thumbs.db 96 | 97 | # Ignore built ts files 98 | __tests__/runner/* 99 | 100 | # IDE files 101 | .idea 102 | .vscode 103 | *.code-workspace 104 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | coverage/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": true, 5 | "semi": false, 6 | "singleQuote": true, 7 | "quoteProps": "consistent", 8 | "trailingComma": "none", 9 | "bracketSpacing": true, 10 | "arrowParens": "always", 11 | "proseWrap": "always", 12 | "htmlWhitespaceSensitivity": "css", 13 | "endOfLine": "lf" 14 | } 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [3.9.0] - 2025-04-26 4 | 5 | - Add output variable for raw report data (@seongminn) 6 | 7 | ## [3.8.0] - 2025-01-30 8 | 9 | - Allow using a different action for creating and updating the comments 10 | 11 | ## [3.7.0] - 2024-11-25 12 | 13 | - Add option to display a footer with additional content below the summary (@mskelton) 14 | 15 | ## [3.6.0] - 2024-10-05 16 | 17 | - Add option to display npm command for re-running failed tests (@mskelton) 18 | - Render commit sha as clickable link to commit page (@mskelton) 19 | 20 | ## [3.5.2] - 2024-07-08 21 | 22 | - Update action input docs 23 | 24 | ## [3.5.1] - 2024-07-07 25 | 26 | - Display correct commit sha when triggered from pull request comment 27 | 28 | ## [3.5.0] - 2024-07-07 29 | 30 | - Support pull request comment as workflow trigger 31 | 32 | ## [3.4.0] - 2024-07-02 33 | 34 | - Add ability to display custom information by @fungairino 35 | 36 | ## [3.3.0] - 2024-04-21 37 | 38 | - Add support for indefinitely nested specs 39 | - Switch from `→` to `›` for joining test paths 40 | 41 | ## [3.2.0] - 2024-04-11 42 | 43 | - Allow all workflow event types 44 | 45 | ## [3.1.0] - 2024-04-11 46 | 47 | - Support manual workflow dispatch 48 | 49 | ## [3.0.1] - 2024-04-02 50 | 51 | - Bump dependencies 52 | 53 | ## [3.0.0] - 2023-12-13 54 | 55 | - Distinguish between reports by workflow name 56 | - Distinguish between reports by new optional `report-tag` input 57 | - Allow comments by non-bot users 58 | 59 | ## [2.1.2] - 2023-09-26 60 | 61 | - Calculate total duration if missing from Playwright report 62 | - Only display fractions for sub-minute durations 63 | 64 | ## [2.1.1] - 2023-09-10 65 | 66 | - Add `job-summary` input var 67 | 68 | ## [2.1.0] - 2023-09-10 69 | 70 | - Create job summary 71 | 72 | ## [2.0.0] - 2023-09-09 73 | 74 | - Rename action 75 | - Create output var for rendered summary 76 | 77 | ## [1.1.2] - 2023-09-09 78 | 79 | - Display report link 80 | 81 | ## [1.1.1] - 2023-09-09 82 | 83 | - Correctly count sharded tests 84 | 85 | ## [1.1.0] - 2023-09-08 86 | 87 | - Improve formatting 88 | 89 | ## [1.0.0] - 2023-09-07 90 | 91 | - Initial release 92 | 93 | [3.9.0]: https://github.com/daun/playwright-report-summary/releases/tag/v3.9.0 94 | [3.8.0]: https://github.com/daun/playwright-report-summary/releases/tag/v3.8.0 95 | [3.7.0]: https://github.com/daun/playwright-report-summary/releases/tag/v3.7.0 96 | [3.6.0]: https://github.com/daun/playwright-report-summary/releases/tag/v3.6.0 97 | [3.5.2]: https://github.com/daun/playwright-report-summary/releases/tag/v3.5.2 98 | [3.5.1]: https://github.com/daun/playwright-report-summary/releases/tag/v3.5.1 99 | [3.5.0]: https://github.com/daun/playwright-report-summary/releases/tag/v3.5.0 100 | [3.4.0]: https://github.com/daun/playwright-report-summary/releases/tag/v3.4.0 101 | [3.3.0]: https://github.com/daun/playwright-report-summary/releases/tag/v3.3.0 102 | [3.2.0]: https://github.com/daun/playwright-report-summary/releases/tag/v3.2.0 103 | [3.1.0]: https://github.com/daun/playwright-report-summary/releases/tag/v3.1.0 104 | [3.0.1]: https://github.com/daun/playwright-report-summary/releases/tag/v3.0.1 105 | [3.0.0]: https://github.com/daun/playwright-report-summary/releases/tag/v3.0.0 106 | [2.1.2]: https://github.com/daun/playwright-report-summary/releases/tag/v2.1.2 107 | [2.1.1]: https://github.com/daun/playwright-report-summary/releases/tag/v2.1.1 108 | [2.1.0]: https://github.com/daun/playwright-report-summary/releases/tag/v2.1.0 109 | [2.0.0]: https://github.com/daun/playwright-report-summary/releases/tag/v2.0.0 110 | [1.1.2]: https://github.com/daun/playwright-report-summary/releases/tag/v1.1.2 111 | [1.1.1]: https://github.com/daun/playwright-report-summary/releases/tag/v1.1.1 112 | [1.1.0]: https://github.com/daun/playwright-report-summary/releases/tag/v1.1.0 113 | [1.0.0]: https://github.com/daun/playwright-report-summary/releases/tag/v1.0.0 114 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2023 Philipp Daun 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![report](https://icongr.am/octicons/comment-discussion.svg?size=22&color=abb4bf)   Playwright Report Summary 2 | 3 | [![GitHub Super-Linter](https://github.com/actions/typescript-action/actions/workflows/linter.yml/badge.svg)](https://github.com/super-linter/super-linter) 4 | ![CI](https://github.com/actions/typescript-action/actions/workflows/ci.yml/badge.svg) 5 | 6 | A GitHub action to report Playwright test results as a pull request comment. 7 | 8 | - Parse the JSON test report generated by Playwright 9 | - Generate a markdown summary of the test results 10 | - Post the summary as a pull request comment 11 | - Uses GitHub's official [icons](https://primer.style/design/foundations/icons) and [color scheme](https://primer.style/design/foundations/color) 12 | 13 | ## Examples 14 | 15 | 16 | 17 | 18 | 19 | ## Usage 20 | 21 | ### Basic usage 22 | 23 | Playwright must be configured to [generate a JSON report](https://playwright.dev/docs/test-reporters#json-reporter) 24 | and write it to disk. This action receives the report file path as input, in this case `results.json`. 25 | 26 | Note the `if: always()` to ensure the report comment is created even if the tests failed. 27 | 28 | ```yaml 29 | jobs: 30 | test: 31 | name: Run playwright tests 32 | needs: install 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: actions/setup-node@v3 36 | with: 37 | node-version: 20 38 | 39 | - run: PLAYWRIGHT_JSON_OUTPUT_NAME=results.json npx playwright test --reporter=json 40 | 41 | - uses: daun/playwright-report-summary@v3 42 | if: always() 43 | with: 44 | report-file: results.json 45 | ``` 46 | 47 | ### Usage with custom commenting logic 48 | 49 | The action will do the work of creating comments and updating them whenever tests re-run. If you 50 | require custom logic for creating and updating comments, you can disable the default logic and use 51 | any other action in combination with the `summary` output of this action. 52 | 53 | A few recommended actions are 54 | [Sticky Pull Request Comment](https://github.com/marketplace/actions/sticky-pull-request-comment) 55 | and [Create or Update Comment](https://github.com/marketplace/actions/create-or-update-comment). 56 | 57 | ```diff 58 | - uses: daun/playwright-report-summary@v3 59 | if: always() 60 | id: summary 61 | with: 62 | report-file: results.json 63 | + create-comment: false 64 | 65 | + - uses: marocchino/sticky-pull-request-comment@v2 66 | + with: 67 | + message: ${{ steps.summary.outputs.summary }} 68 | ``` 69 | 70 | ## Options 71 | 72 | ```yaml 73 | - uses: daun/playwright-report-summary@v3 74 | if: always() 75 | with: 76 | # The GitHub access token to use for API requests 77 | # Defaults to the standard GITHUB_TOKEN 78 | github-token: '' 79 | 80 | # Path to the JSON report file generated by Playwright. Required. 81 | report-file: 'results.json' 82 | 83 | # A unique tag to represent this report when reporting on multiple test runs 84 | # Defaults to the current workflow name 85 | report-tag: '' 86 | 87 | # URL to a published html report, uploaded by another action in a previous step. 88 | # Example pipeline: https://playwright.dev/docs/test-sharding#publishing-report-on-the-web 89 | report-url: 'https://user.github.io/repo/yyyy-mm-dd-id/' 90 | 91 | # Whether the action should create the actual comment. Set to false to implement 92 | # your own commenting logic. 93 | # Default: true 94 | create-comment: true 95 | 96 | # Title/headline to use for the created pull request comment. 97 | # Default: 'Playwright test results' 98 | comment-title: 'Test results' 99 | 100 | # Additional information to include in the summary comment, markdown-formatted 101 | # Default: '' 102 | custom-info: 'For more information, [see our readme](http://link)' 103 | 104 | # Create a job summary comment for the workflow run 105 | # Default: false 106 | job-summary: false 107 | 108 | # Icon style to use: octicons | emojis 109 | # Default: octicons 110 | icon-style: 'octicons' 111 | 112 | # Command used to run tests. If provided, a command to re-run failed or 113 | # flaky tests will be printed for each section. 114 | # Default: '' 115 | test-command: 'npm run test --' 116 | 117 | # Additional content to add to the comment below the test report. 118 | # Default: '' 119 | footer: '' 120 | ``` 121 | 122 | ## Output 123 | 124 | The action creates three output variables: 125 | 126 | ### summary 127 | 128 | The rendered markdown summary of the test report. 129 | 130 | ### comment-id 131 | 132 | The ID of the comment that was created or updated 133 | 134 | ### report-data 135 | 136 | The raw data of the test report, as a JSON-encoded string. This is 137 | useful for creating custom summaries or debugging. You can get an idea 138 | of the data structure by checking out the [ReportSummary interface](https://github.com/daun/playwright-report-summary/blob/main/src/report.ts#L13). 139 | 140 | ## License 141 | 142 | [MIT](./LICENSE) 143 | -------------------------------------------------------------------------------- /__tests__/__fixtures__/report-invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "configFile": "/project/tests/config/playwright.config.ts", 4 | "rootDir": "/project/tests/functional", 5 | "metadata": { 6 | "actualWorkers": 5, 7 | "totalTime": 611.735 8 | } 9 | }, 10 | "tests": [ 11 | { 12 | "title": "add.spec.ts", 13 | "file": "add.spec.ts", 14 | "column": 0, 15 | "line": 0, 16 | "specs": [], 17 | "suites": [ 18 | { 19 | "title": "add", 20 | "file": "add.spec.ts", 21 | "line": 5, 22 | "column": 6, 23 | "specs": [ 24 | { 25 | "title": "returns", 26 | "ok": true, 27 | "tags": [], 28 | "tests": [ 29 | { 30 | "timeout": 5000, 31 | "annotations": [], 32 | "expectedStatus": "passed", 33 | "projectId": "chromium", 34 | "projectName": "chromium", 35 | "results": [ 36 | { 37 | "workerIndex": 0, 38 | "status": "passed", 39 | "duration": 12, 40 | "errors": [], 41 | "stdout": [], 42 | "stderr": [], 43 | "retry": 0, 44 | "startTime": "2023-09-08T11:41:22.242Z", 45 | "attachments": [] 46 | } 47 | ], 48 | "status": "expected" 49 | } 50 | ], 51 | "id": "2f4bb8d916741bacc6cb-23c1b2167130e38ec767", 52 | "file": "add.spec.ts", 53 | "line": 6, 54 | "column": 2 55 | }, 56 | { 57 | "title": "adds", 58 | "ok": true, 59 | "tags": [], 60 | "tests": [ 61 | { 62 | "timeout": 5000, 63 | "annotations": [], 64 | "expectedStatus": "passed", 65 | "projectId": "chromium", 66 | "projectName": "chromium", 67 | "results": [ 68 | { 69 | "workerIndex": 1, 70 | "status": "passed", 71 | "duration": 14, 72 | "errors": [], 73 | "stdout": [], 74 | "stderr": [], 75 | "retry": 0, 76 | "startTime": "2023-09-08T11:41:22.240Z", 77 | "attachments": [] 78 | } 79 | ], 80 | "status": "expected" 81 | } 82 | ], 83 | "id": "2f4bb8d916741bacc6cb-328ccc869357d9515391", 84 | "file": "add.spec.ts", 85 | "line": 9, 86 | "column": 2 87 | }, 88 | { 89 | "title": "returns", 90 | "ok": true, 91 | "tags": [], 92 | "tests": [ 93 | { 94 | "timeout": 5000, 95 | "annotations": [], 96 | "expectedStatus": "passed", 97 | "projectId": "firefox", 98 | "projectName": "firefox", 99 | "results": [ 100 | { 101 | "workerIndex": 5, 102 | "status": "passed", 103 | "duration": 12, 104 | "errors": [], 105 | "stdout": [], 106 | "stderr": [], 107 | "retry": 0, 108 | "startTime": "2023-09-08T11:41:22.441Z", 109 | "attachments": [] 110 | } 111 | ], 112 | "status": "expected" 113 | } 114 | ], 115 | "id": "2f4bb8d916741bacc6cb-acf8e9042a34b90fed6a", 116 | "file": "add.spec.ts", 117 | "line": 6, 118 | "column": 2 119 | }, 120 | { 121 | "title": "adds", 122 | "ok": true, 123 | "tags": [], 124 | "tests": [ 125 | { 126 | "timeout": 5000, 127 | "annotations": [], 128 | "expectedStatus": "passed", 129 | "projectId": "firefox", 130 | "projectName": "firefox", 131 | "results": [ 132 | { 133 | "workerIndex": 6, 134 | "status": "passed", 135 | "duration": 13, 136 | "errors": [], 137 | "stdout": [], 138 | "stderr": [], 139 | "retry": 0, 140 | "startTime": "2023-09-08T11:41:22.437Z", 141 | "attachments": [] 142 | } 143 | ], 144 | "status": "expected" 145 | } 146 | ], 147 | "id": "2f4bb8d916741bacc6cb-ab12f000ed9a4eea5a16", 148 | "file": "add.spec.ts", 149 | "line": 9, 150 | "column": 2 151 | }, 152 | { 153 | "title": "returns", 154 | "ok": true, 155 | "tags": [], 156 | "tests": [ 157 | { 158 | "timeout": 5000, 159 | "annotations": [], 160 | "expectedStatus": "passed", 161 | "projectId": "webkit", 162 | "projectName": "webkit", 163 | "results": [ 164 | { 165 | "workerIndex": 10, 166 | "status": "passed", 167 | "duration": 15, 168 | "errors": [], 169 | "stdout": [], 170 | "stderr": [], 171 | "retry": 0, 172 | "startTime": "2023-09-08T11:41:22.644Z", 173 | "attachments": [] 174 | } 175 | ], 176 | "status": "expected" 177 | } 178 | ], 179 | "id": "2f4bb8d916741bacc6cb-1072f29bbb9cb3f8bbc9", 180 | "file": "add.spec.ts", 181 | "line": 6, 182 | "column": 2 183 | }, 184 | { 185 | "title": "adds", 186 | "ok": true, 187 | "tags": [], 188 | "tests": [ 189 | { 190 | "timeout": 5000, 191 | "annotations": [], 192 | "expectedStatus": "passed", 193 | "projectId": "webkit", 194 | "projectName": "webkit", 195 | "results": [ 196 | { 197 | "workerIndex": 12, 198 | "status": "passed", 199 | "duration": 12, 200 | "errors": [], 201 | "stdout": [], 202 | "stderr": [], 203 | "retry": 0, 204 | "startTime": "2023-09-08T11:41:22.651Z", 205 | "attachments": [] 206 | } 207 | ], 208 | "status": "expected" 209 | } 210 | ], 211 | "id": "2f4bb8d916741bacc6cb-fd19a15b8b57bf62098b", 212 | "file": "add.spec.ts", 213 | "line": 9, 214 | "column": 2 215 | } 216 | ] 217 | } 218 | ] 219 | } 220 | ] 221 | } 222 | -------------------------------------------------------------------------------- /__tests__/__fixtures__/report-sharded.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "forbidOnly": false, 4 | "fullyParallel": false, 5 | "globalSetup": null, 6 | "globalTeardown": null, 7 | "globalTimeout": 0, 8 | "grep": {}, 9 | "grepInvert": null, 10 | "maxFailures": 0, 11 | "metadata": { 12 | "totalTime": 5003.32, 13 | "actualWorkers": 20 14 | }, 15 | "preserveOutput": "always", 16 | "projects": [], 17 | "reporter": [ 18 | [ 19 | "list" 20 | ] 21 | ], 22 | "reportSlowTests": { 23 | "max": 5, 24 | "threshold": 15000 25 | }, 26 | "rootDir": "/project", 27 | "quiet": false, 28 | "shard": null, 29 | "updateSnapshots": "missing", 30 | "version": "1.37.1", 31 | "workers": 20, 32 | "webServer": null, 33 | "listOnly": false 34 | }, 35 | "suites": [ 36 | { 37 | "title": "add.spec.ts", 38 | "file": "add.spec.ts", 39 | "column": 0, 40 | "line": 0, 41 | "specs": [], 42 | "suites": [ 43 | { 44 | "title": "add", 45 | "file": "add.spec.ts", 46 | "line": 5, 47 | "column": 6, 48 | "specs": [ 49 | { 50 | "title": "returns", 51 | "ok": true, 52 | "tags": [], 53 | "tests": [ 54 | { 55 | "timeout": 5000, 56 | "annotations": [], 57 | "expectedStatus": "passed", 58 | "projectId": "chromium038c2392796d4a22", 59 | "projectName": "chromium", 60 | "results": [ 61 | { 62 | "workerIndex": 0, 63 | "status": "passed", 64 | "duration": 35, 65 | "errors": [], 66 | "stdout": [], 67 | "stderr": [], 68 | "retry": 0, 69 | "startTime": "2023-09-09T10:45:05.447Z", 70 | "attachments": [] 71 | } 72 | ], 73 | "status": "expected" 74 | }, 75 | { 76 | "timeout": 5000, 77 | "annotations": [], 78 | "expectedStatus": "passed", 79 | "projectId": "firefox1eb99aebfb39e9e7", 80 | "projectName": "firefox", 81 | "results": [ 82 | { 83 | "workerIndex": 2, 84 | "status": "passed", 85 | "duration": 33, 86 | "errors": [], 87 | "stdout": [], 88 | "stderr": [], 89 | "retry": 0, 90 | "startTime": "2023-09-09T10:45:16.110Z", 91 | "attachments": [] 92 | } 93 | ], 94 | "status": "expected" 95 | }, 96 | { 97 | "timeout": 5000, 98 | "annotations": [], 99 | "expectedStatus": "passed", 100 | "projectId": "webkit6128e7aebeb94118", 101 | "projectName": "webkit", 102 | "results": [ 103 | { 104 | "workerIndex": 4, 105 | "status": "passed", 106 | "duration": 38, 107 | "errors": [], 108 | "stdout": [], 109 | "stderr": [], 110 | "retry": 0, 111 | "startTime": "2023-09-09T10:45:54.243Z", 112 | "attachments": [] 113 | } 114 | ], 115 | "status": "expected" 116 | } 117 | ], 118 | "id": "2f4bb8d916741bacc6cb-23c1b2167130e38ec767038c2392796d4a22", 119 | "file": "add.spec.ts", 120 | "line": 6, 121 | "column": 2 122 | }, 123 | { 124 | "title": "adds", 125 | "ok": true, 126 | "tags": [], 127 | "tests": [ 128 | { 129 | "timeout": 5000, 130 | "annotations": [], 131 | "expectedStatus": "passed", 132 | "projectId": "chromium038c2392796d4a22", 133 | "projectName": "chromium", 134 | "results": [ 135 | { 136 | "workerIndex": 1, 137 | "status": "passed", 138 | "duration": 40, 139 | "errors": [], 140 | "stdout": [], 141 | "stderr": [], 142 | "retry": 0, 143 | "startTime": "2023-09-09T10:45:05.444Z", 144 | "attachments": [] 145 | } 146 | ], 147 | "status": "expected" 148 | }, 149 | { 150 | "timeout": 5000, 151 | "annotations": [], 152 | "expectedStatus": "passed", 153 | "projectId": "firefox1eb99aebfb39e9e7", 154 | "projectName": "firefox", 155 | "results": [ 156 | { 157 | "workerIndex": 3, 158 | "status": "passed", 159 | "duration": 39, 160 | "errors": [], 161 | "stdout": [], 162 | "stderr": [], 163 | "retry": 0, 164 | "startTime": "2023-09-09T10:45:16.105Z", 165 | "attachments": [] 166 | } 167 | ], 168 | "status": "expected" 169 | }, 170 | { 171 | "timeout": 5000, 172 | "annotations": [], 173 | "expectedStatus": "passed", 174 | "projectId": "webkit6128e7aebeb94118", 175 | "projectName": "webkit", 176 | "results": [ 177 | { 178 | "workerIndex": 4, 179 | "status": "passed", 180 | "duration": 19, 181 | "errors": [], 182 | "stdout": [], 183 | "stderr": [], 184 | "retry": 0, 185 | "startTime": "2023-09-09T10:45:54.296Z", 186 | "attachments": [] 187 | } 188 | ], 189 | "status": "expected" 190 | } 191 | ], 192 | "id": "2f4bb8d916741bacc6cb-328ccc869357d9515391038c2392796d4a22", 193 | "file": "add.spec.ts", 194 | "line": 9, 195 | "column": 2 196 | } 197 | ] 198 | } 199 | ] 200 | }, 201 | { 202 | "title": "divide.spec.ts", 203 | "file": "divide.spec.ts", 204 | "column": 0, 205 | "line": 0, 206 | "specs": [], 207 | "suites": [ 208 | { 209 | "title": "divide", 210 | "file": "divide.spec.ts", 211 | "line": 5, 212 | "column": 6, 213 | "specs": [ 214 | { 215 | "title": "returns", 216 | "ok": true, 217 | "tags": [], 218 | "tests": [ 219 | { 220 | "timeout": 5000, 221 | "annotations": [], 222 | "expectedStatus": "passed", 223 | "projectId": "chromium038c2392796d4a22", 224 | "projectName": "chromium", 225 | "results": [ 226 | { 227 | "workerIndex": 2, 228 | "status": "passed", 229 | "duration": 35, 230 | "errors": [], 231 | "stdout": [], 232 | "stderr": [], 233 | "retry": 0, 234 | "startTime": "2023-09-09T10:45:05.443Z", 235 | "attachments": [] 236 | } 237 | ], 238 | "status": "expected" 239 | }, 240 | { 241 | "timeout": 5000, 242 | "annotations": [], 243 | "expectedStatus": "passed", 244 | "projectId": "firefox1eb99aebfb39e9e7", 245 | "projectName": "firefox", 246 | "results": [ 247 | { 248 | "workerIndex": 4, 249 | "status": "passed", 250 | "duration": 37, 251 | "errors": [], 252 | "stdout": [], 253 | "stderr": [], 254 | "retry": 0, 255 | "startTime": "2023-09-09T10:45:16.110Z", 256 | "attachments": [] 257 | } 258 | ], 259 | "status": "expected" 260 | }, 261 | { 262 | "timeout": 5000, 263 | "annotations": [], 264 | "expectedStatus": "passed", 265 | "projectId": "webkit6128e7aebeb94118", 266 | "projectName": "webkit", 267 | "results": [ 268 | { 269 | "workerIndex": 5, 270 | "status": "passed", 271 | "duration": 32, 272 | "errors": [], 273 | "stdout": [], 274 | "stderr": [], 275 | "retry": 0, 276 | "startTime": "2023-09-09T10:45:54.745Z", 277 | "attachments": [] 278 | } 279 | ], 280 | "status": "expected" 281 | } 282 | ], 283 | "id": "abd65a3ec2f3a35a080f-6441d08247ddeb3814bf038c2392796d4a22", 284 | "file": "divide.spec.ts", 285 | "line": 6, 286 | "column": 2 287 | }, 288 | { 289 | "title": "divides", 290 | "ok": true, 291 | "tags": [], 292 | "tests": [ 293 | { 294 | "timeout": 5000, 295 | "annotations": [], 296 | "expectedStatus": "passed", 297 | "projectId": "chromium038c2392796d4a22", 298 | "projectName": "chromium", 299 | "results": [ 300 | { 301 | "workerIndex": 3, 302 | "status": "passed", 303 | "duration": 39, 304 | "errors": [], 305 | "stdout": [], 306 | "stderr": [], 307 | "retry": 0, 308 | "startTime": "2023-09-09T10:45:05.444Z", 309 | "attachments": [] 310 | } 311 | ], 312 | "status": "expected" 313 | }, 314 | { 315 | "timeout": 5000, 316 | "annotations": [], 317 | "expectedStatus": "passed", 318 | "projectId": "firefox1eb99aebfb39e9e7", 319 | "projectName": "firefox", 320 | "results": [ 321 | { 322 | "workerIndex": 2, 323 | "status": "passed", 324 | "duration": 18, 325 | "errors": [], 326 | "stdout": [], 327 | "stderr": [], 328 | "retry": 0, 329 | "startTime": "2023-09-09T10:45:16.156Z", 330 | "attachments": [] 331 | } 332 | ], 333 | "status": "expected" 334 | }, 335 | { 336 | "timeout": 5000, 337 | "annotations": [], 338 | "expectedStatus": "passed", 339 | "projectId": "webkitbd6f400eea527a0b", 340 | "projectName": "webkit", 341 | "results": [ 342 | { 343 | "workerIndex": 0, 344 | "status": "passed", 345 | "duration": 39, 346 | "errors": [], 347 | "stdout": [], 348 | "stderr": [], 349 | "retry": 0, 350 | "startTime": "2023-09-09T10:46:14.314Z", 351 | "attachments": [] 352 | } 353 | ], 354 | "status": "expected" 355 | } 356 | ], 357 | "id": "abd65a3ec2f3a35a080f-acf768063c5e76dcf174038c2392796d4a22", 358 | "file": "divide.spec.ts", 359 | "line": 9, 360 | "column": 2 361 | } 362 | ] 363 | } 364 | ] 365 | }, 366 | { 367 | "title": "multiply.spec.ts", 368 | "file": "multiply.spec.ts", 369 | "column": 0, 370 | "line": 0, 371 | "specs": [], 372 | "suites": [ 373 | { 374 | "title": "multiply", 375 | "file": "multiply.spec.ts", 376 | "line": 5, 377 | "column": 6, 378 | "specs": [ 379 | { 380 | "title": "returns", 381 | "ok": true, 382 | "tags": [], 383 | "tests": [ 384 | { 385 | "timeout": 5000, 386 | "annotations": [], 387 | "expectedStatus": "passed", 388 | "projectId": "chromium038c2392796d4a22", 389 | "projectName": "chromium", 390 | "results": [ 391 | { 392 | "workerIndex": 4, 393 | "status": "passed", 394 | "duration": 41, 395 | "errors": [], 396 | "stdout": [], 397 | "stderr": [], 398 | "retry": 0, 399 | "startTime": "2023-09-09T10:45:05.443Z", 400 | "attachments": [] 401 | } 402 | ], 403 | "status": "expected" 404 | }, 405 | { 406 | "timeout": 5000, 407 | "annotations": [], 408 | "expectedStatus": "passed", 409 | "projectId": "firefox1eb99aebfb39e9e7", 410 | "projectName": "firefox", 411 | "results": [ 412 | { 413 | "workerIndex": 5, 414 | "status": "passed", 415 | "duration": 36, 416 | "errors": [], 417 | "stdout": [], 418 | "stderr": [], 419 | "retry": 0, 420 | "startTime": "2023-09-09T10:45:16.577Z", 421 | "attachments": [] 422 | } 423 | ], 424 | "status": "expected" 425 | }, 426 | { 427 | "timeout": 5000, 428 | "annotations": [], 429 | "expectedStatus": "passed", 430 | "projectId": "webkitbd6f400eea527a0b", 431 | "projectName": "webkit", 432 | "results": [ 433 | { 434 | "workerIndex": 1, 435 | "status": "passed", 436 | "duration": 42, 437 | "errors": [], 438 | "stdout": [], 439 | "stderr": [], 440 | "retry": 0, 441 | "startTime": "2023-09-09T10:46:14.319Z", 442 | "attachments": [] 443 | } 444 | ], 445 | "status": "expected" 446 | } 447 | ], 448 | "id": "e7c88bf2960b23764a9d-a054f04aedfc95a37bb3038c2392796d4a22", 449 | "file": "multiply.spec.ts", 450 | "line": 6, 451 | "column": 2 452 | }, 453 | { 454 | "title": "multiplies", 455 | "ok": true, 456 | "tags": [], 457 | "tests": [ 458 | { 459 | "timeout": 5000, 460 | "annotations": [], 461 | "expectedStatus": "passed", 462 | "projectId": "chromium038c2392796d4a22", 463 | "projectName": "chromium", 464 | "results": [ 465 | { 466 | "workerIndex": 2, 467 | "status": "passed", 468 | "duration": 20, 469 | "errors": [], 470 | "stdout": [], 471 | "stderr": [], 472 | "retry": 0, 473 | "startTime": "2023-09-09T10:45:05.496Z", 474 | "attachments": [] 475 | } 476 | ], 477 | "status": "expected" 478 | }, 479 | { 480 | "timeout": 5000, 481 | "annotations": [], 482 | "expectedStatus": "passed", 483 | "projectId": "firefox6128e7aebeb94118", 484 | "projectName": "firefox", 485 | "results": [ 486 | { 487 | "workerIndex": 0, 488 | "status": "failed", 489 | "duration": 41, 490 | "error": { 491 | "message": "\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m7\u001b[39m\nReceived: \u001b[31m6\u001b[39m", 492 | "stack": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m7\u001b[39m\nReceived: \u001b[31m6\u001b[39m\n at /project/tests/functional/multiply.spec.ts:10:26", 493 | "location": { 494 | "file": "/project/tests/functional/multiply.spec.ts", 495 | "column": 26, 496 | "line": 10 497 | }, 498 | "snippet": "\u001b[0m \u001b[90m 8 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 9 |\u001b[39m \ttest(\u001b[32m'multiplies'\u001b[39m\u001b[33m,\u001b[39m \u001b[36masync\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[0m\n\u001b[0m\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 10 |\u001b[39m \t\texpect(multiply(\u001b[35m3\u001b[39m\u001b[33m,\u001b[39m \u001b[35m2\u001b[39m))\u001b[33m.\u001b[39mtoBe(\u001b[33mMath\u001b[39m\u001b[33m.\u001b[39mrandom() \u001b[33m>\u001b[39m \u001b[35m0.5\u001b[39m \u001b[33m?\u001b[39m \u001b[35m6\u001b[39m \u001b[33m:\u001b[39m \u001b[35m7\u001b[39m)\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m |\u001b[39m \t\t \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 11 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 12 |\u001b[39m \ttest(\u001b[32m'skips'\u001b[39m\u001b[33m,\u001b[39m \u001b[36masync\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[0m\n\u001b[0m \u001b[90m 13 |\u001b[39m \t\ttest\u001b[33m.\u001b[39mskip()\u001b[33m;\u001b[39m\u001b[0m" 499 | }, 500 | "errors": [ 501 | { 502 | "location": { 503 | "file": "/project/tests/functional/multiply.spec.ts", 504 | "column": 26, 505 | "line": 10 506 | }, 507 | "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m7\u001b[39m\nReceived: \u001b[31m6\u001b[39m\n\n\u001b[0m \u001b[90m 8 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 9 |\u001b[39m \ttest(\u001b[32m'multiplies'\u001b[39m\u001b[33m,\u001b[39m \u001b[36masync\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[0m\n\u001b[0m\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 10 |\u001b[39m \t\texpect(multiply(\u001b[35m3\u001b[39m\u001b[33m,\u001b[39m \u001b[35m2\u001b[39m))\u001b[33m.\u001b[39mtoBe(\u001b[33mMath\u001b[39m\u001b[33m.\u001b[39mrandom() \u001b[33m>\u001b[39m \u001b[35m0.5\u001b[39m \u001b[33m?\u001b[39m \u001b[35m6\u001b[39m \u001b[33m:\u001b[39m \u001b[35m7\u001b[39m)\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m |\u001b[39m \t\t \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 11 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 12 |\u001b[39m \ttest(\u001b[32m'skips'\u001b[39m\u001b[33m,\u001b[39m \u001b[36masync\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[0m\n\u001b[0m \u001b[90m 13 |\u001b[39m \t\ttest\u001b[33m.\u001b[39mskip()\u001b[33m;\u001b[39m\u001b[0m\n\n\u001b[2m at /project/tests/functional/multiply.spec.ts:10:26\u001b[22m" 508 | } 509 | ], 510 | "stdout": [], 511 | "stderr": [], 512 | "retry": 0, 513 | "startTime": "2023-09-09T10:45:54.241Z", 514 | "attachments": [], 515 | "errorLocation": { 516 | "file": "/project/tests/functional/multiply.spec.ts", 517 | "column": 26, 518 | "line": 10 519 | } 520 | }, 521 | { 522 | "workerIndex": 6, 523 | "status": "passed", 524 | "duration": 44, 525 | "errors": [], 526 | "stdout": [], 527 | "stderr": [], 528 | "retry": 1, 529 | "startTime": "2023-09-09T10:45:54.747Z", 530 | "attachments": [ 531 | { 532 | "name": "trace", 533 | "contentType": "application/zip", 534 | "path": "/project/blobs/resources/d9fd85ea0eba4d32a4c0e9a0390bb889582f5139.zip" 535 | } 536 | ] 537 | } 538 | ], 539 | "status": "flaky" 540 | }, 541 | { 542 | "timeout": 5000, 543 | "annotations": [], 544 | "expectedStatus": "passed", 545 | "projectId": "webkitbd6f400eea527a0b", 546 | "projectName": "webkit", 547 | "results": [ 548 | { 549 | "workerIndex": 2, 550 | "status": "passed", 551 | "duration": 36, 552 | "errors": [], 553 | "stdout": [], 554 | "stderr": [], 555 | "retry": 0, 556 | "startTime": "2023-09-09T10:46:14.324Z", 557 | "attachments": [] 558 | } 559 | ], 560 | "status": "expected" 561 | } 562 | ], 563 | "id": "e7c88bf2960b23764a9d-a5e9ce9b2e8260f09823038c2392796d4a22", 564 | "file": "multiply.spec.ts", 565 | "line": 9, 566 | "column": 2 567 | }, 568 | { 569 | "title": "skips", 570 | "ok": true, 571 | "tags": [], 572 | "tests": [ 573 | { 574 | "timeout": 5000, 575 | "annotations": [ 576 | { 577 | "type": "skip" 578 | } 579 | ], 580 | "expectedStatus": "skipped", 581 | "projectId": "chromium038c2392796d4a22", 582 | "projectName": "chromium", 583 | "results": [ 584 | { 585 | "workerIndex": 0, 586 | "status": "skipped", 587 | "duration": 20, 588 | "errors": [], 589 | "stdout": [], 590 | "stderr": [], 591 | "retry": 0, 592 | "startTime": "2023-09-09T10:45:05.501Z", 593 | "attachments": [] 594 | } 595 | ], 596 | "status": "skipped" 597 | }, 598 | { 599 | "timeout": 5000, 600 | "annotations": [ 601 | { 602 | "type": "skip" 603 | } 604 | ], 605 | "expectedStatus": "skipped", 606 | "projectId": "firefox6128e7aebeb94118", 607 | "projectName": "firefox", 608 | "results": [ 609 | { 610 | "workerIndex": 1, 611 | "status": "skipped", 612 | "duration": 36, 613 | "errors": [], 614 | "stdout": [], 615 | "stderr": [], 616 | "retry": 0, 617 | "startTime": "2023-09-09T10:45:54.243Z", 618 | "attachments": [] 619 | } 620 | ], 621 | "status": "skipped" 622 | }, 623 | { 624 | "timeout": 5000, 625 | "annotations": [ 626 | { 627 | "type": "skip" 628 | } 629 | ], 630 | "expectedStatus": "skipped", 631 | "projectId": "webkitbd6f400eea527a0b", 632 | "projectName": "webkit", 633 | "results": [ 634 | { 635 | "workerIndex": 3, 636 | "status": "skipped", 637 | "duration": 36, 638 | "errors": [], 639 | "stdout": [], 640 | "stderr": [], 641 | "retry": 0, 642 | "startTime": "2023-09-09T10:46:14.308Z", 643 | "attachments": [] 644 | } 645 | ], 646 | "status": "skipped" 647 | } 648 | ], 649 | "id": "e7c88bf2960b23764a9d-669ef72c0b906305e786038c2392796d4a22", 650 | "file": "multiply.spec.ts", 651 | "line": 12, 652 | "column": 2 653 | } 654 | ] 655 | } 656 | ] 657 | }, 658 | { 659 | "title": "subtract.spec.ts", 660 | "file": "subtract.spec.ts", 661 | "column": 0, 662 | "line": 0, 663 | "specs": [], 664 | "suites": [ 665 | { 666 | "title": "subtract", 667 | "file": "subtract.spec.ts", 668 | "line": 5, 669 | "column": 6, 670 | "specs": [ 671 | { 672 | "title": "returns", 673 | "ok": true, 674 | "tags": [], 675 | "tests": [ 676 | { 677 | "timeout": 5000, 678 | "annotations": [], 679 | "expectedStatus": "passed", 680 | "projectId": "chromium1eb99aebfb39e9e7", 681 | "projectName": "chromium", 682 | "results": [ 683 | { 684 | "workerIndex": 0, 685 | "status": "passed", 686 | "duration": 38, 687 | "errors": [], 688 | "stdout": [], 689 | "stderr": [], 690 | "retry": 0, 691 | "startTime": "2023-09-09T10:45:16.111Z", 692 | "attachments": [] 693 | } 694 | ], 695 | "status": "expected" 696 | }, 697 | { 698 | "timeout": 5000, 699 | "annotations": [], 700 | "expectedStatus": "passed", 701 | "projectId": "firefox6128e7aebeb94118", 702 | "projectName": "firefox", 703 | "results": [ 704 | { 705 | "workerIndex": 2, 706 | "status": "passed", 707 | "duration": 39, 708 | "errors": [], 709 | "stdout": [], 710 | "stderr": [], 711 | "retry": 0, 712 | "startTime": "2023-09-09T10:45:54.245Z", 713 | "attachments": [] 714 | } 715 | ], 716 | "status": "expected" 717 | }, 718 | { 719 | "timeout": 5000, 720 | "annotations": [], 721 | "expectedStatus": "passed", 722 | "projectId": "webkitbd6f400eea527a0b", 723 | "projectName": "webkit", 724 | "results": [ 725 | { 726 | "workerIndex": 4, 727 | "status": "passed", 728 | "duration": 39, 729 | "errors": [], 730 | "stdout": [], 731 | "stderr": [], 732 | "retry": 0, 733 | "startTime": "2023-09-09T10:46:14.314Z", 734 | "attachments": [] 735 | } 736 | ], 737 | "status": "expected" 738 | } 739 | ], 740 | "id": "6ac0882294147c5cf865-ce417e4a0eefba5d5d531eb99aebfb39e9e7", 741 | "file": "subtract.spec.ts", 742 | "line": 6, 743 | "column": 2 744 | }, 745 | { 746 | "title": "subtracts", 747 | "ok": true, 748 | "tags": [], 749 | "tests": [ 750 | { 751 | "timeout": 5000, 752 | "annotations": [], 753 | "expectedStatus": "passed", 754 | "projectId": "chromium1eb99aebfb39e9e7", 755 | "projectName": "chromium", 756 | "results": [ 757 | { 758 | "workerIndex": 1, 759 | "status": "passed", 760 | "duration": 35, 761 | "errors": [], 762 | "stdout": [], 763 | "stderr": [], 764 | "retry": 0, 765 | "startTime": "2023-09-09T10:45:16.111Z", 766 | "attachments": [] 767 | } 768 | ], 769 | "status": "expected" 770 | }, 771 | { 772 | "timeout": 5000, 773 | "annotations": [], 774 | "expectedStatus": "passed", 775 | "projectId": "firefox6128e7aebeb94118", 776 | "projectName": "firefox", 777 | "results": [ 778 | { 779 | "workerIndex": 3, 780 | "status": "passed", 781 | "duration": 38, 782 | "errors": [], 783 | "stdout": [], 784 | "stderr": [], 785 | "retry": 0, 786 | "startTime": "2023-09-09T10:45:54.243Z", 787 | "attachments": [] 788 | } 789 | ], 790 | "status": "expected" 791 | }, 792 | { 793 | "timeout": 5000, 794 | "annotations": [], 795 | "expectedStatus": "passed", 796 | "projectId": "webkitbd6f400eea527a0b", 797 | "projectName": "webkit", 798 | "results": [ 799 | { 800 | "workerIndex": 3, 801 | "status": "failed", 802 | "duration": 26, 803 | "error": { 804 | "message": "\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m4\u001b[39m\nReceived: \u001b[31m1\u001b[39m", 805 | "stack": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m4\u001b[39m\nReceived: \u001b[31m1\u001b[39m\n at /project/tests/functional/subtract.spec.ts:10:26", 806 | "location": { 807 | "file": "/project/tests/functional/subtract.spec.ts", 808 | "column": 26, 809 | "line": 10 810 | }, 811 | "snippet": "\u001b[0m \u001b[90m 8 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 9 |\u001b[39m \ttest(\u001b[32m'subtracts'\u001b[39m\u001b[33m,\u001b[39m \u001b[36masync\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[0m\n\u001b[0m\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 10 |\u001b[39m \t\texpect(subtract(\u001b[35m3\u001b[39m\u001b[33m,\u001b[39m \u001b[35m2\u001b[39m))\u001b[33m.\u001b[39mtoBe(\u001b[35m4\u001b[39m)\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m |\u001b[39m \t\t \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 11 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 12 |\u001b[39m })\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 13 |\u001b[39m\u001b[0m" 812 | }, 813 | "errors": [ 814 | { 815 | "location": { 816 | "file": "/project/tests/functional/subtract.spec.ts", 817 | "column": 26, 818 | "line": 10 819 | }, 820 | "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m4\u001b[39m\nReceived: \u001b[31m1\u001b[39m\n\n\u001b[0m \u001b[90m 8 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 9 |\u001b[39m \ttest(\u001b[32m'subtracts'\u001b[39m\u001b[33m,\u001b[39m \u001b[36masync\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[0m\n\u001b[0m\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 10 |\u001b[39m \t\texpect(subtract(\u001b[35m3\u001b[39m\u001b[33m,\u001b[39m \u001b[35m2\u001b[39m))\u001b[33m.\u001b[39mtoBe(\u001b[35m4\u001b[39m)\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m |\u001b[39m \t\t \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 11 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 12 |\u001b[39m })\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 13 |\u001b[39m\u001b[0m\n\n\u001b[2m at /project/tests/functional/subtract.spec.ts:10:26\u001b[22m" 821 | } 822 | ], 823 | "stdout": [], 824 | "stderr": [], 825 | "retry": 0, 826 | "startTime": "2023-09-09T10:46:14.361Z", 827 | "attachments": [], 828 | "errorLocation": { 829 | "file": "/project/tests/functional/subtract.spec.ts", 830 | "column": 26, 831 | "line": 10 832 | } 833 | }, 834 | { 835 | "workerIndex": 5, 836 | "status": "failed", 837 | "duration": 49, 838 | "error": { 839 | "message": "\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m4\u001b[39m\nReceived: \u001b[31m1\u001b[39m", 840 | "stack": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m4\u001b[39m\nReceived: \u001b[31m1\u001b[39m\n at /project/tests/functional/subtract.spec.ts:10:26", 841 | "location": { 842 | "file": "/project/tests/functional/subtract.spec.ts", 843 | "column": 26, 844 | "line": 10 845 | }, 846 | "snippet": "\u001b[0m \u001b[90m 8 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 9 |\u001b[39m \ttest(\u001b[32m'subtracts'\u001b[39m\u001b[33m,\u001b[39m \u001b[36masync\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[0m\n\u001b[0m\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 10 |\u001b[39m \t\texpect(subtract(\u001b[35m3\u001b[39m\u001b[33m,\u001b[39m \u001b[35m2\u001b[39m))\u001b[33m.\u001b[39mtoBe(\u001b[35m4\u001b[39m)\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m |\u001b[39m \t\t \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 11 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 12 |\u001b[39m })\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 13 |\u001b[39m\u001b[0m" 847 | }, 848 | "errors": [ 849 | { 850 | "location": { 851 | "file": "/project/tests/functional/subtract.spec.ts", 852 | "column": 26, 853 | "line": 10 854 | }, 855 | "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m4\u001b[39m\nReceived: \u001b[31m1\u001b[39m\n\n\u001b[0m \u001b[90m 8 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 9 |\u001b[39m \ttest(\u001b[32m'subtracts'\u001b[39m\u001b[33m,\u001b[39m \u001b[36masync\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[0m\n\u001b[0m\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 10 |\u001b[39m \t\texpect(subtract(\u001b[35m3\u001b[39m\u001b[33m,\u001b[39m \u001b[35m2\u001b[39m))\u001b[33m.\u001b[39mtoBe(\u001b[35m4\u001b[39m)\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m |\u001b[39m \t\t \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 11 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 12 |\u001b[39m })\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 13 |\u001b[39m\u001b[0m\n\n\u001b[2m at /project/tests/functional/subtract.spec.ts:10:26\u001b[22m" 856 | } 857 | ], 858 | "stdout": [], 859 | "stderr": [], 860 | "retry": 1, 861 | "startTime": "2023-09-09T10:46:14.818Z", 862 | "attachments": [ 863 | { 864 | "name": "trace", 865 | "contentType": "application/zip", 866 | "path": "/project/blobs/resources/f2597886a6d84319818a588535d5abc864ee9cbf.zip" 867 | } 868 | ], 869 | "errorLocation": { 870 | "file": "/project/tests/functional/subtract.spec.ts", 871 | "column": 26, 872 | "line": 10 873 | } 874 | }, 875 | { 876 | "workerIndex": 6, 877 | "status": "failed", 878 | "duration": 36, 879 | "error": { 880 | "message": "\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m4\u001b[39m\nReceived: \u001b[31m1\u001b[39m", 881 | "stack": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m4\u001b[39m\nReceived: \u001b[31m1\u001b[39m\n at /project/tests/functional/subtract.spec.ts:10:26", 882 | "location": { 883 | "file": "/project/tests/functional/subtract.spec.ts", 884 | "column": 26, 885 | "line": 10 886 | }, 887 | "snippet": "\u001b[0m \u001b[90m 8 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 9 |\u001b[39m \ttest(\u001b[32m'subtracts'\u001b[39m\u001b[33m,\u001b[39m \u001b[36masync\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[0m\n\u001b[0m\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 10 |\u001b[39m \t\texpect(subtract(\u001b[35m3\u001b[39m\u001b[33m,\u001b[39m \u001b[35m2\u001b[39m))\u001b[33m.\u001b[39mtoBe(\u001b[35m4\u001b[39m)\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m |\u001b[39m \t\t \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 11 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 12 |\u001b[39m })\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 13 |\u001b[39m\u001b[0m" 888 | }, 889 | "errors": [ 890 | { 891 | "location": { 892 | "file": "/project/tests/functional/subtract.spec.ts", 893 | "column": 26, 894 | "line": 10 895 | }, 896 | "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m4\u001b[39m\nReceived: \u001b[31m1\u001b[39m\n\n\u001b[0m \u001b[90m 8 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 9 |\u001b[39m \ttest(\u001b[32m'subtracts'\u001b[39m\u001b[33m,\u001b[39m \u001b[36masync\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[0m\n\u001b[0m\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 10 |\u001b[39m \t\texpect(subtract(\u001b[35m3\u001b[39m\u001b[33m,\u001b[39m \u001b[35m2\u001b[39m))\u001b[33m.\u001b[39mtoBe(\u001b[35m4\u001b[39m)\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m |\u001b[39m \t\t \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 11 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 12 |\u001b[39m })\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 13 |\u001b[39m\u001b[0m\n\n\u001b[2m at /project/tests/functional/subtract.spec.ts:10:26\u001b[22m" 897 | } 898 | ], 899 | "stdout": [], 900 | "stderr": [], 901 | "retry": 2, 902 | "startTime": "2023-09-09T10:46:15.294Z", 903 | "attachments": [], 904 | "errorLocation": { 905 | "file": "/project/tests/functional/subtract.spec.ts", 906 | "column": 26, 907 | "line": 10 908 | } 909 | }, 910 | { 911 | "workerIndex": 7, 912 | "status": "failed", 913 | "duration": 36, 914 | "error": { 915 | "message": "\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m4\u001b[39m\nReceived: \u001b[31m1\u001b[39m", 916 | "stack": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m4\u001b[39m\nReceived: \u001b[31m1\u001b[39m\n at /project/tests/functional/subtract.spec.ts:10:26", 917 | "location": { 918 | "file": "/project/tests/functional/subtract.spec.ts", 919 | "column": 26, 920 | "line": 10 921 | }, 922 | "snippet": "\u001b[0m \u001b[90m 8 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 9 |\u001b[39m \ttest(\u001b[32m'subtracts'\u001b[39m\u001b[33m,\u001b[39m \u001b[36masync\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[0m\n\u001b[0m\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 10 |\u001b[39m \t\texpect(subtract(\u001b[35m3\u001b[39m\u001b[33m,\u001b[39m \u001b[35m2\u001b[39m))\u001b[33m.\u001b[39mtoBe(\u001b[35m4\u001b[39m)\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m |\u001b[39m \t\t \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 11 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 12 |\u001b[39m })\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 13 |\u001b[39m\u001b[0m" 923 | }, 924 | "errors": [ 925 | { 926 | "location": { 927 | "file": "/project/tests/functional/subtract.spec.ts", 928 | "column": 26, 929 | "line": 10 930 | }, 931 | "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m4\u001b[39m\nReceived: \u001b[31m1\u001b[39m\n\n\u001b[0m \u001b[90m 8 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 9 |\u001b[39m \ttest(\u001b[32m'subtracts'\u001b[39m\u001b[33m,\u001b[39m \u001b[36masync\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[0m\n\u001b[0m\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 10 |\u001b[39m \t\texpect(subtract(\u001b[35m3\u001b[39m\u001b[33m,\u001b[39m \u001b[35m2\u001b[39m))\u001b[33m.\u001b[39mtoBe(\u001b[35m4\u001b[39m)\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m |\u001b[39m \t\t \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 11 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 12 |\u001b[39m })\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 13 |\u001b[39m\u001b[0m\n\n\u001b[2m at /project/tests/functional/subtract.spec.ts:10:26\u001b[22m" 932 | } 933 | ], 934 | "stdout": [], 935 | "stderr": [], 936 | "retry": 3, 937 | "startTime": "2023-09-09T10:46:15.766Z", 938 | "attachments": [], 939 | "errorLocation": { 940 | "file": "/project/tests/functional/subtract.spec.ts", 941 | "column": 26, 942 | "line": 10 943 | } 944 | }, 945 | { 946 | "workerIndex": 8, 947 | "status": "failed", 948 | "duration": 35, 949 | "error": { 950 | "message": "\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m4\u001b[39m\nReceived: \u001b[31m1\u001b[39m", 951 | "stack": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m4\u001b[39m\nReceived: \u001b[31m1\u001b[39m\n at /project/tests/functional/subtract.spec.ts:10:26", 952 | "location": { 953 | "file": "/project/tests/functional/subtract.spec.ts", 954 | "column": 26, 955 | "line": 10 956 | }, 957 | "snippet": "\u001b[0m \u001b[90m 8 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 9 |\u001b[39m \ttest(\u001b[32m'subtracts'\u001b[39m\u001b[33m,\u001b[39m \u001b[36masync\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[0m\n\u001b[0m\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 10 |\u001b[39m \t\texpect(subtract(\u001b[35m3\u001b[39m\u001b[33m,\u001b[39m \u001b[35m2\u001b[39m))\u001b[33m.\u001b[39mtoBe(\u001b[35m4\u001b[39m)\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m |\u001b[39m \t\t \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 11 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 12 |\u001b[39m })\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 13 |\u001b[39m\u001b[0m" 958 | }, 959 | "errors": [ 960 | { 961 | "location": { 962 | "file": "/project/tests/functional/subtract.spec.ts", 963 | "column": 26, 964 | "line": 10 965 | }, 966 | "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m4\u001b[39m\nReceived: \u001b[31m1\u001b[39m\n\n\u001b[0m \u001b[90m 8 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 9 |\u001b[39m \ttest(\u001b[32m'subtracts'\u001b[39m\u001b[33m,\u001b[39m \u001b[36masync\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[0m\n\u001b[0m\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 10 |\u001b[39m \t\texpect(subtract(\u001b[35m3\u001b[39m\u001b[33m,\u001b[39m \u001b[35m2\u001b[39m))\u001b[33m.\u001b[39mtoBe(\u001b[35m4\u001b[39m)\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m |\u001b[39m \t\t \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 11 |\u001b[39m \t})\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 12 |\u001b[39m })\u001b[33m;\u001b[39m\u001b[0m\n\u001b[0m \u001b[90m 13 |\u001b[39m\u001b[0m\n\n\u001b[2m at /project/tests/functional/subtract.spec.ts:10:26\u001b[22m" 967 | } 968 | ], 969 | "stdout": [], 970 | "stderr": [], 971 | "retry": 4, 972 | "startTime": "2023-09-09T10:46:16.234Z", 973 | "attachments": [], 974 | "errorLocation": { 975 | "file": "/project/tests/functional/subtract.spec.ts", 976 | "column": 26, 977 | "line": 10 978 | } 979 | } 980 | ], 981 | "status": "unexpected" 982 | } 983 | ], 984 | "id": "6ac0882294147c5cf865-17a50ac4c8699683fcf21eb99aebfb39e9e7", 985 | "file": "subtract.spec.ts", 986 | "line": 9, 987 | "column": 2 988 | } 989 | ] 990 | } 991 | ] 992 | } 993 | ], 994 | "errors": [] 995 | } 996 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/report.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renderReportSummary matches snapshot 1`] = ` 4 | "### Test Report 5 | 6 | ![failed](https://icongr.am/octicons/stop.svg?size=14&color=da3633)  **2 failed** 7 | ![passed](https://icongr.am/octicons/check-circle.svg?size=14&color=3fb950)  **10 passed** 8 | ![flaky](https://icongr.am/octicons/alert.svg?size=14&color=d29922)  **1 flaky** 9 | ![skipped](https://icongr.am/octicons/skip.svg?size=14&color=0967d9)  **1 skipped** 10 | 11 | #### Details 12 | 13 | ![report](https://icongr.am/octicons/package.svg?size=14&color=abb4bf)  [Open report ↗︎](https://example.com/report) 14 | ![stats](https://icongr.am/octicons/pulse.svg?size=14&color=abb4bf)  14 tests across 4 suites 15 | ![duration](https://icongr.am/octicons/clock.svg?size=14&color=abb4bf)  1.1 seconds 16 | ![commit](https://icongr.am/octicons/git-pull-request.svg?size=14&color=abb4bf)  1234567 17 | ![info](https://icongr.am/octicons/info.svg?size=14&color=abb4bf)  For more information, see our [documentation](https://example.com/docs) 18 | 19 |
Failed tests 20 | 21 | chromium › add.spec.ts › add › adds 22 | firefox › add.spec.ts › add › adds 23 | 24 |
25 |
Flaky tests 26 | 27 | chromium › multiply.spec.ts › multiply › multiplies 28 | 29 |
30 |
Skipped tests 31 | 32 | chromium › multiply.spec.ts › multiply › skips 33 | 34 |
" 35 | `; 36 | -------------------------------------------------------------------------------- /__tests__/formatting.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for src/formatting.ts 3 | */ 4 | 5 | import { expect } from '@jest/globals' 6 | import { formatDuration, upperCaseFirst, renderMarkdownTable } from '../src/formatting' 7 | 8 | describe('formatDuration', () => { 9 | it('returns a string', async () => { 10 | expect(typeof formatDuration(3000) === 'string').toBe(true) 11 | }) 12 | it('formats milliseconds', async () => { 13 | expect(formatDuration(500)).toBe('0.5 seconds') 14 | }) 15 | it('formats seconds', async () => { 16 | expect(formatDuration(3000)).toBe('3 seconds') 17 | }) 18 | it('formats singular seconds', async () => { 19 | expect(formatDuration(1000)).toBe('1 second') 20 | }) 21 | it('formats minutes', async () => { 22 | expect(formatDuration(330000)).toBe('5 minutes, 30 seconds') 23 | }) 24 | it('formats singular minutes', async () => { 25 | expect(formatDuration(60000)).toBe('1 minute') 26 | }) 27 | it('formats hours', async () => { 28 | // (5*1000)+(15*1000*60)+(2*1000*60*60) 29 | expect(formatDuration(8105000)).toBe('2 hours, 15 minutes, 5 seconds') 30 | }) 31 | it('formats singular hours', async () => { 32 | expect(formatDuration(3600000)).toBe('1 hour') 33 | }) 34 | it('formats days', async () => { 35 | // (8*1000)+(36*1000*60)+(13*1000*60*60)+(3*1000*60*60*24) 36 | expect(formatDuration(308168000)).toBe('3 days, 13 hours, 36 minutes, 8 seconds') 37 | }) 38 | it('formats singular days', async () => { 39 | expect(formatDuration(86400000)).toBe('1 day') 40 | }) 41 | }) 42 | 43 | describe('upperCaseFirst', () => { 44 | it('returns a string', async () => { 45 | expect(typeof upperCaseFirst('lorem') === 'string').toBe(true) 46 | }) 47 | it('uppercases the first letter', async () => { 48 | expect(upperCaseFirst('lorem')).toBe('Lorem') 49 | }) 50 | }) 51 | 52 | describe('renderMarkdownTable', () => { 53 | it('returns a string', async () => { 54 | expect( 55 | typeof renderMarkdownTable([ 56 | ['A', 'B'], 57 | ['C', 'D'] 58 | ]) === 'string' 59 | ).toBe(true) 60 | }) 61 | it('generates the correct markup', async () => { 62 | const expected = `| |\n| :--- | :---: |\n| A | B |\n| C | D |` 63 | expect( 64 | renderMarkdownTable([ 65 | ['A', 'B'], 66 | ['C', 'D'] 67 | ]) 68 | ).toBe(expected) 69 | }) 70 | it('returns empty string for empty data', async () => { 71 | expect(renderMarkdownTable([])).toBe('') 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /__tests__/fs.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for src/fs.ts 3 | */ 4 | 5 | import { expect } from '@jest/globals' 6 | import { fileExists, readFile } from '../src/fs' 7 | 8 | describe('fileExists', () => { 9 | it('returns a Promise', async () => { 10 | expect(fileExists('package.json') instanceof Promise).toBe(true) 11 | }) 12 | it('resolves to a boolean', async () => { 13 | expect(typeof (await fileExists('package.json')) === 'boolean').toBe(true) 14 | }) 15 | it('returns true for existing files', async () => { 16 | expect(await fileExists('package.json')).toBe(true) 17 | }) 18 | it('returns false for non-existing files', async () => { 19 | expect(await fileExists('no-such-icon.json')).toBe(false) 20 | }) 21 | }) 22 | 23 | describe('readFile', () => { 24 | it('returns a Promise', async () => { 25 | expect(readFile('LICENSE') instanceof Promise).toBe(true) 26 | }) 27 | it('resolves to a string', async () => { 28 | expect(typeof (await readFile('LICENSE')) === 'string').toBe(true) 29 | }) 30 | it('returns file contents', async () => { 31 | expect(await readFile('LICENSE')).toMatch(/THE SOFTWARE IS PROVIDED/) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /__tests__/github.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for src/github.ts 3 | */ 4 | 5 | import { expect } from '@jest/globals' 6 | 7 | import { createIssueComment, createPullRequestReview, getIssueComments, updateIssueComment } from '../src/github' 8 | 9 | describe('github', () => { 10 | let octokit: any 11 | 12 | beforeEach(() => { 13 | octokit = { 14 | rest: { 15 | issues: { 16 | listComments: jest.fn(async () => ({ data: [{ id: 1 }, { id: 2 }] })), 17 | updateComment: jest.fn(async (data: any) => ({ data: { ...data, id: data.comment_id } })), 18 | createComment: jest.fn(async (data: any) => ({ data: { ...data, id: 4 } })) 19 | }, 20 | pulls: { 21 | createReview: jest.fn(async (data: object) => ({ data: { ...data, id: 5 } })) 22 | } 23 | } 24 | } 25 | jest.clearAllMocks() 26 | }) 27 | 28 | describe('getIssueComments', () => { 29 | it('calls issues.listComments with correct parameters', async () => { 30 | const params = { owner: 'owner', repo: 'repo', issue_number: 123 } 31 | const expectedArguments = { ...params } 32 | 33 | await getIssueComments(octokit, params) 34 | 35 | expect(octokit.rest.issues.listComments).toHaveBeenCalledWith(expectedArguments) 36 | }) 37 | 38 | it('returns the comment data', async () => { 39 | const params = { owner: 'owner', repo: 'repo', issue_number: 123 } 40 | const expectedResult = [{ id: 1 }, { id: 2 }] 41 | 42 | const result = await getIssueComments(octokit, params) 43 | 44 | expect(result).toMatchObject(expectedResult) 45 | }) 46 | 47 | it('throws an error if listComments fails', async () => { 48 | octokit.rest.issues.listComments.mockRejectedValue(new Error('API error')) 49 | const params = { owner: 'owner', repo: 'repo', issue_number: 123 } 50 | 51 | await expect(getIssueComments(octokit, params)).rejects.toThrow('API error') 52 | }) 53 | }) 54 | 55 | describe('createIssueComment', () => { 56 | it('calls issues.createComment with correct parameters', async () => { 57 | const params = { owner: 'owner', repo: 'repo', issue_number: 123, body: 'body' } 58 | const expectedArguments = { ...params } 59 | 60 | await createIssueComment(octokit, params) 61 | 62 | expect(octokit.rest.issues.createComment).toHaveBeenCalledWith(expectedArguments) 63 | }) 64 | 65 | it('returns the comment data', async () => { 66 | const params = { owner: 'owner', repo: 'repo', issue_number: 123, body: 'body' } 67 | const expectedResult = { ...params, id: expect.any(Number) } 68 | 69 | const result = await createIssueComment(octokit, params) 70 | 71 | expect(result).toMatchObject(expectedResult) 72 | }) 73 | 74 | it('throws an error if createComment fails', async () => { 75 | octokit.rest.issues.createComment.mockRejectedValue(new Error('API error')) 76 | const params = { owner: 'owner', repo: 'repo', issue_number: 123, body: 'body' } 77 | 78 | await expect(createIssueComment(octokit, params)).rejects.toThrow('API error') 79 | }) 80 | }) 81 | 82 | describe('updateIssueComment', () => { 83 | it('calls issues.updateComment with correct parameters', async () => { 84 | const params = { owner: 'owner', repo: 'repo', comment_id: 123, body: 'body' } 85 | const expectedArguments = { ...params } 86 | 87 | await updateIssueComment(octokit, params) 88 | 89 | expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith(expectedArguments) 90 | }) 91 | 92 | it('returns the comment data', async () => { 93 | const params = { owner: 'owner', repo: 'repo', comment_id: 123, body: 'body' } 94 | const expectedResult = { ...params, id: expect.any(Number) } 95 | 96 | const result = await updateIssueComment(octokit, params) 97 | 98 | expect(result).toMatchObject(expectedResult) 99 | }) 100 | 101 | it('throws an error if updateComment fails', async () => { 102 | octokit.rest.issues.updateComment.mockRejectedValue(new Error('API error')) 103 | const params = { owner: 'owner', repo: 'repo', comment_id: 123, body: 'body' } 104 | 105 | await expect(updateIssueComment(octokit, params)).rejects.toThrow('API error') 106 | }) 107 | }) 108 | 109 | describe('createPullRequestReview', () => { 110 | it('calls pulls.createReview with correct parameters', async () => { 111 | const params = { owner: 'owner', repo: 'repo', pull_number: 123, body: 'body' } 112 | const expectedArguments = { ...params, event: 'COMMENT' } 113 | 114 | await createPullRequestReview(octokit, params) 115 | 116 | expect(octokit.rest.pulls.createReview).toHaveBeenCalledWith(expectedArguments) 117 | }) 118 | 119 | it('returns the review data', async () => { 120 | const params = { owner: 'owner', repo: 'repo', pull_number: 123, body: 'body' } 121 | const expectedResult = { ...params, id: expect.any(Number) } 122 | 123 | const result = await createPullRequestReview(octokit, params) 124 | 125 | expect(result).toMatchObject(expectedResult) 126 | }) 127 | 128 | it('throws an error if createReview fails', async () => { 129 | octokit.rest.pulls.createReview.mockRejectedValue(new Error('API error')) 130 | const params = { owner: 'owner', repo: 'repo', pull_number: 123, body: 'body' } 131 | 132 | await expect(createPullRequestReview(octokit, params)).rejects.toThrow('API error') 133 | }) 134 | }) 135 | }) 136 | -------------------------------------------------------------------------------- /__tests__/icons.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for src/icons.ts 3 | */ 4 | 5 | import { expect } from '@jest/globals' 6 | import { renderIcon } from '../src/icons' 7 | 8 | describe('renderIcon', () => { 9 | it('returns a string', async () => { 10 | expect(typeof renderIcon('passed') === 'string').toBe(true) 11 | }) 12 | it('returns an empty string for undefined icons', async () => { 13 | expect(renderIcon('no-such-icon')).toBe('') 14 | }) 15 | it('renders markdown images', async () => { 16 | expect(renderIcon('passed')).toMatch(/^[!]\[([\w\s\d]+)\]\((https?:\/\/[\w\d./?&=_-]+)\)$/i) 17 | }) 18 | it('colorizes markdown images', async () => { 19 | expect(renderIcon('passed')).toMatch(/\?.*?\bcolor=[\w]{6}\b/i) 20 | }) 21 | it('renders emojis', async () => { 22 | expect(renderIcon('passed', { iconStyle: 'emojis' })).toBe('✅') 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for the action's entrypoint, src/index.ts 3 | * 4 | * These should be run as if the action was called from a workflow. 5 | * Specifically, the inputs listed in `action.yml` should be set as environment 6 | * variables following the pattern `INPUT_`. 7 | */ 8 | 9 | import { Context } from '@actions/github/lib/context' 10 | import * as actionsCore from '@actions/core' 11 | import * as actionsGitHub from '@actions/github' 12 | 13 | import * as index from '../src/index' 14 | import * as fs from '../src/fs' 15 | import * as report from '../src/report' 16 | import * as github from '../src/github' 17 | 18 | // Mock the GitHub Actions core library 19 | jest.spyOn(actionsCore, 'info').mockImplementation(jest.fn()) 20 | jest.spyOn(actionsCore, 'warning').mockImplementation(jest.fn()) 21 | jest.spyOn(actionsCore, 'error').mockImplementation(jest.fn()) 22 | const debugMock = jest.spyOn(actionsCore, 'debug').mockImplementation(jest.fn()) 23 | const getInputMock = jest.spyOn(actionsCore, 'getInput').mockImplementation((name: string) => inputs[name] || '') 24 | const setFailedMock = jest.spyOn(actionsCore, 'setFailed').mockImplementation(jest.fn()) 25 | const setOutputMock = jest.spyOn(actionsCore, 'setOutput').mockImplementation(jest.fn()) 26 | 27 | // Mock the fs module 28 | const readFileMock = jest.spyOn(fs, 'readFile') 29 | 30 | // Mock the report module 31 | const parseReportMock = jest.spyOn(report, 'parseReport') 32 | const renderReportSummaryMock = jest.spyOn(report, 'renderReportSummary') 33 | 34 | // Mock the github module 35 | const createIssueCommentMock = jest.spyOn(github, 'createIssueComment') 36 | 37 | // Mock the GitHub Actions context library 38 | // const contextMock = jest.spyOn(github, 'context') 39 | 40 | // Mock the GitHub Actions Octokit instance 41 | type Octokit = ReturnType 42 | 43 | const octokitMock = { 44 | rest: { 45 | issues: { 46 | listComments: jest.fn(async () => ({ data: [{ id: 1 }, { id: 2 }] })), 47 | updateComment: jest.fn(async (data: any) => ({ data: { ...data, id: data.comment_id } })), 48 | createComment: jest.fn(async (data: any) => ({ data: { ...data, id: 4 } })) 49 | }, 50 | pulls: { 51 | createReview: jest.fn(async (data: object) => ({ data: { ...data, id: 5 } })) 52 | } 53 | } 54 | } as unknown as Octokit 55 | 56 | const getOctokitMock = jest.spyOn(actionsGitHub, 'getOctokit').mockImplementation(() => octokitMock) 57 | 58 | // Mock the action's entrypoint 59 | const runMock = jest.spyOn(index, 'run') 60 | 61 | // Mark as GitHub action environment 62 | // process.env.GITHUB_ACTIONS = 'true' 63 | 64 | // Shallow clone original @actions/github context 65 | // @ts-expect-error missing issue and repo keys 66 | const originalContext: Context = { issue: {}, ...actionsGitHub.context } 67 | 68 | const defaultContext = { 69 | eventName: 'pull_request', 70 | repo: { 71 | owner: 'some-owner', 72 | repo: 'some-repo' 73 | }, 74 | issue: { 75 | owner: 'some-owner', 76 | number: 12345 77 | }, 78 | payload: { 79 | issue: { 80 | number: 12345 81 | }, 82 | pull_request: { 83 | base: { 84 | ref: 'main', 85 | sha: 'abc123' 86 | }, 87 | head: { 88 | ref: 'feature-branch', 89 | sha: 'def456' 90 | } 91 | } 92 | } 93 | } 94 | 95 | // Inputs for mock @actions/core 96 | let inputs: Record = {} 97 | 98 | function setContext(context: any): void { 99 | Object.defineProperty(actionsGitHub, 'context', { value: context, writable: true }) 100 | } 101 | 102 | describe('action', () => { 103 | beforeAll(() => {}) 104 | 105 | beforeEach(() => { 106 | setContext(defaultContext) 107 | jest.clearAllMocks() 108 | }) 109 | 110 | afterEach(() => { 111 | // Restore @actions/github context 112 | setContext(originalContext) 113 | }) 114 | 115 | afterAll(() => { 116 | // Restore 117 | jest.restoreAllMocks() 118 | }) 119 | 120 | it('reads its inputs', async () => { 121 | inputs = { 122 | 'report-file': '__tests__/__fixtures__/report-valid.json', 123 | 'comment-title': 'Custom comment title' 124 | } 125 | 126 | await index.run() 127 | 128 | expect(runMock).toHaveReturned() 129 | expect(getInputMock).toHaveBeenCalledWith('github-token') 130 | expect(getInputMock).toHaveBeenCalledWith('report-file', { required: true }) 131 | expect(getInputMock).toHaveBeenCalledWith('report-url') 132 | expect(getInputMock).toHaveBeenCalledWith('report-tag') 133 | expect(getInputMock).toHaveBeenCalledWith('comment-title') 134 | expect(getInputMock).toHaveBeenCalledWith('icon-style') 135 | expect(getInputMock).toHaveBeenCalledWith('create-comment') 136 | expect(getInputMock).toHaveBeenCalledWith('job-summary') 137 | expect(getInputMock).toHaveBeenCalledWith('test-command') 138 | expect(getInputMock).toHaveBeenCalledWith('footer') 139 | }) 140 | 141 | it('debugs its inputs', async () => { 142 | inputs = { 143 | 'report-file': '__tests__/__fixtures__/report-valid.json', 144 | 'comment-title': 'Custom comment title' 145 | } 146 | 147 | await index.run() 148 | 149 | expect(runMock).toHaveReturned() 150 | expect(debugMock).toHaveBeenNthCalledWith(1, 'Report file: __tests__/__fixtures__/report-valid.json') 151 | expect(debugMock).toHaveBeenNthCalledWith(2, 'Report url: (none)') 152 | expect(debugMock).toHaveBeenNthCalledWith(3, 'Report tag: (none)') 153 | expect(debugMock).toHaveBeenNthCalledWith(4, 'Comment title: Custom comment title') 154 | expect(debugMock).toHaveBeenNthCalledWith(5, 'Creating comment? yes') 155 | expect(debugMock).toHaveBeenNthCalledWith(6, 'Creating job summary? no') 156 | }) 157 | 158 | it('creates an Octokit instance', async () => { 159 | inputs = { 160 | 'github-token': 'some-token' 161 | } 162 | 163 | await index.run() 164 | expect(runMock).toHaveReturned() 165 | expect(getOctokitMock).toHaveBeenCalledWith('some-token') 166 | }) 167 | 168 | it('reads the supplied report file', async () => { 169 | inputs = { 170 | 'report-file': '__tests__/__fixtures__/report-valid.json' 171 | } 172 | 173 | await index.run() 174 | 175 | expect(runMock).toHaveReturned() 176 | expect(readFileMock).toHaveBeenNthCalledWith( 177 | 1, 178 | expect.stringMatching(/__tests__[/]__fixtures__[/]report-valid.json$/) 179 | ) 180 | }) 181 | 182 | it('parses the report and renders a summary', async () => { 183 | inputs = { 184 | 'report-file': '__tests__/__fixtures__/report-valid.json' 185 | } 186 | 187 | await index.run() 188 | expect(runMock).toHaveReturned() 189 | 190 | expect(parseReportMock).toHaveBeenNthCalledWith(1, expect.any(String)) 191 | expect(renderReportSummaryMock).toHaveBeenNthCalledWith( 192 | 1, 193 | expect.any(Object), 194 | expect.objectContaining({ 195 | commit: 'def456', 196 | title: 'Playwright test results', 197 | reportUrl: expect.any(String), 198 | iconStyle: expect.any(String) 199 | }) 200 | ) 201 | }) 202 | 203 | it('sets a summary output', async () => { 204 | inputs = { 205 | 'report-file': '__tests__/__fixtures__/report-valid.json' 206 | } 207 | 208 | await index.run() 209 | 210 | expect(runMock).toHaveReturned() 211 | expect(setOutputMock).toHaveBeenNthCalledWith(1, 'summary', expect.stringContaining('# Playwright test results')) 212 | }) 213 | 214 | it('sets a comment id output', async () => { 215 | inputs = { 216 | 'report-file': '__tests__/__fixtures__/report-valid.json' 217 | } 218 | 219 | await index.run() 220 | 221 | expect(runMock).toHaveReturned() 222 | expect(setOutputMock).toHaveBeenNthCalledWith(2, 'comment-id', expect.anything()) 223 | }) 224 | 225 | it('sets a report data output', async () => { 226 | inputs = { 227 | 'report-file': '__tests__/__fixtures__/report-valid.json' 228 | } 229 | 230 | await index.run() 231 | 232 | expect(runMock).toHaveReturned() 233 | expect(setOutputMock).toHaveBeenNthCalledWith(3, 'report-data', expect.stringContaining('"failed":[')) 234 | }) 235 | 236 | it('creates a comment', async () => { 237 | inputs = { 238 | 'report-file': '__tests__/__fixtures__/report-valid.json' 239 | } 240 | 241 | await index.run() 242 | 243 | expect(runMock).toHaveReturned() 244 | expect(createIssueCommentMock).toHaveBeenCalledWith( 245 | expect.anything(), 246 | expect.objectContaining({ 247 | body: expect.stringContaining('# Playwright test results') 248 | }) 249 | ) 250 | }) 251 | 252 | it('can disable creating a comment', async () => { 253 | inputs = { 254 | 'report-file': '__tests__/__fixtures__/report-valid.json', 255 | 'create-comment': 'false' 256 | } 257 | 258 | await index.run() 259 | 260 | expect(runMock).toHaveReturned() 261 | expect(createIssueCommentMock).not.toHaveBeenCalled() 262 | }) 263 | 264 | it('sets a failed status', async () => { 265 | inputs = { 266 | 'report-file': 'file-does-not-exist.json' 267 | } 268 | 269 | await index.run() 270 | 271 | expect(runMock).toHaveReturned() 272 | expect(setFailedMock).toHaveBeenNthCalledWith( 273 | 1, 274 | 'Report file file-does-not-exist.json not found. Make sure Playwright is configured to generate a JSON report.' 275 | ) 276 | }) 277 | }) 278 | -------------------------------------------------------------------------------- /__tests__/report.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for src/report.ts 3 | */ 4 | 5 | import { expect } from '@jest/globals' 6 | import { readFile } from '../src/fs' 7 | import { 8 | ReportSummary, 9 | buildTitle, 10 | isValidReport, 11 | parseReport, 12 | parseReportFiles, 13 | parseReportSuites, 14 | renderReportSummary 15 | } from '../src/report' 16 | 17 | const defaultReport = 'report-valid.json' 18 | const invalidReport = 'report-invalid.json' 19 | const reportWithoutDuration = 'report-without-duration.json' 20 | const shardedReport = 'report-sharded.json' 21 | const nestedReport = 'report-nested.json' 22 | 23 | async function getReport(file = defaultReport): Promise { 24 | return await readFile(`__tests__/__fixtures__/${file}`) 25 | } 26 | 27 | async function getParsedReport(file = defaultReport): Promise { 28 | return parseReport(await getReport(file)) 29 | } 30 | 31 | describe('isValidReport', () => { 32 | it('detects valid reports', async () => { 33 | const report = await getReport() 34 | expect(isValidReport(JSON.parse(report))).toBe(true) 35 | }) 36 | it('detects invalid reports', async () => { 37 | const report = await getReport(invalidReport) 38 | expect(isValidReport([])).toBe(false) 39 | expect(isValidReport('')).toBe(false) 40 | expect(isValidReport(JSON.parse(report))).toBe(false) 41 | }) 42 | }) 43 | 44 | describe('buildTitle', () => { 45 | it('returns an object with path and title', async () => { 46 | const result = buildTitle('A', 'B') 47 | expect(result).toBeInstanceOf(Object) 48 | expect(result.path).toBeDefined() 49 | expect(result.title).toBeDefined() 50 | }) 51 | it('concatenates and filters title segments', async () => { 52 | const { title } = buildTitle('A', 'B', '', 'C') 53 | expect(title).toBe('A › B › C') 54 | }) 55 | it('concatenates and filters path segments', async () => { 56 | const { path } = buildTitle('A', '', 'B', 'C') 57 | expect(path).toStrictEqual(['A', 'B', 'C']) 58 | }) 59 | }) 60 | 61 | describe('parseReportFiles', () => { 62 | it('returns an array of root filenames', async () => { 63 | const report = JSON.parse(await getReport(nestedReport)) 64 | const files = parseReportFiles(report) 65 | expect(files).toStrictEqual(['add.spec.ts', 'nested.spec.ts']) 66 | }) 67 | }) 68 | 69 | describe('parseReportSuites', () => { 70 | it('returns an array of root suite summaries', async () => { 71 | const report = JSON.parse(await getReport(nestedReport)) 72 | const suites = parseReportSuites(report) 73 | expect(suites).toBeInstanceOf(Array) 74 | expect(suites.length).toBe(2) 75 | expect(suites[0].title).toBe('add.spec.ts') 76 | }) 77 | }) 78 | 79 | describe('parseReport', () => { 80 | it('returns an object', async () => { 81 | const parsed = await getParsedReport() 82 | expect(typeof parsed === 'object').toBe(true) 83 | }) 84 | it('returns playwright version', async () => { 85 | const parsed = await getParsedReport() 86 | expect(parsed.version).toBe('1.37.1') 87 | }) 88 | it('returns total duration', async () => { 89 | const parsed = await getParsedReport() 90 | expect(parsed.duration).toBe(1118.34) 91 | }) 92 | it('calculates duration if missing', async () => { 93 | const parsed = await getParsedReport(reportWithoutDuration) 94 | expect(parsed.duration).toBe(943) 95 | }) 96 | it('returns workers', async () => { 97 | const parsed = await getParsedReport() 98 | expect(parsed.workers).toBe(5) 99 | }) 100 | it('returns shards', async () => { 101 | const parsed = await getParsedReport() 102 | expect(parsed.shards).toBe(2) 103 | }) 104 | it('returns files', async () => { 105 | const parsed = await getParsedReport() 106 | expect(parsed.files.length).toBe(4) 107 | }) 108 | it('returns suites', async () => { 109 | const parsed = await getParsedReport() 110 | expect(parsed.suites.length).toBe(4) 111 | }) 112 | it('returns specs', async () => { 113 | const parsed = await getParsedReport() 114 | expect(parsed.specs.length).toBe(14) 115 | }) 116 | it('counts tests', async () => { 117 | const parsed = await getParsedReport() 118 | expect(parsed.tests.length).toBe(14) 119 | expect(parsed.failed.length).toBe(2) 120 | expect(parsed.passed.length).toBe(10) 121 | expect(parsed.flaky.length).toBe(1) 122 | expect(parsed.skipped.length).toBe(1) 123 | }) 124 | it('counts sharded tests', async () => { 125 | const parsed = await getParsedReport(shardedReport) 126 | expect(parsed.tests.length).toBe(27) 127 | expect(parsed.failed.length).toBe(1) 128 | expect(parsed.passed.length).toBe(22) 129 | expect(parsed.flaky.length).toBe(1) 130 | expect(parsed.skipped.length).toBe(3) 131 | }) 132 | it('counts nested suites', async () => { 133 | const parsed = await getParsedReport(nestedReport) 134 | expect(parsed.suites.length).toBe(2) 135 | expect(parsed.specs.length).toBe(45) 136 | expect(parsed.tests.length).toBe(45) 137 | }) 138 | }) 139 | 140 | describe('renderReportSummary', () => { 141 | const renderOptions = { 142 | title: 'Test Report', 143 | reportUrl: 'https://example.com/report', 144 | customInfo: 'For more information, see our [documentation](https://example.com/docs)', 145 | commit: '1234567' 146 | } 147 | const getReportSummary = async (): Promise => 148 | renderReportSummary(parseReport(await getReport()), renderOptions) 149 | it('returns a string', async () => { 150 | const summary = await getReportSummary() 151 | expect(typeof summary === 'string').toBe(true) 152 | }) 153 | it('matches snapshot', async () => { 154 | const summary = await getReportSummary() 155 | expect(summary).toMatchSnapshot() 156 | }) 157 | }) 158 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Playwright report comment' 2 | description: 'Report Playwright test results as pull request comments' 3 | author: 'Philipp Daun ' 4 | 5 | branding: 6 | icon: 'message-circle' 7 | color: 'purple' 8 | 9 | inputs: 10 | github-token: 11 | description: 'The GitHub access token' 12 | required: false 13 | default: ${{ github.token }} 14 | report-file: 15 | description: 'Path to the generated json report file' 16 | required: true 17 | report-tag: 18 | description: 'Tag reports to distinguish test runs in the same workflow instead of overwriting' 19 | required: false 20 | report-url: 21 | description: 'URL to the generated html report, if uploaded' 22 | required: false 23 | create-comment: 24 | description: 'Create a pull request comment with the test result summary' 25 | required: false 26 | default: 'true' 27 | comment-title: 28 | description: 'Customize the title of the pull request comment' 29 | required: false 30 | default: 'Playwright test results' 31 | custom-info: 32 | description: 'Additional information to include in the summary comment, markdown-formatted' 33 | required: false 34 | job-summary: 35 | description: 'Create a job summary comment for the workflow run' 36 | required: false 37 | default: 'false' 38 | icon-style: 39 | description: 'The icons to use: octicons or emoji' 40 | required: false 41 | default: 'octicons' 42 | test-command: 43 | description: 'The command used to run the tests' 44 | required: false 45 | footer: 46 | description: 'Additional content to add to the comment below the test report' 47 | required: false 48 | default: '' 49 | 50 | outputs: 51 | summary: 52 | description: 'The rendered markdown summary of the test report' 53 | comment-id: 54 | description: 'The ID of the comment that was created or updated' 55 | report-data: 56 | description: 'The raw data of the test report' 57 | 58 | runs: 59 | using: node20 60 | main: dist/index.js 61 | -------------------------------------------------------------------------------- /assets/comment-failed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daun/playwright-report-summary/2fe141d67923f13be8fa607f6462aea837865492/assets/comment-failed.png -------------------------------------------------------------------------------- /assets/comment-passed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daun/playwright-report-summary/2fe141d67923f13be8fa607f6462aea837865492/assets/comment-passed.png -------------------------------------------------------------------------------- /dist/licenses.txt: -------------------------------------------------------------------------------- 1 | @actions/core 2 | MIT 3 | The MIT License (MIT) 4 | 5 | Copyright 2019 GitHub 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | 13 | @actions/exec 14 | MIT 15 | The MIT License (MIT) 16 | 17 | Copyright 2019 GitHub 18 | 19 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 20 | 21 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | 25 | @actions/github 26 | MIT 27 | The MIT License (MIT) 28 | 29 | Copyright 2019 GitHub 30 | 31 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 32 | 33 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 34 | 35 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 36 | 37 | @actions/http-client 38 | MIT 39 | Actions Http Client for Node.js 40 | 41 | Copyright (c) GitHub, Inc. 42 | 43 | All rights reserved. 44 | 45 | MIT License 46 | 47 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 48 | associated documentation files (the "Software"), to deal in the Software without restriction, 49 | including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, 50 | and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, 51 | subject to the following conditions: 52 | 53 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 54 | 55 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 56 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN 57 | NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 58 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 59 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 60 | 61 | 62 | @actions/io 63 | MIT 64 | The MIT License (MIT) 65 | 66 | Copyright 2019 GitHub 67 | 68 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 69 | 70 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 71 | 72 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 73 | 74 | @fastify/busboy 75 | MIT 76 | Copyright Brian White. All rights reserved. 77 | 78 | Permission is hereby granted, free of charge, to any person obtaining a copy 79 | of this software and associated documentation files (the "Software"), to 80 | deal in the Software without restriction, including without limitation the 81 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 82 | sell copies of the Software, and to permit persons to whom the Software is 83 | furnished to do so, subject to the following conditions: 84 | 85 | The above copyright notice and this permission notice shall be included in 86 | all copies or substantial portions of the Software. 87 | 88 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 89 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 90 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 91 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 92 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 93 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 94 | IN THE SOFTWARE. 95 | 96 | @octokit/auth-token 97 | MIT 98 | The MIT License 99 | 100 | Copyright (c) 2019 Octokit contributors 101 | 102 | Permission is hereby granted, free of charge, to any person obtaining a copy 103 | of this software and associated documentation files (the "Software"), to deal 104 | in the Software without restriction, including without limitation the rights 105 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 106 | copies of the Software, and to permit persons to whom the Software is 107 | furnished to do so, subject to the following conditions: 108 | 109 | The above copyright notice and this permission notice shall be included in 110 | all copies or substantial portions of the Software. 111 | 112 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 113 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 114 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 115 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 116 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 117 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 118 | THE SOFTWARE. 119 | 120 | 121 | @octokit/core 122 | MIT 123 | The MIT License 124 | 125 | Copyright (c) 2019 Octokit contributors 126 | 127 | Permission is hereby granted, free of charge, to any person obtaining a copy 128 | of this software and associated documentation files (the "Software"), to deal 129 | in the Software without restriction, including without limitation the rights 130 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 131 | copies of the Software, and to permit persons to whom the Software is 132 | furnished to do so, subject to the following conditions: 133 | 134 | The above copyright notice and this permission notice shall be included in 135 | all copies or substantial portions of the Software. 136 | 137 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 138 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 139 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 140 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 141 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 142 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 143 | THE SOFTWARE. 144 | 145 | 146 | @octokit/endpoint 147 | MIT 148 | The MIT License 149 | 150 | Copyright (c) 2018 Octokit contributors 151 | 152 | Permission is hereby granted, free of charge, to any person obtaining a copy 153 | of this software and associated documentation files (the "Software"), to deal 154 | in the Software without restriction, including without limitation the rights 155 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 156 | copies of the Software, and to permit persons to whom the Software is 157 | furnished to do so, subject to the following conditions: 158 | 159 | The above copyright notice and this permission notice shall be included in 160 | all copies or substantial portions of the Software. 161 | 162 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 163 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 164 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 165 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 166 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 167 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 168 | THE SOFTWARE. 169 | 170 | 171 | @octokit/graphql 172 | MIT 173 | The MIT License 174 | 175 | Copyright (c) 2018 Octokit contributors 176 | 177 | Permission is hereby granted, free of charge, to any person obtaining a copy 178 | of this software and associated documentation files (the "Software"), to deal 179 | in the Software without restriction, including without limitation the rights 180 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 181 | copies of the Software, and to permit persons to whom the Software is 182 | furnished to do so, subject to the following conditions: 183 | 184 | The above copyright notice and this permission notice shall be included in 185 | all copies or substantial portions of the Software. 186 | 187 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 188 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 189 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 190 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 191 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 192 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 193 | THE SOFTWARE. 194 | 195 | 196 | @octokit/plugin-paginate-rest 197 | MIT 198 | MIT License Copyright (c) 2019 Octokit contributors 199 | 200 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 201 | 202 | The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. 203 | 204 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 205 | 206 | 207 | @octokit/plugin-rest-endpoint-methods 208 | MIT 209 | MIT License Copyright (c) 2019 Octokit contributors 210 | 211 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 212 | 213 | The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. 214 | 215 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 216 | 217 | 218 | @octokit/request 219 | MIT 220 | The MIT License 221 | 222 | Copyright (c) 2018 Octokit contributors 223 | 224 | Permission is hereby granted, free of charge, to any person obtaining a copy 225 | of this software and associated documentation files (the "Software"), to deal 226 | in the Software without restriction, including without limitation the rights 227 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 228 | copies of the Software, and to permit persons to whom the Software is 229 | furnished to do so, subject to the following conditions: 230 | 231 | The above copyright notice and this permission notice shall be included in 232 | all copies or substantial portions of the Software. 233 | 234 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 235 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 236 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 237 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 238 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 239 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 240 | THE SOFTWARE. 241 | 242 | 243 | @octokit/request-error 244 | MIT 245 | The MIT License 246 | 247 | Copyright (c) 2019 Octokit contributors 248 | 249 | Permission is hereby granted, free of charge, to any person obtaining a copy 250 | of this software and associated documentation files (the "Software"), to deal 251 | in the Software without restriction, including without limitation the rights 252 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 253 | copies of the Software, and to permit persons to whom the Software is 254 | furnished to do so, subject to the following conditions: 255 | 256 | The above copyright notice and this permission notice shall be included in 257 | all copies or substantial portions of the Software. 258 | 259 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 260 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 261 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 262 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 263 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 264 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 265 | THE SOFTWARE. 266 | 267 | 268 | @vercel/ncc 269 | MIT 270 | Copyright 2018 ZEIT, Inc. 271 | 272 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 273 | 274 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 275 | 276 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 277 | 278 | before-after-hook 279 | Apache-2.0 280 | Apache License 281 | Version 2.0, January 2004 282 | http://www.apache.org/licenses/ 283 | 284 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 285 | 286 | 1. Definitions. 287 | 288 | "License" shall mean the terms and conditions for use, reproduction, 289 | and distribution as defined by Sections 1 through 9 of this document. 290 | 291 | "Licensor" shall mean the copyright owner or entity authorized by 292 | the copyright owner that is granting the License. 293 | 294 | "Legal Entity" shall mean the union of the acting entity and all 295 | other entities that control, are controlled by, or are under common 296 | control with that entity. For the purposes of this definition, 297 | "control" means (i) the power, direct or indirect, to cause the 298 | direction or management of such entity, whether by contract or 299 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 300 | outstanding shares, or (iii) beneficial ownership of such entity. 301 | 302 | "You" (or "Your") shall mean an individual or Legal Entity 303 | exercising permissions granted by this License. 304 | 305 | "Source" form shall mean the preferred form for making modifications, 306 | including but not limited to software source code, documentation 307 | source, and configuration files. 308 | 309 | "Object" form shall mean any form resulting from mechanical 310 | transformation or translation of a Source form, including but 311 | not limited to compiled object code, generated documentation, 312 | and conversions to other media types. 313 | 314 | "Work" shall mean the work of authorship, whether in Source or 315 | Object form, made available under the License, as indicated by a 316 | copyright notice that is included in or attached to the work 317 | (an example is provided in the Appendix below). 318 | 319 | "Derivative Works" shall mean any work, whether in Source or Object 320 | form, that is based on (or derived from) the Work and for which the 321 | editorial revisions, annotations, elaborations, or other modifications 322 | represent, as a whole, an original work of authorship. For the purposes 323 | of this License, Derivative Works shall not include works that remain 324 | separable from, or merely link (or bind by name) to the interfaces of, 325 | the Work and Derivative Works thereof. 326 | 327 | "Contribution" shall mean any work of authorship, including 328 | the original version of the Work and any modifications or additions 329 | to that Work or Derivative Works thereof, that is intentionally 330 | submitted to Licensor for inclusion in the Work by the copyright owner 331 | or by an individual or Legal Entity authorized to submit on behalf of 332 | the copyright owner. For the purposes of this definition, "submitted" 333 | means any form of electronic, verbal, or written communication sent 334 | to the Licensor or its representatives, including but not limited to 335 | communication on electronic mailing lists, source code control systems, 336 | and issue tracking systems that are managed by, or on behalf of, the 337 | Licensor for the purpose of discussing and improving the Work, but 338 | excluding communication that is conspicuously marked or otherwise 339 | designated in writing by the copyright owner as "Not a Contribution." 340 | 341 | "Contributor" shall mean Licensor and any individual or Legal Entity 342 | on behalf of whom a Contribution has been received by Licensor and 343 | subsequently incorporated within the Work. 344 | 345 | 2. Grant of Copyright License. Subject to the terms and conditions of 346 | this License, each Contributor hereby grants to You a perpetual, 347 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 348 | copyright license to reproduce, prepare Derivative Works of, 349 | publicly display, publicly perform, sublicense, and distribute the 350 | Work and such Derivative Works in Source or Object form. 351 | 352 | 3. Grant of Patent License. Subject to the terms and conditions of 353 | this License, each Contributor hereby grants to You a perpetual, 354 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 355 | (except as stated in this section) patent license to make, have made, 356 | use, offer to sell, sell, import, and otherwise transfer the Work, 357 | where such license applies only to those patent claims licensable 358 | by such Contributor that are necessarily infringed by their 359 | Contribution(s) alone or by combination of their Contribution(s) 360 | with the Work to which such Contribution(s) was submitted. If You 361 | institute patent litigation against any entity (including a 362 | cross-claim or counterclaim in a lawsuit) alleging that the Work 363 | or a Contribution incorporated within the Work constitutes direct 364 | or contributory patent infringement, then any patent licenses 365 | granted to You under this License for that Work shall terminate 366 | as of the date such litigation is filed. 367 | 368 | 4. Redistribution. You may reproduce and distribute copies of the 369 | Work or Derivative Works thereof in any medium, with or without 370 | modifications, and in Source or Object form, provided that You 371 | meet the following conditions: 372 | 373 | (a) You must give any other recipients of the Work or 374 | Derivative Works a copy of this License; and 375 | 376 | (b) You must cause any modified files to carry prominent notices 377 | stating that You changed the files; and 378 | 379 | (c) You must retain, in the Source form of any Derivative Works 380 | that You distribute, all copyright, patent, trademark, and 381 | attribution notices from the Source form of the Work, 382 | excluding those notices that do not pertain to any part of 383 | the Derivative Works; and 384 | 385 | (d) If the Work includes a "NOTICE" text file as part of its 386 | distribution, then any Derivative Works that You distribute must 387 | include a readable copy of the attribution notices contained 388 | within such NOTICE file, excluding those notices that do not 389 | pertain to any part of the Derivative Works, in at least one 390 | of the following places: within a NOTICE text file distributed 391 | as part of the Derivative Works; within the Source form or 392 | documentation, if provided along with the Derivative Works; or, 393 | within a display generated by the Derivative Works, if and 394 | wherever such third-party notices normally appear. The contents 395 | of the NOTICE file are for informational purposes only and 396 | do not modify the License. You may add Your own attribution 397 | notices within Derivative Works that You distribute, alongside 398 | or as an addendum to the NOTICE text from the Work, provided 399 | that such additional attribution notices cannot be construed 400 | as modifying the License. 401 | 402 | You may add Your own copyright statement to Your modifications and 403 | may provide additional or different license terms and conditions 404 | for use, reproduction, or distribution of Your modifications, or 405 | for any such Derivative Works as a whole, provided Your use, 406 | reproduction, and distribution of the Work otherwise complies with 407 | the conditions stated in this License. 408 | 409 | 5. Submission of Contributions. Unless You explicitly state otherwise, 410 | any Contribution intentionally submitted for inclusion in the Work 411 | by You to the Licensor shall be under the terms and conditions of 412 | this License, without any additional terms or conditions. 413 | Notwithstanding the above, nothing herein shall supersede or modify 414 | the terms of any separate license agreement you may have executed 415 | with Licensor regarding such Contributions. 416 | 417 | 6. Trademarks. This License does not grant permission to use the trade 418 | names, trademarks, service marks, or product names of the Licensor, 419 | except as required for reasonable and customary use in describing the 420 | origin of the Work and reproducing the content of the NOTICE file. 421 | 422 | 7. Disclaimer of Warranty. Unless required by applicable law or 423 | agreed to in writing, Licensor provides the Work (and each 424 | Contributor provides its Contributions) on an "AS IS" BASIS, 425 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 426 | implied, including, without limitation, any warranties or conditions 427 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 428 | PARTICULAR PURPOSE. You are solely responsible for determining the 429 | appropriateness of using or redistributing the Work and assume any 430 | risks associated with Your exercise of permissions under this License. 431 | 432 | 8. Limitation of Liability. In no event and under no legal theory, 433 | whether in tort (including negligence), contract, or otherwise, 434 | unless required by applicable law (such as deliberate and grossly 435 | negligent acts) or agreed to in writing, shall any Contributor be 436 | liable to You for damages, including any direct, indirect, special, 437 | incidental, or consequential damages of any character arising as a 438 | result of this License or out of the use or inability to use the 439 | Work (including but not limited to damages for loss of goodwill, 440 | work stoppage, computer failure or malfunction, or any and all 441 | other commercial damages or losses), even if such Contributor 442 | has been advised of the possibility of such damages. 443 | 444 | 9. Accepting Warranty or Additional Liability. While redistributing 445 | the Work or Derivative Works thereof, You may choose to offer, 446 | and charge a fee for, acceptance of support, warranty, indemnity, 447 | or other liability obligations and/or rights consistent with this 448 | License. However, in accepting such obligations, You may act only 449 | on Your own behalf and on Your sole responsibility, not on behalf 450 | of any other Contributor, and only if You agree to indemnify, 451 | defend, and hold each Contributor harmless for any liability 452 | incurred by, or claims asserted against, such Contributor by reason 453 | of your accepting any such warranty or additional liability. 454 | 455 | END OF TERMS AND CONDITIONS 456 | 457 | APPENDIX: How to apply the Apache License to your work. 458 | 459 | To apply the Apache License to your work, attach the following 460 | boilerplate notice, with the fields enclosed by brackets "{}" 461 | replaced with your own identifying information. (Don't include 462 | the brackets!) The text should be enclosed in the appropriate 463 | comment syntax for the file format. We also recommend that a 464 | file or class name and description of purpose be included on the 465 | same "printed page" as the copyright notice for easier 466 | identification within third-party archives. 467 | 468 | Copyright 2018 Gregor Martynus and other contributors. 469 | 470 | Licensed under the Apache License, Version 2.0 (the "License"); 471 | you may not use this file except in compliance with the License. 472 | You may obtain a copy of the License at 473 | 474 | http://www.apache.org/licenses/LICENSE-2.0 475 | 476 | Unless required by applicable law or agreed to in writing, software 477 | distributed under the License is distributed on an "AS IS" BASIS, 478 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 479 | See the License for the specific language governing permissions and 480 | limitations under the License. 481 | 482 | 483 | deprecation 484 | ISC 485 | The ISC License 486 | 487 | Copyright (c) Gregor Martynus and contributors 488 | 489 | Permission to use, copy, modify, and/or distribute this software for any 490 | purpose with or without fee is hereby granted, provided that the above 491 | copyright notice and this permission notice appear in all copies. 492 | 493 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 494 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 495 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 496 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 497 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 498 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR 499 | IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 500 | 501 | 502 | is-plain-object 503 | MIT 504 | The MIT License (MIT) 505 | 506 | Copyright (c) 2014-2017, Jon Schlinkert. 507 | 508 | Permission is hereby granted, free of charge, to any person obtaining a copy 509 | of this software and associated documentation files (the "Software"), to deal 510 | in the Software without restriction, including without limitation the rights 511 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 512 | copies of the Software, and to permit persons to whom the Software is 513 | furnished to do so, subject to the following conditions: 514 | 515 | The above copyright notice and this permission notice shall be included in 516 | all copies or substantial portions of the Software. 517 | 518 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 519 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 520 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 521 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 522 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 523 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 524 | THE SOFTWARE. 525 | 526 | 527 | node-fetch 528 | MIT 529 | The MIT License (MIT) 530 | 531 | Copyright (c) 2016 David Frank 532 | 533 | Permission is hereby granted, free of charge, to any person obtaining a copy 534 | of this software and associated documentation files (the "Software"), to deal 535 | in the Software without restriction, including without limitation the rights 536 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 537 | copies of the Software, and to permit persons to whom the Software is 538 | furnished to do so, subject to the following conditions: 539 | 540 | The above copyright notice and this permission notice shall be included in all 541 | copies or substantial portions of the Software. 542 | 543 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 544 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 545 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 546 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 547 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 548 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 549 | SOFTWARE. 550 | 551 | 552 | 553 | once 554 | ISC 555 | The ISC License 556 | 557 | Copyright (c) Isaac Z. Schlueter and Contributors 558 | 559 | Permission to use, copy, modify, and/or distribute this software for any 560 | purpose with or without fee is hereby granted, provided that the above 561 | copyright notice and this permission notice appear in all copies. 562 | 563 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 564 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 565 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 566 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 567 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 568 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR 569 | IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 570 | 571 | 572 | tr46 573 | MIT 574 | 575 | tunnel 576 | MIT 577 | The MIT License (MIT) 578 | 579 | Copyright (c) 2012 Koichi Kobayashi 580 | 581 | Permission is hereby granted, free of charge, to any person obtaining a copy 582 | of this software and associated documentation files (the "Software"), to deal 583 | in the Software without restriction, including without limitation the rights 584 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 585 | copies of the Software, and to permit persons to whom the Software is 586 | furnished to do so, subject to the following conditions: 587 | 588 | The above copyright notice and this permission notice shall be included in 589 | all copies or substantial portions of the Software. 590 | 591 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 592 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 593 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 594 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 595 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 596 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 597 | THE SOFTWARE. 598 | 599 | 600 | undici 601 | MIT 602 | MIT License 603 | 604 | Copyright (c) Matteo Collina and Undici contributors 605 | 606 | Permission is hereby granted, free of charge, to any person obtaining a copy 607 | of this software and associated documentation files (the "Software"), to deal 608 | in the Software without restriction, including without limitation the rights 609 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 610 | copies of the Software, and to permit persons to whom the Software is 611 | furnished to do so, subject to the following conditions: 612 | 613 | The above copyright notice and this permission notice shall be included in all 614 | copies or substantial portions of the Software. 615 | 616 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 617 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 618 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 619 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 620 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 621 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 622 | SOFTWARE. 623 | 624 | 625 | universal-user-agent 626 | ISC 627 | # [ISC License](https://spdx.org/licenses/ISC) 628 | 629 | Copyright (c) 2018, Gregor Martynus (https://github.com/gr2m) 630 | 631 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 632 | 633 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 634 | 635 | 636 | webidl-conversions 637 | BSD-2-Clause 638 | # The BSD 2-Clause License 639 | 640 | Copyright (c) 2014, Domenic Denicola 641 | All rights reserved. 642 | 643 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 644 | 645 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 646 | 647 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 648 | 649 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 650 | 651 | 652 | whatwg-url 653 | MIT 654 | The MIT License (MIT) 655 | 656 | Copyright (c) 2015–2016 Sebastian Mayr 657 | 658 | Permission is hereby granted, free of charge, to any person obtaining a copy 659 | of this software and associated documentation files (the "Software"), to deal 660 | in the Software without restriction, including without limitation the rights 661 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 662 | copies of the Software, and to permit persons to whom the Software is 663 | furnished to do so, subject to the following conditions: 664 | 665 | The above copyright notice and this permission notice shall be included in 666 | all copies or substantial portions of the Software. 667 | 668 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 669 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 670 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 671 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 672 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 673 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 674 | THE SOFTWARE. 675 | 676 | 677 | wrappy 678 | ISC 679 | The ISC License 680 | 681 | Copyright (c) Isaac Z. Schlueter and Contributors 682 | 683 | Permission to use, copy, modify, and/or distribute this software for any 684 | purpose with or without fee is hereby granted, provided that the above 685 | copyright notice and this permission notice appear in all copies. 686 | 687 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 688 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 689 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 690 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 691 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 692 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR 693 | IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 694 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playwright-report-summary", 3 | "description": "A GitHub action to report Playwright test results as pull-request comments", 4 | "version": "3.3.0", 5 | "author": "Philipp Daun ", 6 | "homepage": "https://github.com/daun/playwright-report-summary", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/daun/playwright-report-summary.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/daun/playwright-report-summary/issues" 13 | }, 14 | "keywords": [ 15 | "actions", 16 | "node", 17 | "testing", 18 | "reporting", 19 | "test report", 20 | "playwright", 21 | "pull request" 22 | ], 23 | "exports": { 24 | ".": "./dist/index.js" 25 | }, 26 | "engines": { 27 | "node": ">=16" 28 | }, 29 | "scripts": { 30 | "bundle": "npm run format:write && npm run package", 31 | "ci-test": "jest", 32 | "format:write": "prettier --write '**/*.ts'", 33 | "format:check": "prettier --check '**/*.ts'", 34 | "lint": "npx eslint . -c ./.github/linters/.eslintrc.yml", 35 | "package": "ncc build src/index.ts --license licenses.txt", 36 | "package:watch": "npm run package -- --watch", 37 | "test": "jest", 38 | "test:update": "jest -u", 39 | "all": "npm run format:write && npm run lint && npm run test && npm run package" 40 | }, 41 | "license": "MIT", 42 | "jest": { 43 | "preset": "ts-jest", 44 | "verbose": true, 45 | "clearMocks": true, 46 | "testEnvironment": "node", 47 | "moduleFileExtensions": [ 48 | "js", 49 | "ts" 50 | ], 51 | "testMatch": [ 52 | "**/*.test.ts" 53 | ], 54 | "testPathIgnorePatterns": [ 55 | "/node_modules/", 56 | "/dist/" 57 | ], 58 | "transform": { 59 | "^.+\\.ts$": "ts-jest" 60 | }, 61 | "coverageReporters": [ 62 | "json-summary", 63 | "text", 64 | "lcov" 65 | ], 66 | "collectCoverage": true, 67 | "collectCoverageFrom": [ 68 | "./src/**" 69 | ] 70 | }, 71 | "dependencies": { 72 | "@actions/core": "^1.10.1", 73 | "@actions/github": "^5.1.1" 74 | }, 75 | "devDependencies": { 76 | "@octokit/openapi-types": "^22.0.0", 77 | "@playwright/test": "^1.50.1", 78 | "@types/jest": "^29.5.13", 79 | "@types/node": "^22.15.29", 80 | "@typescript-eslint/eslint-plugin": "^7.14.1", 81 | "@typescript-eslint/parser": "^7.18.0", 82 | "@vercel/ncc": "^0.38.0", 83 | "eslint": "^8.49.0", 84 | "eslint-plugin-github": "^5.1.8", 85 | "eslint-plugin-jest": "^28.8.3", 86 | "eslint-plugin-jsonc": "^2.20.1", 87 | "eslint-plugin-prettier": "^5.2.3", 88 | "jest": "^29.7.0", 89 | "js-yaml": "^4.1.0", 90 | "prettier": "^3.5.3", 91 | "prettier-eslint": "^16.3.0", 92 | "ts-jest": "^29.2.5", 93 | "typescript": "^5.7.3" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/formatting.ts: -------------------------------------------------------------------------------- 1 | export function renderMarkdownTable(rows: string[][], headers: string[] = []): string { 2 | if (!rows.length) { 3 | return '' 4 | } 5 | const align = [':---', ':---:', ':---:', ':---:'].slice(0, rows[0].length) 6 | const lines = [headers, align, ...rows].filter(Boolean) 7 | return lines.map((columns) => `| ${columns.join(' | ')} |`).join('\n') 8 | } 9 | 10 | export function renderAccordion(summary: string, content: string, { open = false }: { open?: boolean } = {}): string { 11 | summary = `${summary}` 12 | content = `\n\n${content.trim()}\n\n` 13 | return `
${summary}\n\n${content.trim()}\n\n
` 14 | } 15 | 16 | export function renderCodeBlock(code: string, lang = ''): string { 17 | return `\`\`\`${lang}\n${code}\n\`\`\`` 18 | } 19 | 20 | export function formatDuration(milliseconds: number): string { 21 | const SECOND = 1000 22 | const MINUTE = 60 * SECOND 23 | const HOUR = 60 * MINUTE 24 | const DAY = 24 * HOUR 25 | 26 | let remaining = milliseconds 27 | 28 | const days = Math.floor(remaining / DAY) 29 | remaining %= DAY 30 | 31 | const hours = Math.floor(remaining / HOUR) 32 | remaining %= HOUR 33 | 34 | const minutes = Math.floor(remaining / MINUTE) 35 | remaining %= MINUTE 36 | 37 | const seconds = +(remaining / SECOND).toFixed(minutes ? 0 : 1) 38 | 39 | return [ 40 | days && `${days} ${n('day', days)}`, 41 | hours && `${hours} ${n('hour', hours)}`, 42 | minutes && `${minutes} ${n('minute', minutes)}`, 43 | seconds && `${seconds} ${n('second', seconds)}` 44 | ] 45 | .filter(Boolean) 46 | .join(', ') 47 | } 48 | 49 | export function upperCaseFirst(str: string): string { 50 | return str.charAt(0).toUpperCase() + str.slice(1) 51 | } 52 | 53 | export function n(str: string, count: number): string { 54 | return count === 1 ? str : `${str}s` 55 | } 56 | -------------------------------------------------------------------------------- /src/fs.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises' 2 | 3 | export async function fileExists(filename: string): Promise { 4 | try { 5 | await fs.access(filename, fs.constants.F_OK) 6 | return true 7 | } catch (e) { 8 | return false 9 | } 10 | } 11 | 12 | export async function readFile(path: string): Promise { 13 | return await fs.readFile(path, { encoding: 'utf8' }) 14 | } 15 | -------------------------------------------------------------------------------- /src/github.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '@actions/github' 2 | 3 | // eslint-disable-next-line import/no-unresolved 4 | import { components } from '@octokit/openapi-types' 5 | 6 | type IssueComment = components['schemas']['issue-comment'] 7 | type PullRequestReview = components['schemas']['pull-request-review'] 8 | type Octokit = ReturnType 9 | 10 | export async function getPullRequestInfo( 11 | octokit: Octokit, 12 | params: { owner: string; repo: string; pull_number: number } 13 | ): Promise<{ ref: string; sha: string }> { 14 | const { data: pr } = await octokit.rest.pulls.get(params) 15 | const { ref } = pr.base 16 | const { sha } = pr.head 17 | return { ref, sha } 18 | } 19 | 20 | export async function getIssueComments( 21 | octokit: Octokit, 22 | params: { owner: string; repo: string; issue_number: number } 23 | ): Promise { 24 | const { data: comments } = await octokit.rest.issues.listComments(params) 25 | return comments 26 | } 27 | 28 | export async function createIssueComment( 29 | octokit: Octokit, 30 | params: { owner: string; repo: string; issue_number: number; body: string } 31 | ): Promise { 32 | const { data: comment } = await octokit.rest.issues.createComment(params) 33 | return comment 34 | } 35 | 36 | export async function updateIssueComment( 37 | octokit: Octokit, 38 | params: { owner: string; repo: string; comment_id: number; body: string } 39 | ): Promise { 40 | const { data: comment } = await octokit.rest.issues.updateComment(params) 41 | return comment 42 | } 43 | 44 | export async function createPullRequestReview( 45 | octokit: Octokit, 46 | params: { owner: string; repo: string; pull_number: number; body: string } 47 | ): Promise { 48 | const { data: review } = await octokit.rest.pulls.createReview({ ...params, event: 'COMMENT' }) 49 | return review 50 | } 51 | -------------------------------------------------------------------------------- /src/icons.ts: -------------------------------------------------------------------------------- 1 | type IconSet = Record 2 | type IconColors = Record 3 | 4 | const iconSize = 14 5 | const defaultIconStyle = 'octicons' 6 | 7 | export const icons: Record = { 8 | octicons: { 9 | failed: 'stop', 10 | passed: 'check-circle', 11 | flaky: 'alert', 12 | skipped: 'skip', 13 | stats: 'pulse', 14 | duration: 'clock', 15 | link: 'link-external', 16 | report: 'package', 17 | commit: 'git-pull-request', 18 | info: 'info' 19 | }, 20 | emojis: { 21 | failed: '❌', 22 | passed: '✅', 23 | flaky: '⚠️', 24 | skipped: '⏭️', 25 | stats: '', 26 | duration: '', 27 | link: '', 28 | report: '', 29 | commit: '', 30 | info: 'ℹ️' 31 | } 32 | } 33 | 34 | const iconColors: IconColors = { 35 | failed: 'da3633', 36 | passed: '3fb950', 37 | flaky: 'd29922', 38 | skipped: '0967d9', 39 | icon: 'abb4bf' 40 | } 41 | 42 | export function renderIcon( 43 | status: string, 44 | { iconStyle = defaultIconStyle }: { iconStyle?: keyof typeof icons } = {} 45 | ): string { 46 | if (iconStyle === 'emojis') { 47 | return icons.emojis[status] || '' 48 | } else { 49 | const color = iconColors[status] || iconColors.icon 50 | return createOcticonUrl(icons.octicons[status], { label: status, color }) 51 | } 52 | } 53 | 54 | function createOcticonUrl(icon: string, { label = 'icon', color = iconColors.icon, size = iconSize } = {}): string { 55 | if (icon) { 56 | return `![${label}](https://icongr.am/octicons/${icon}.svg?size=${size}&color=${color})` 57 | } else { 58 | return '' 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { 3 | getInput, 4 | getBooleanInput, 5 | setOutput, 6 | setFailed, 7 | startGroup, 8 | endGroup, 9 | debug, 10 | warning, 11 | summary as setSummary 12 | } from '@actions/core' 13 | import { context, getOctokit } from '@actions/github' 14 | import { fileExists, readFile } from './fs' 15 | import { parseReport, renderReportSummary, getCommitUrl } from './report' 16 | import { 17 | getIssueComments, 18 | createIssueComment, 19 | updateIssueComment, 20 | createPullRequestReview, 21 | getPullRequestInfo 22 | } from './github' 23 | 24 | /** 25 | * The main function for the action. 26 | */ 27 | export async function run(): Promise { 28 | try { 29 | await report() 30 | } catch (error) { 31 | if (error instanceof Error) { 32 | setFailed(error.message) 33 | } 34 | } 35 | } 36 | 37 | /** 38 | * Parse the Playwright report and post a comment on the PR. 39 | */ 40 | export async function report(): Promise { 41 | const cwd = process.cwd() 42 | 43 | const { 44 | workflow, 45 | eventName, 46 | repo: { owner, repo }, 47 | payload 48 | } = context 49 | const { number: issueNumber } = context.issue || {} 50 | 51 | const token = getInput('github-token') 52 | const reportFile = getInput('report-file', { required: true }) 53 | const reportUrl = getInput('report-url') 54 | const reportTag = getInput('report-tag') || workflow 55 | const commentTitle = getInput('comment-title') || 'Playwright test results' 56 | const customInfo = getInput('custom-info') 57 | const iconStyle = getInput('icon-style') || 'octicons' 58 | const createComment = getInput('create-comment') ? getBooleanInput('create-comment') : true 59 | const createJobSummary = getInput('job-summary') ? getBooleanInput('job-summary') : false 60 | const testCommand = getInput('test-command') 61 | const footer = getInput('footer') 62 | 63 | debug(`Report file: ${reportFile}`) 64 | debug(`Report url: ${reportUrl || '(none)'}`) 65 | debug(`Report tag: ${reportTag || '(none)'}`) 66 | debug(`Comment title: ${commentTitle}`) 67 | debug(`Creating comment? ${createComment ? 'yes' : 'no'}`) 68 | debug(`Creating job summary? ${createJobSummary ? 'yes' : 'no'}`) 69 | 70 | let ref: string = context.ref 71 | let sha: string = context.sha 72 | let pr: number | null = null 73 | let commitUrl: string | undefined 74 | 75 | const octokit = getOctokit(token) 76 | 77 | switch (eventName) { 78 | case 'push': 79 | ref = payload.ref 80 | sha = payload.after 81 | commitUrl = getCommitUrl(payload.repository?.html_url, sha) 82 | console.log(`Commit pushed onto ${ref} (${sha})`) 83 | break 84 | 85 | case 'pull_request': 86 | case 'pull_request_target': 87 | ref = payload.pull_request?.base?.ref 88 | sha = payload.pull_request?.head?.sha 89 | pr = issueNumber 90 | commitUrl = getCommitUrl(payload.repository?.html_url, sha) 91 | console.log(`PR #${pr} targeting ${ref} (${sha})`) 92 | break 93 | 94 | case 'issue_comment': 95 | if (payload.issue?.pull_request) { 96 | pr = issueNumber 97 | ;({ ref, sha } = await getPullRequestInfo(octokit, { owner, repo, pull_number: pr })) 98 | console.log(`Comment on PR #${pr} targeting ${ref} (${sha})`) 99 | } else { 100 | console.log(`Comment on issue #${issueNumber}`) 101 | } 102 | break 103 | 104 | case 'workflow_dispatch': 105 | console.log(`Workflow dispatched on ${ref} (${sha})`) 106 | break 107 | 108 | default: 109 | console.warn(`Unsupported event type: ${eventName}`) 110 | break 111 | } 112 | 113 | const reportPath = path.resolve(cwd, reportFile) 114 | const reportExists = await fileExists(reportPath) 115 | if (!reportExists) { 116 | debug(`Failed to find report file at path ${reportPath}`) 117 | throw new Error( 118 | `Report file ${reportFile} not found. Make sure Playwright is configured to generate a JSON report.` 119 | ) 120 | } 121 | 122 | const data = await readFile(reportPath) 123 | const report = parseReport(data) 124 | const summary = renderReportSummary(report, { 125 | commit: sha, 126 | commitUrl, 127 | title: commentTitle, 128 | customInfo, 129 | reportUrl, 130 | iconStyle, 131 | testCommand, 132 | footer 133 | }) 134 | 135 | let commentId = null 136 | 137 | if (createComment) { 138 | const prefix = `` 139 | const body = `${prefix}\n\n${summary}` 140 | 141 | if (!pr) { 142 | console.log('No PR associated with this action run. Not posting a check or comment.') 143 | } else { 144 | startGroup(`Commenting test report on PR`) 145 | try { 146 | const comments = await getIssueComments(octokit, { owner, repo, issue_number: pr }) 147 | const existingComment = comments.findLast((c) => c.body?.includes(prefix)) 148 | commentId = existingComment?.id || null 149 | } catch (error: unknown) { 150 | console.error(`Error fetching existing comments: ${(error as Error).message}`) 151 | } 152 | 153 | if (commentId) { 154 | console.log(`Found previous comment #${commentId}`) 155 | try { 156 | await updateIssueComment(octokit, { owner, repo, comment_id: commentId, body }) 157 | console.log(`Updated previous comment #${commentId}`) 158 | } catch (error: unknown) { 159 | console.error(`Error updating previous comment: ${(error as Error).message}`) 160 | commentId = null 161 | } 162 | } 163 | 164 | if (!commentId) { 165 | console.log('Creating new comment') 166 | try { 167 | const newComment = await createIssueComment(octokit, { owner, repo, issue_number: pr, body }) 168 | commentId = newComment.id 169 | console.log(`Created new comment #${commentId}`) 170 | } catch (error: unknown) { 171 | console.error(`Error creating comment: ${(error as Error).message}`) 172 | console.log(`Submitting PR review comment instead...`) 173 | try { 174 | const { issue } = context 175 | const review = await createPullRequestReview(octokit, { 176 | owner, 177 | repo: issue.repo, 178 | pull_number: issue.number, 179 | body 180 | }) 181 | console.log(`Created pull request review: #${review.id}`) 182 | } catch (error: unknown) { 183 | console.error(`Error creating PR review: ${(error as Error).message}`) 184 | } 185 | } 186 | } 187 | endGroup() 188 | } 189 | 190 | if (!commentId && pr) { 191 | const intro = `Unable to comment on your PR — this can happen for PR's originating from a fork without write permissions. You can copy the test results directly into a comment using the markdown summary below:` 192 | warning(`${intro}\n\n${body}`, { title: 'Unable to comment on PR' }) 193 | } 194 | } 195 | 196 | if (createJobSummary) { 197 | setSummary.addRaw(summary).write() 198 | } 199 | 200 | setOutput('summary', summary) 201 | setOutput('comment-id', commentId) 202 | setOutput('report-data', JSON.stringify(report)) 203 | } 204 | 205 | if (process.env.GITHUB_ACTIONS === 'true') { 206 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 207 | run() 208 | } 209 | -------------------------------------------------------------------------------- /src/report.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JSONReport, 3 | JSONReportSpec, 4 | JSONReportSuite, 5 | JSONReportTest, 6 | JSONReportTestResult 7 | } from '@playwright/test/reporter' 8 | import { debug } from '@actions/core' 9 | 10 | import { formatDuration, n, renderAccordion, renderCodeBlock, upperCaseFirst } from './formatting' 11 | import { icons, renderIcon } from './icons' 12 | 13 | export interface ReportSummary { 14 | version: string 15 | started: Date 16 | duration: number 17 | workers: number 18 | shards: number 19 | projects: string[] 20 | files: string[] 21 | suites: SuiteSummary[] 22 | specs: SpecSummary[] 23 | tests: TestSummary[] 24 | failed: TestSummary[] 25 | passed: TestSummary[] 26 | flaky: TestSummary[] 27 | skipped: TestSummary[] 28 | results: TestResultSummary[] 29 | } 30 | 31 | interface SuiteSummary { 32 | file: string 33 | line: number 34 | column: number 35 | path: string[] 36 | title: string 37 | level: number 38 | root: boolean 39 | specs: SpecSummary[] 40 | } 41 | 42 | interface SpecSummary { 43 | ok: boolean 44 | file: string 45 | line: number 46 | column: number 47 | path: string[] 48 | title: string 49 | tests: TestSummary[] 50 | } 51 | 52 | interface TestSummary { 53 | passed: boolean 54 | failed: boolean 55 | flaky: boolean 56 | skipped: boolean 57 | file: string 58 | line: number 59 | column: number 60 | path: string[] 61 | title: string 62 | results: TestResultSummary[] 63 | } 64 | 65 | interface TestResultSummary { 66 | duration: number 67 | started: Date 68 | } 69 | 70 | interface ReportRenderOptions { 71 | commit?: string 72 | commitUrl?: string 73 | message?: string 74 | title?: string 75 | customInfo?: string 76 | reportUrl?: string 77 | iconStyle?: keyof typeof icons 78 | testCommand?: string 79 | footer?: string 80 | } 81 | 82 | export function isValidReport(report: unknown): report is JSONReport { 83 | return report !== null && typeof report === 'object' && 'config' in report && 'errors' in report && 'suites' in report 84 | } 85 | 86 | export function makeReport(data: string): JSONReport { 87 | const report: JSONReport = JSON.parse(data) 88 | if (isValidReport(report)) { 89 | return report 90 | } else { 91 | debug('Invalid report file') 92 | debug(data) 93 | throw new Error('Invalid JSON report file') 94 | } 95 | } 96 | 97 | export function parseReport(data: string): ReportSummary { 98 | const report = makeReport(data) 99 | const files = parseReportFiles(report) 100 | const allSuites = parseReportSuites(report) 101 | const suites = allSuites.filter((suite) => suite.root) 102 | const specs = allSuites.flatMap((suite) => suite.specs) 103 | 104 | const tests = specs.flatMap((spec) => spec.tests) 105 | const results = tests.flatMap((test) => test.results) 106 | const failed = tests.filter((test) => test.failed) 107 | const passed = tests.filter((test) => test.passed) 108 | const flaky = tests.filter((test) => test.flaky) 109 | const skipped = tests.filter((test) => test.skipped) 110 | 111 | const { duration, started } = getTotalDuration(report, results) 112 | const version: string = report.config.version 113 | const workers: number = report.config.metadata.actualWorkers || report.config.workers || 1 114 | const shards: number = report.config.shard?.total || 0 115 | const projects: string[] = report.config.projects.map((p) => p.name) 116 | 117 | return { 118 | version, 119 | started, 120 | duration, 121 | workers, 122 | shards, 123 | projects, 124 | files, 125 | suites, 126 | specs, 127 | tests, 128 | results, 129 | failed, 130 | passed, 131 | flaky, 132 | skipped 133 | } 134 | } 135 | 136 | export function parseReportFiles({ suites }: JSONReport): string[] { 137 | return suites.map((suite) => suite.file) 138 | } 139 | 140 | export function parseReportSuites({ suites }: JSONReport): SuiteSummary[] { 141 | return suites.map((suite) => parseSuite(suite)) 142 | } 143 | 144 | function parseSuite(suite: JSONReportSuite, parents: string[] = []): SuiteSummary { 145 | const { file, line, column } = suite 146 | const { title, path } = buildTitle(...parents, suite.title) 147 | const level = parents.length 148 | const root = level === 0 149 | 150 | const directSpecs = (suite.specs ?? []).map((spec) => parseSpec(spec, [...parents, suite.title])) 151 | const nestedSuites = (suite.suites ?? []).map((child) => parseSuite(child, [...parents, suite.title])) 152 | const nestedSpecs = nestedSuites.flatMap((suite) => suite.specs) 153 | const specs = [...nestedSpecs, ...directSpecs] 154 | 155 | return { file, line, column, path, title, level, root, specs } 156 | } 157 | 158 | function parseSpec(spec: JSONReportSpec, parents: string[] = []): SpecSummary { 159 | const { ok, file, line, column } = spec 160 | const { title, path } = buildTitle(...parents, spec.title) 161 | const tests = spec.tests.map((test) => parseTest(test, spec, parents)) 162 | return { ok, file, line, column, path, title, tests } 163 | } 164 | 165 | function parseTest(test: JSONReportTest, spec: JSONReportSpec, parents: string[] = []): TestSummary { 166 | const { file, line, column } = spec 167 | const { status, projectName: project } = test 168 | const { title, path } = buildTitle(project, ...parents, spec.title) 169 | const results = test.results.map((result) => parseTestResult(result)) 170 | const passed = status === 'expected' 171 | const failed = status === 'unexpected' 172 | const skipped = status === 'skipped' 173 | const flaky = status === 'flaky' 174 | return { passed, failed, flaky, skipped, results, title, path, file, line, column } 175 | } 176 | 177 | function parseTestResult({ duration, startTime }: JSONReportTestResult): TestResultSummary { 178 | return { duration, started: new Date(startTime) } 179 | } 180 | 181 | export function buildTitle(...paths: string[]): { title: string; path: string[] } { 182 | const path = paths.filter(Boolean) 183 | const title = path.join(' › ') 184 | return { title, path } 185 | } 186 | 187 | export function renderReportSummary( 188 | report: ReportSummary, 189 | { commit, commitUrl, message, title, customInfo, reportUrl, iconStyle, testCommand, footer }: ReportRenderOptions = {} 190 | ): string { 191 | const { duration, failed, passed, flaky, skipped } = report 192 | const icon = (symbol: string): string => renderIcon(symbol, { iconStyle }) 193 | const paragraphs = [] 194 | 195 | // Title 196 | 197 | paragraphs.push(`### ${title}`) 198 | 199 | // Passed/failed tests 200 | 201 | const tests = [ 202 | failed.length ? `${icon('failed')}  **${failed.length} failed**` : ``, 203 | passed.length ? `${icon('passed')}  **${passed.length} passed** ` : ``, 204 | flaky.length ? `${icon('flaky')}  **${flaky.length} flaky** ` : ``, 205 | skipped.length ? `${icon('skipped')}  **${skipped.length} skipped**` : `` 206 | ] 207 | paragraphs.push(tests.filter(Boolean).join(' \n')) 208 | 209 | // Stats about test run 210 | 211 | paragraphs.push(`#### Details`) 212 | 213 | const shortCommit = commit?.slice(0, 7) 214 | const commitText = commitUrl ? `[${shortCommit}](${commitUrl})` : shortCommit 215 | 216 | const stats = [ 217 | reportUrl ? `${icon('report')}  [Open report ↗︎](${reportUrl})` : '', 218 | `${icon('stats')}  ${report.tests.length} ${n('test', report.tests.length)} across ${report.suites.length} ${n( 219 | 'suite', 220 | report.suites.length 221 | )}`, 222 | `${icon('duration')}  ${duration ? formatDuration(duration) : 'unknown'}`, 223 | commitText && message ? `${icon('commit')}  ${message} (${commitText})` : '', 224 | commitText && !message ? `${icon('commit')}  ${commitText}` : '', 225 | customInfo ? `${icon('info')}  ${customInfo}` : '' 226 | ] 227 | paragraphs.push(stats.filter(Boolean).join(' \n')) 228 | 229 | // Lists of failed/skipped tests 230 | 231 | const listStatuses = ['failed', 'flaky', 'skipped'] as const 232 | const details = listStatuses.map((status) => { 233 | const tests = report[status] 234 | if (tests.length) { 235 | const summary = `${upperCaseFirst(status)} tests` 236 | const content = renderTestList(tests, status !== 'skipped' ? testCommand : undefined) 237 | const open = status === 'failed' 238 | return renderAccordion(summary, content, { open }) 239 | } 240 | }) 241 | paragraphs.push( 242 | details 243 | .filter(Boolean) 244 | .map((md) => (md as string).trim()) 245 | .join('\n') 246 | ) 247 | 248 | if (footer) { 249 | paragraphs.push(footer) 250 | } 251 | 252 | return paragraphs 253 | .map((p) => p.trim()) 254 | .filter(Boolean) 255 | .join('\n\n') 256 | } 257 | 258 | function renderTestList(tests: TestSummary[], testCommand: string | undefined): string { 259 | const list = tests.map((test) => ` ${test.title}`).join('\n') 260 | if (!testCommand) { 261 | return list 262 | } 263 | 264 | const testIds = tests.map((test) => `${test.file}:${test.line}`).join(' ') 265 | const command = `${testCommand} ${testIds}` 266 | 267 | return `${list}\n\n${renderCodeBlock(command)}` 268 | } 269 | 270 | function getTotalDuration(report: JSONReport, results: TestResultSummary[]): { duration: number; started: Date } { 271 | let duration = 0 272 | let started = new Date() 273 | const { totalTime } = report.config.metadata 274 | if (totalTime) { 275 | duration = totalTime 276 | } else { 277 | const sorted = results.sort((a, b) => a.started.getTime() - b.started.getTime()) 278 | const first = sorted[0] 279 | const last = sorted[sorted.length - 1] 280 | if (first && last) { 281 | started = first.started 282 | duration = last.started.getTime() + last.duration - first.started.getTime() 283 | } 284 | } 285 | return { duration, started } 286 | } 287 | 288 | export function getCommitUrl(repoUrl: string | undefined, sha: string): string | undefined { 289 | return repoUrl ? `${repoUrl}/commit/${sha}` : undefined 290 | } 291 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "module": "nodenext", 6 | "rootDir": "./src", 7 | "moduleResolution": "nodenext", 8 | "baseUrl": "./", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "noImplicitAny": true, 12 | "esModuleInterop": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "strict": true, 15 | "skipLibCheck": true, 16 | "newLine": "lf" 17 | }, 18 | "exclude": ["./dist", "./node_modules", "./__tests__", "./coverage"] 19 | } 20 | --------------------------------------------------------------------------------