├── .nvmrc ├── .husky ├── pre-commit └── commit-msg ├── .commitlintrc ├── docs ├── summary.png └── annotation.png ├── .prettierrc ├── .editorconfig ├── .gitignore ├── test ├── resources │ ├── sample-tests │ │ ├── broken.test.js │ │ ├── skip.test.js │ │ └── nested.test.js │ └── expected.md ├── e2e │ └── compare.sh └── package-tests │ └── path.test.js ├── .eslintrc ├── .github ├── dependabot.yml └── workflows │ ├── notify-release.yml │ ├── check-linked-issues.yml │ ├── ci.yml │ └── release.yml ├── README.md ├── LICENSE ├── package.json └── index.js /.nvmrc: -------------------------------------------------------------------------------- 1 | 19 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.commitlintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /docs/summary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nearform/node-test-github-reporter/HEAD/docs/summary.png -------------------------------------------------------------------------------- /docs/annotation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nearform/node-test-github-reporter/HEAD/docs/annotation.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "arrowParens": "avoid", 5 | "trailingComma": "none" 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | insert_final_newline = true 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .eslintcache 3 | 4 | # JetBrains IDEs 5 | .idea 6 | # Visual Studio Code 7 | .vscode 8 | 9 | # OS X 10 | .DS_Store 11 | -------------------------------------------------------------------------------- /test/resources/sample-tests/broken.test.js: -------------------------------------------------------------------------------- 1 | import { it } from 'node:test' 2 | 3 | it('calls a nonexistent method', () => { 4 | // eslint-disable-next-line no-undef 5 | nonexistentMethod() 6 | }) 7 | -------------------------------------------------------------------------------- /test/resources/sample-tests/skip.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | 4 | describe('behavior', () => { 5 | it.skip('skipped test', () => { 6 | assert.strictEqual(1, 2) 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "plugin:prettier/recommended"], 3 | "env": { 4 | "node": true, 5 | "es2021": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 2021, 9 | "sourceType": "module" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: github-actions 8 | directory: / 9 | schedule: 10 | interval: weekly 11 | -------------------------------------------------------------------------------- /test/resources/sample-tests/nested.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { describe, it } from 'node:test' 3 | 4 | describe('module', () => { 5 | describe('function', () => { 6 | describe('behavior', () => { 7 | it('asserts 1 === 1', () => { 8 | assert.strictEqual(1, 1) 9 | }) 10 | 11 | it('fails', () => { 12 | assert.strictEqual(1, 2) 13 | }) 14 | }) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /.github/workflows/notify-release.yml: -------------------------------------------------------------------------------- 1 | name: Notify release 2 | 'on': 3 | workflow_dispatch: 4 | schedule: 5 | - cron: 30 8 * * * 6 | release: 7 | types: 8 | - published 9 | issues: 10 | types: 11 | - closed 12 | jobs: 13 | setup: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: nearform-actions/github-action-notify-release@v1 17 | permissions: 18 | issues: write 19 | contents: read 20 | -------------------------------------------------------------------------------- /.github/workflows/check-linked-issues.yml: -------------------------------------------------------------------------------- 1 | name: Check linked issues 2 | 'on': 3 | pull_request_target: 4 | types: 5 | - opened 6 | - edited 7 | - reopened 8 | - synchronize 9 | jobs: 10 | check_pull_requests: 11 | runs-on: ubuntu-latest 12 | name: Check linked issues 13 | steps: 14 | - uses: nearform-actions/github-action-check-linked-issues@v1 15 | id: check-linked-issues 16 | with: 17 | exclude-branches: release/**, dependabot/** 18 | permissions: 19 | issues: read 20 | pull-requests: write 21 | -------------------------------------------------------------------------------- /test/e2e/compare.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Function to replace test duration with 0 5 | remove_variables() { 6 | echo "$1" | sed -E 's/[0-9]+ms/0ms/g' 7 | } 8 | 9 | # Create temporary file to hold test summary 10 | export GITHUB_STEP_SUMMARY=$(mktemp) 11 | 12 | # Run sample tests and generate the report, ignoring errors 13 | node --test --test-reporter ./index.js ./test/resources/sample-tests || true 14 | 15 | # Compare with expected results 16 | report=$(cat $GITHUB_STEP_SUMMARY) 17 | expected=$(cat ./test/resources/expected.md) 18 | diff <(remove_variables "$report") <(remove_variables "$expected") 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | name: Lint and test 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v6 15 | - uses: actions/setup-node@v6 16 | with: 17 | node-version-file: '.nvmrc' 18 | - run: | 19 | npm ci 20 | npm run lint 21 | npm test 22 | 23 | automerge: 24 | name: Merge dependabot's PRs 25 | needs: test 26 | runs-on: ubuntu-latest 27 | permissions: 28 | pull-requests: write 29 | contents: write 30 | steps: 31 | - uses: fastify/github-action-merge-dependabot@v3 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | semver: 6 | description: The semver to use 7 | required: true 8 | default: patch 9 | type: choice 10 | options: 11 | - patch 12 | - minor 13 | - major 14 | pull_request: 15 | types: [closed] 16 | 17 | jobs: 18 | release: 19 | runs-on: ubuntu-latest 20 | environment: production 21 | permissions: 22 | contents: write 23 | issues: write 24 | pull-requests: write 25 | id-token: write 26 | steps: 27 | - uses: nearform-actions/optic-release-automation-action@v4 28 | with: 29 | semver: ${{ github.event.inputs.semver }} 30 | commit-message: 'chore: release {version}' 31 | publish-mode: oidc 32 | build-command: npm ci 33 | -------------------------------------------------------------------------------- /test/package-tests/path.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { describe, it } from 'node:test' 3 | import { getRelativeFilePath, getSafePath } from '../../index.js' 4 | 5 | describe('File Path', () => { 6 | it('should prefix file:// to file path', () => { 7 | const expected = 'file:///path/to/file.js' 8 | const actual = getSafePath('/path/to/file.js') 9 | assert.equal(expected, actual) 10 | }) 11 | 12 | it('should not modify correctly constructed path', () => { 13 | const expected = 'file:///path/to/file.js' 14 | const actual = getSafePath('file:///path/to/file.js') 15 | assert.equal(expected, actual) 16 | }) 17 | 18 | it('should not error with file path without file:// prefix', () => { 19 | const expected = 'path/to/file.js' 20 | const actualUrl = getRelativeFilePath('/path/to/file.js') 21 | assert.equal(actualUrl, expected) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-test-github-reporter 2 | 3 | ![CI](https://github.com/nearform/node-test-github-reporter/actions/workflows/ci.yml/badge.svg?event=push) 4 | 5 | A GitHub test reporter for the Node.js test runner 6 | 7 | ![Summary](docs/summary.png) 8 | 9 | ![Annotation](docs/annotation.png) 10 | 11 | ## Installation 12 | 13 | ```shell 14 | npm i -D node-test-github-reporter 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```shell 20 | node --test --test-reporter node-test-github-reporter 21 | ``` 22 | 23 | You can use it in conjunction with another test report to also get the output in the logs: 24 | 25 | ```shell 26 | node --test --test-reporter spec --test-reporter-destination stdout --test-reporter node-test-github-reporter --test-reporter-destination stdout 27 | ``` 28 | 29 | [![banner](https://raw.githubusercontent.com/nearform/.github/refs/heads/master/assets/os-banner-green.svg)](https://www.nearform.com/contact/?utm_source=open-source&utm_medium=banner&utm_campaign=os-project-pages) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 NearForm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-test-github-reporter", 3 | "version": "1.3.1", 4 | "description": "A GitHub test reporter for the Node.js test runner", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "lint": "eslint .", 9 | "test": "npm run test:compare && npm run test:package", 10 | "test:compare": "./test/e2e/compare.sh", 11 | "test:package": "node --test ./test/package-tests/**.test.js", 12 | "prepare": "husky" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/nearform/node-test-github-reporter.git" 17 | }, 18 | "keywords": [ 19 | "test", 20 | "reporter", 21 | "github" 22 | ], 23 | "author": { 24 | "name": "Romulo Vitoi", 25 | "email": "romulo.vitoi@nearform.com" 26 | }, 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/nearform/node-test-github-reporter/issues" 30 | }, 31 | "homepage": "https://github.com/nearform/node-test-github-reporter#readme", 32 | "dependencies": { 33 | "@actions/core": "^2.0.1", 34 | "error-stack-parser": "^2.1.4", 35 | "node-test-parser": "^3.0.0", 36 | "stack-utils": "^2.0.6" 37 | }, 38 | "devDependencies": { 39 | "@commitlint/cli": "^20.0.0", 40 | "@commitlint/config-conventional": "^20.0.0", 41 | "eslint": "^8.35.0", 42 | "eslint-config-prettier": "^10.0.1", 43 | "eslint-plugin-prettier": "5.5.4", 44 | "husky": "^9.0.11", 45 | "lint-staged": "^16.1.0", 46 | "prettier": "^3.0.3" 47 | }, 48 | "lint-staged": { 49 | "*.{js,jsx}": "eslint --cache --fix" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/resources/expected.md: -------------------------------------------------------------------------------- 1 |

