From 1a3cfe6b4814570b5c1e9c2b900eeb66ad5f1408 Mon Sep 17 00:00:00 2001 From: oscar mampel Date: Thu, 14 Mar 2024 00:40:50 +0100 Subject: [PATCH] Add support for rspec --- README.md | 1 + __tests__/__outputs__/rspec-json.md | 16 +++ .../__snapshots__/rspec-json.test.ts.snap | 49 ++++++++ __tests__/fixtures/empty/rspec-json.json | 17 +++ __tests__/fixtures/rspec-json.json | 53 ++++++++ __tests__/rspec-json.test.ts | 45 +++++++ action.yml | 1 + dist/index.js | 118 ++++++++++++++++++ src/main.ts | 3 + src/parsers/rspec-json/rspec-json-parser.ts | 113 +++++++++++++++++ src/parsers/rspec-json/rspec-json-types.ts | 34 +++++ 11 files changed, 450 insertions(+) create mode 100644 __tests__/__outputs__/rspec-json.md create mode 100644 __tests__/__snapshots__/rspec-json.test.ts.snap create mode 100644 __tests__/fixtures/empty/rspec-json.json create mode 100644 __tests__/fixtures/rspec-json.json create mode 100644 __tests__/rspec-json.test.ts create mode 100644 src/parsers/rspec-json/rspec-json-parser.ts create mode 100644 src/parsers/rspec-json/rspec-json-types.ts diff --git a/README.md b/README.md index f4be6e05..e98cc2d5 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ jobs: # java-junit # jest-junit # mocha-json + # rspec-json reporter: '' # Allows you to generate only the summary. diff --git a/__tests__/__outputs__/rspec-json.md b/__tests__/__outputs__/rspec-json.md new file mode 100644 index 00000000..04bae8a5 --- /dev/null +++ b/__tests__/__outputs__/rspec-json.md @@ -0,0 +1,16 @@ +![Tests failed](https://img.shields.io/badge/tests-1%20passed%2C%201%20failed%2C%201%20skipped-critical) +## ❌ fixtures/rspec-json.json +**3** tests were completed in **0ms** with **1** passed, **1** failed and **1** skipped. +|Test suite|Passed|Failed|Skipped|Time| +|:---|---:|---:|---:|---:| +|[./spec/config/check_env_vars_spec.rb](#r0s0)|1✅|1❌|1⚪|0ms| +### ❌ ./spec/config/check_env_vars_spec.rb +``` +CheckEnvVars#call when all env vars are defined behaves like success load + ❌ CheckEnvVars#call when all env vars are defined behaves like success load fails in assertion + (#ActiveSupport::BroadcastLogger:0x00007f1007fedf58).debug("All config env vars exist") + expected: 0 times with arguments: ("All config env vars exist") + received: 1 time with arguments: ("All config env vars exist") + ✅ CheckEnvVars#call when all env vars are defined behaves like success load logs success message + ⚪ CheckEnvVars#call when all env vars are defined behaves like success load skips the test +``` \ No newline at end of file diff --git a/__tests__/__snapshots__/rspec-json.test.ts.snap b/__tests__/__snapshots__/rspec-json.test.ts.snap new file mode 100644 index 00000000..cc14bfbb --- /dev/null +++ b/__tests__/__snapshots__/rspec-json.test.ts.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`rspec-json tests report from ./reports/rspec-json test results matches snapshot 1`] = ` +TestRunResult { + "path": "fixtures/rspec-json.json", + "suites": [ + TestSuiteResult { + "groups": [ + TestGroupResult { + "name": "CheckEnvVars#call when all env vars are defined behaves like success load", + "tests": [ + TestCaseResult { + "error": { + "details": "/usr/local/bundle/ruby/3.3.0/gems/net-http-0.4.1/lib/net/http.rb:1603:in \`initialize' +./config/check_env_vars.rb:11:in \`call' +./spec/config/check_env_vars_spec.rb:7:in \`block (3 levels) in ' +./spec/config/check_env_vars_spec.rb:19:in \`block (4 levels) in '", + "line": 11, + "message": "(#ActiveSupport::BroadcastLogger:0x00007f1007fedf58).debug("All config env vars exist") + expected: 0 times with arguments: ("All config env vars exist") + received: 1 time with arguments: ("All config env vars exist")", + "path": "./config/check_env_vars.rb", + }, + "name": "CheckEnvVars#call when all env vars are defined behaves like success load fails in assertion", + "result": "failed", + "time": 0.004411051, + }, + TestCaseResult { + "error": undefined, + "name": "CheckEnvVars#call when all env vars are defined behaves like success load logs success message", + "result": "success", + "time": 0.079159625, + }, + TestCaseResult { + "error": undefined, + "name": "CheckEnvVars#call when all env vars are defined behaves like success load skips the test", + "result": "skipped", + "time": 0.000023007, + }, + ], + }, + ], + "name": "./spec/config/check_env_vars_spec.rb", + "totalTime": undefined, + }, + ], + "totalTime": 0.19118387, +} +`; diff --git a/__tests__/fixtures/empty/rspec-json.json b/__tests__/fixtures/empty/rspec-json.json new file mode 100644 index 00000000..3629097c --- /dev/null +++ b/__tests__/fixtures/empty/rspec-json.json @@ -0,0 +1,17 @@ +{ + "version": "3.13.0", + "messages": [ + "No examples found." + ], + "examples": [ + + ], + "summary": { + "duration": 0.002514266, + "example_count": 0, + "failure_count": 0, + "pending_count": 0, + "errors_outside_of_examples_count": 0 + }, + "summary_line": "0 examples, 0 failures" +} diff --git a/__tests__/fixtures/rspec-json.json b/__tests__/fixtures/rspec-json.json new file mode 100644 index 00000000..34d4e49e --- /dev/null +++ b/__tests__/fixtures/rspec-json.json @@ -0,0 +1,53 @@ +{ + "version": "3.13.0", + "examples": [ + { + "id": "./spec/config/check_env_vars_spec.rb[1:1:1:1:1]", + "description": "logs success message", + "full_description": "CheckEnvVars#call when all env vars are defined behaves like success load logs success message", + "status": "passed", + "file_path": "./spec/config/check_env_vars_spec.rb", + "line_number": 12, + "run_time": 0.079159625, + "pending_message": null + }, + { + "id": "./spec/config/check_env_vars_spec.rb[1:1:1:1:2]", + "description": "fails in assertion", + "full_description": "CheckEnvVars#call when all env vars are defined behaves like success load fails in assertion", + "status": "failed", + "file_path": "./spec/config/check_env_vars_spec.rb", + "line_number": 17, + "run_time": 0.004411051, + "pending_message": null, + "exception": { + "class": "RSpec::Mocks::MockExpectationError", + "message": "(#ActiveSupport::BroadcastLogger:0x00007f1007fedf58).debug(\"All config env vars exist\")\n expected: 0 times with arguments: (\"All config env vars exist\")\n received: 1 time with arguments: (\"All config env vars exist\")", + "backtrace": [ + "/usr/local/bundle/ruby/3.3.0/gems/net-http-0.4.1/lib/net/http.rb:1603:in `initialize'", + "./config/check_env_vars.rb:11:in `call'", + "./spec/config/check_env_vars_spec.rb:7:in `block (3 levels) in \u003ctop (required)\u003e'", + "./spec/config/check_env_vars_spec.rb:19:in `block (4 levels) in \u003ctop (required)\u003e'" + ] + } + }, + { + "id": "./spec/config/check_env_vars_spec.rb[1:1:1:1:4]", + "description": "skips the test", + "full_description": "CheckEnvVars#call when all env vars are defined behaves like success load skips the test", + "status": "pending", + "file_path": "./spec/config/check_env_vars_spec.rb", + "line_number": 27, + "run_time": 2.3007e-05, + "pending_message": "Temporarily skipped with xit" + } + ], + "summary": { + "duration": 0.19118387, + "example_count": 3, + "failure_count": 1, + "pending_count": 1, + "errors_outside_of_examples_count": 0 + }, + "summary_line": "3 examples, 1 failures, 1 pending" +} diff --git a/__tests__/rspec-json.test.ts b/__tests__/rspec-json.test.ts new file mode 100644 index 00000000..f77475aa --- /dev/null +++ b/__tests__/rspec-json.test.ts @@ -0,0 +1,45 @@ +import * as fs from 'fs' +import * as path from 'path' + +import {RspecJsonParser} from '../src/parsers/rspec-json/rspec-json-parser' +import {ParseOptions} from '../src/test-parser' +import {getReport} from '../src/report/get-report' +import {normalizeFilePath} from '../src/utils/path-utils' + +describe('rspec-json tests', () => { + it('produces empty test run result when there are no test cases', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'empty', 'rspec-json.json') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const opts: ParseOptions = { + parseErrors: true, + trackedFiles: [] + } + + const parser = new RspecJsonParser(opts) + const result = await parser.parse(filePath, fileContent) + expect(result.tests).toBe(0) + expect(result.result).toBe('success') + }) + + it('report from ./reports/rspec-json test results matches snapshot', async () => { + const fixturePath = path.join(__dirname, 'fixtures', 'rspec-json.json') + const outputPath = path.join(__dirname, '__outputs__', 'rspec-json.md') + const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) + const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) + + const opts: ParseOptions = { + parseErrors: true, + trackedFiles: ['test/main.test.js', 'test/second.test.js', 'lib/main.js'] + } + + const parser = new RspecJsonParser(opts) + const result = await parser.parse(filePath, fileContent) + expect(result).toMatchSnapshot() + + const report = getReport([result]) + fs.mkdirSync(path.dirname(outputPath), {recursive: true}) + fs.writeFileSync(outputPath, report) + }) +}) diff --git a/action.yml b/action.yml index 6f35ebfe..8d9d7282 100644 --- a/action.yml +++ b/action.yml @@ -31,6 +31,7 @@ inputs: - java-junit - jest-junit - mocha-json + - rspec-json - swift-xunit required: true list-suites: diff --git a/dist/index.js b/dist/index.js index 47a1b8d2..bbc5e787 100644 --- a/dist/index.js +++ b/dist/index.js @@ -265,6 +265,7 @@ const dotnet_trx_parser_1 = __nccwpck_require__(2664); const java_junit_parser_1 = __nccwpck_require__(676); const jest_junit_parser_1 = __nccwpck_require__(1113); const mocha_json_parser_1 = __nccwpck_require__(6043); +const rspec_json_parser_1 = __nccwpck_require__(406); const swift_xunit_parser_1 = __nccwpck_require__(5366); const path_utils_1 = __nccwpck_require__(4070); const github_utils_1 = __nccwpck_require__(3522); @@ -434,6 +435,8 @@ class TestReporter { return new jest_junit_parser_1.JestJunitParser(options); case 'mocha-json': return new mocha_json_parser_1.MochaJsonParser(options); + case 'rspec-json': + return new rspec_json_parser_1.RspecJsonParser(options); case 'swift-xunit': return new swift_xunit_parser_1.SwiftXunitParser(options); default: @@ -1403,6 +1406,121 @@ class MochaJsonParser { exports.MochaJsonParser = MochaJsonParser; +/***/ }), + +/***/ 406: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.RspecJsonParser = void 0; +const test_results_1 = __nccwpck_require__(2768); +class RspecJsonParser { + constructor(options) { + this.options = options; + } + parse(path, content) { + return __awaiter(this, void 0, void 0, function* () { + const mocha = this.getRspecJson(path, content); + const result = this.getTestRunResult(path, mocha); + result.sort(true); + return Promise.resolve(result); + }); + } + getRspecJson(path, content) { + try { + return JSON.parse(content); + } + catch (e) { + throw new Error(`Invalid JSON at ${path}\n\n${e}`); + } + } + getTestRunResult(resultsPath, rspec) { + const suitesMap = {}; + const getSuite = (test) => { + var _a; + const path = test.file_path; + return (_a = suitesMap[path]) !== null && _a !== void 0 ? _a : (suitesMap[path] = new test_results_1.TestSuiteResult(path, [])); + }; + for (const test of rspec.examples) { + const suite = getSuite(test); + if (test.status === 'failed') { + this.processTest(suite, test, 'failed'); + } + else if (test.status === 'passed') { + this.processTest(suite, test, 'success'); + } + else if (test.status === 'pending') { + this.processTest(suite, test, 'skipped'); + } + } + const suites = Object.values(suitesMap); + return new test_results_1.TestRunResult(resultsPath, suites, rspec.summary.duration); + } + processTest(suite, test, result) { + var _a; + const groupName = test.full_description !== test.description + ? test.full_description.substr(0, test.full_description.length - test.description.length).trimEnd() + : null; + let group = suite.groups.find(grp => grp.name === groupName); + if (group === undefined) { + group = new test_results_1.TestGroupResult(groupName, []); + suite.groups.push(group); + } + const error = this.getTestCaseError(test); + const testCase = new test_results_1.TestCaseResult(test.full_description, result, (_a = test.run_time) !== null && _a !== void 0 ? _a : 0, error); + group.tests.push(testCase); + } + getTestCaseError(test) { + var _a, _b; + const backtrace = (_a = test.exception) === null || _a === void 0 ? void 0 : _a.backtrace; + const message = (_b = test.exception) === null || _b === void 0 ? void 0 : _b.message; + if (backtrace === undefined) { + return undefined; + } + let path; + let line; + const details = backtrace.join('\n'); + const src = this.getExceptionSource(backtrace); + if (src) { + path = src.path; + line = src.line; + } + return { + path, + line, + message, + details + }; + } + getExceptionSource(backtrace) { + const re = /^(.*?):(\d+):/; + for (const str of backtrace) { + const match = str.match(re); + if (match !== null) { + const [_, path, lineStr] = match; + if (path.startsWith('./')) { + const line = parseInt(lineStr); + return { path, line }; + } + } + } + return undefined; + } +} +exports.RspecJsonParser = RspecJsonParser; + + /***/ }), /***/ 5366: diff --git a/src/main.ts b/src/main.ts index c3c89efd..8f481afe 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,7 @@ import {DotnetTrxParser} from './parsers/dotnet-trx/dotnet-trx-parser' import {JavaJunitParser} from './parsers/java-junit/java-junit-parser' import {JestJunitParser} from './parsers/jest-junit/jest-junit-parser' import {MochaJsonParser} from './parsers/mocha-json/mocha-json-parser' +import {RspecJsonParser} from './parsers/rspec-json/rspec-json-parser' import {SwiftXunitParser} from './parsers/swift-xunit/swift-xunit-parser' import {normalizeDirPath, normalizeFilePath} from './utils/path-utils' @@ -223,6 +224,8 @@ class TestReporter { return new JestJunitParser(options) case 'mocha-json': return new MochaJsonParser(options) + case 'rspec-json': + return new RspecJsonParser(options) case 'swift-xunit': return new SwiftXunitParser(options) default: diff --git a/src/parsers/rspec-json/rspec-json-parser.ts b/src/parsers/rspec-json/rspec-json-parser.ts new file mode 100644 index 00000000..bf404a12 --- /dev/null +++ b/src/parsers/rspec-json/rspec-json-parser.ts @@ -0,0 +1,113 @@ +import { Console } from 'console' +import {ParseOptions, TestParser} from '../../test-parser' +import { + TestCaseError, + TestCaseResult, + TestExecutionResult, + TestGroupResult, + TestRunResult, + TestSuiteResult +} from '../../test-results' +import {RspecJson, RspecExample} from './rspec-json-types' + +export class RspecJsonParser implements TestParser { + assumedWorkDir: string | undefined + + constructor(readonly options: ParseOptions) {} + + async parse(path: string, content: string): Promise { + const mocha = this.getRspecJson(path, content) + const result = this.getTestRunResult(path, mocha) + result.sort(true) + return Promise.resolve(result) + } + + private getRspecJson(path: string, content: string): RspecJson { + try { + return JSON.parse(content) + } catch (e) { + throw new Error(`Invalid JSON at ${path}\n\n${e}`) + } + } + + private getTestRunResult(resultsPath: string, rspec: RspecJson): TestRunResult { + const suitesMap: {[path: string]: TestSuiteResult} = {} + + const getSuite = (test: RspecExample): TestSuiteResult => { + const path = test.file_path + return suitesMap[path] ?? (suitesMap[path] = new TestSuiteResult(path, [])) + } + + for (const test of rspec.examples) { + const suite = getSuite(test) + if (test.status === 'failed') { + this.processTest(suite, test, 'failed') + } else if (test.status === 'passed') { + this.processTest(suite, test, 'success') + } else if (test.status === 'pending') { + this.processTest(suite, test, 'skipped') + } + } + + const suites = Object.values(suitesMap) + return new TestRunResult(resultsPath, suites, rspec.summary.duration) + } + + private processTest(suite: TestSuiteResult, test: RspecExample, result: TestExecutionResult): void { + const groupName = + test.full_description !== test.description + ? test.full_description.substr(0, test.full_description.length - test.description.length).trimEnd() + : null + + let group = suite.groups.find(grp => grp.name === groupName) + if (group === undefined) { + group = new TestGroupResult(groupName, []) + suite.groups.push(group) + } + + const error = this.getTestCaseError(test) + const testCase = new TestCaseResult(test.full_description, result, test.run_time ?? 0, error) + group.tests.push(testCase) + } + + private getTestCaseError(test: RspecExample): TestCaseError | undefined { + const backtrace = test.exception?.backtrace + const message = test.exception?.message + if (backtrace === undefined) { + return undefined + } + + let path + let line + const details = backtrace.join('\n') + + const src = this.getExceptionSource(backtrace) + if (src) { + path = src.path + line = src.line + } + + return { + path, + line, + message, + details + } + } + + private getExceptionSource(backtrace: string[]): {path: string; line: number} | undefined { + const re = /^(.*?):(\d+):/ + + for (const str of backtrace) { + const match = str.match(re) + if (match !== null) { + const [_, path, lineStr] = match + if (path.startsWith('./')) { + const line = parseInt(lineStr) + return {path, line} + } + } + } + return undefined + } +} diff --git a/src/parsers/rspec-json/rspec-json-types.ts b/src/parsers/rspec-json/rspec-json-types.ts new file mode 100644 index 00000000..495af897 --- /dev/null +++ b/src/parsers/rspec-json/rspec-json-types.ts @@ -0,0 +1,34 @@ +export interface RspecJson { + version: number + examples: RspecExample[] + summary: RspecSummary + summary_line: string +} + +export interface RspecExample { + id: string + description: string + full_description: string + status: TestStatus + file_path: string + line_number: number + run_time: number + pending_message: string | null + exception?: RspecException +} + +type TestStatus = 'passed' | 'failed' | 'pending'; + +export interface RspecException { + class: string + message: string + backtrace: string[] +} + +export interface RspecSummary { + duration: number + example_count: number + failure_count: number + pending_count: number + errors_outside_of_examples_count: number +}