Node.js Test Results

2 |
PassedFailedSkippedDuration
121161ms
3 |

Details

4 |
5 | :x: calls a nonexistent method 6 |
7 | 8 | 9 | ``` 10 | nonexistentMethod is not defined 11 | ``` 12 | 13 | Stack: 14 | ``` 15 | TestContext. (file://test/resources/sample-tests/broken.test.js:5:3) 16 | ``` 17 | 18 |
19 |
20 |
21 | :x: module 22 |
23 |
24 | :x: function 25 |
26 |
27 | :x: behavior 28 |
29 |
30 | :white_check_mark: asserts 1 === 1 31 |
32 | Test passed 33 |
34 |
35 |
36 | :x: fails 37 |
38 | 39 | 40 | ``` 41 | Expected values to be strictly equal: 42 | 43 | 1 !== 2 44 | 45 | ``` 46 | 47 | Stack: 48 | ``` 49 | 1 !== 2 50 | TestContext. (file://test/resources/sample-tests/nested.test.js:12:16) 51 | async Promise.all (index 0) 52 | async Promise.all (index 0) 53 | ``` 54 | 55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | :white_check_mark: behavior 65 |
66 |
67 | :leftwards_arrow_with_hook: skipped test 68 |
69 | Test skipped 70 |
71 |
72 |
73 |
74 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { summary, error as annotateError } from '@actions/core' 2 | import ErrorStackParser from 'error-stack-parser' 3 | import parseReport from 'node-test-parser' 4 | import url from 'url' 5 | import StackUtils from 'stack-utils' 6 | 7 | const workspace = process.env.GITHUB_WORKSPACE 8 | const workspacePrefixRegex = new RegExp(`^${workspace}`) 9 | 10 | const stackUtils = new StackUtils({ 11 | cwd: process.cwd(), 12 | internals: StackUtils.nodeInternals() 13 | }) 14 | 15 | export default async function* githubSummaryReporter(source) { 16 | const report = await parseReport(source) 17 | const tests = report.tests 18 | 19 | const testCounters = { 20 | passed: 0, 21 | failed: 0, 22 | skipped: 0 23 | } 24 | 25 | const tableHeader = [ 26 | { data: 'Passed', header: true }, 27 | { data: 'Failed', header: true }, 28 | { data: 'Skipped', header: true }, 29 | { data: 'Duration', header: true } 30 | ] 31 | 32 | const reportDetails = testDetails(testCounters, { tests: tests }) 33 | 34 | const tableRow = [ 35 | `${testCounters.passed}`, 36 | `${testCounters.failed}`, 37 | `${testCounters.skipped}`, 38 | `${parseInt(report.duration)}ms` 39 | ] 40 | 41 | summary 42 | .addHeading('Node.js Test Results', 2) 43 | .addTable([tableHeader, tableRow]) 44 | .addHeading('Details', 3) 45 | .addRaw(reportDetails) 46 | .write() 47 | 48 | yield '' 49 | } 50 | 51 | function testDetails(testCounters, test) { 52 | if (!test.tests.length) { 53 | return formatMessage(testCounters, test) 54 | } 55 | 56 | return test.tests 57 | .map(test => 58 | formatDetails( 59 | `${statusEmoji(test)} ${test.name}`, 60 | testDetails(testCounters, test) 61 | ) 62 | ) 63 | .join('\n') 64 | } 65 | 66 | function formatMessage(testCounters, test) { 67 | if (test.skip) { 68 | testCounters.skipped++ 69 | return 'Test skipped' 70 | } 71 | 72 | const error = test.error || test.failure 73 | if (!error) { 74 | testCounters.passed++ 75 | return 'Test passed' 76 | } 77 | testCounters.failed++ 78 | 79 | let errorMessage = '\n\n```\n' + error.message + '\n```' 80 | 81 | if (test.diagnostic) { 82 | errorMessage += `\n\n${test.diagnostic}` 83 | } 84 | 85 | if (error.cause && error.cause.stack) { 86 | const cleanStack = stackUtils.clean(error.cause.stack) 87 | errorMessage += `\n\nStack:\n\`\`\`\n${cleanStack}\`\`\`\n` 88 | 89 | const errorLocation = findErrorLocation(error.cause) 90 | if (errorLocation) { 91 | annotateError(error, errorLocation) 92 | } 93 | } 94 | 95 | return errorMessage 96 | } 97 | 98 | function formatDetails(heading, content) { 99 | return `
100 | ${heading} 101 |
102 | ${content} 103 |
104 |
` 105 | } 106 | 107 | function statusEmoji(test) { 108 | if (test.failure || test.error) { 109 | return ':x:' 110 | } else if (test.skip) { 111 | return ':leftwards_arrow_with_hook:' 112 | } else { 113 | return ':white_check_mark:' 114 | } 115 | } 116 | 117 | function findErrorLocation(error) { 118 | const [firstFrame] = ErrorStackParser.parse(error) 119 | if (!firstFrame) { 120 | return 121 | } 122 | 123 | return { 124 | file: getRelativeFilePath(firstFrame.fileName), 125 | startLine: firstFrame.lineNumber, 126 | startColumn: firstFrame.columnNumber 127 | } 128 | } 129 | 130 | export function getSafePath(path) { 131 | if (path.startsWith('file')) { 132 | return path 133 | } 134 | return url.pathToFileURL(path).href 135 | } 136 | 137 | export function getRelativeFilePath(path) { 138 | const filePath = getSafePath(path) 139 | return new URL(filePath).pathname 140 | .replace(workspacePrefixRegex, '') 141 | .replace(/^\//, '') 142 | } 143 | --------------------------------------------------------------------------------