diff --git a/action.yml b/action.yml index bcaad45..7a5dc45 100644 --- a/action.yml +++ b/action.yml @@ -70,6 +70,13 @@ inputs: Detailed listing of test suites and test cases will be skipped. default: 'false' required: false + output-to: + description: | + The location to write the report to. Supported options: + - checks + - step-summary + default: 'step-summary' + required: false token: description: GitHub Access Token required: false diff --git a/dist/index.js b/dist/index.js index 0536655..c03f637 100644 --- a/dist/index.js +++ b/dist/index.js @@ -256,6 +256,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge Object.defineProperty(exports, "__esModule", ({ value: true })); const core = __importStar(__nccwpck_require__(2186)); const github = __importStar(__nccwpck_require__(5438)); +const crypto_1 = __nccwpck_require__(6113); const artifact_provider_1 = __nccwpck_require__(7171); const local_file_provider_1 = __nccwpck_require__(9399); const get_annotations_1 = __nccwpck_require__(5867); @@ -267,7 +268,6 @@ const jest_junit_parser_1 = __nccwpck_require__(1113); const mocha_json_parser_1 = __nccwpck_require__(6043); const path_utils_1 = __nccwpck_require__(4070); const github_utils_1 = __nccwpck_require__(3522); -const markdown_utils_1 = __nccwpck_require__(6482); function main() { return __awaiter(this, void 0, void 0, function* () { try { @@ -282,6 +282,15 @@ function main() { } }); } +function createSlugPrefix() { + const step_summary = process.env['GITHUB_STEP_SUMMARY']; + if (!step_summary || step_summary === '') { + return ''; + } + const hash = (0, crypto_1.createHash)('sha1'); + hash.update(step_summary); + return hash.digest('hex').substring(0, 8); +} class TestReporter { constructor() { this.artifact = core.getInput('artifact', { required: false }); @@ -296,7 +305,9 @@ class TestReporter { this.workDirInput = core.getInput('working-directory', { required: false }); this.buildDirInput = core.getInput('build-directory', { required: false }); this.onlySummary = core.getInput('only-summary', { required: false }) === 'true'; + this.outputTo = core.getInput('output-to', { required: false }); this.token = core.getInput('token', { required: true }); + this.slugPrefix = ''; this.context = (0, github_utils_1.getCheckRunContext)(); this.octokit = github.getOctokit(this.token); if (this.listSuites !== 'all' && this.listSuites !== 'failed') { @@ -311,6 +322,13 @@ class TestReporter { core.setFailed(`Input parameter 'max-annotations' has invalid value`); return; } + if (this.outputTo !== 'checks' && this.outputTo !== 'step-summary') { + core.setFailed(`Input parameter 'output-to' has invalid value`); + return; + } + if (this.outputTo === 'step-summary') { + this.slugPrefix = createSlugPrefix(); + } } run() { return __awaiter(this, void 0, void 0, function* () { @@ -376,6 +394,7 @@ class TestReporter { }); } createReport(parser, name, files) { + var _a, _b; return __awaiter(this, void 0, void 0, function* () { if (files.length === 0) { core.warning(`No file matches path ${this.path}`); @@ -393,29 +412,82 @@ class TestReporter { throw error; } } - core.info(`Creating check run ${name}`); - const createResp = yield this.octokit.rest.checks.create(Object.assign({ head_sha: this.context.sha, name, status: 'in_progress', output: { - title: name, - summary: '' - } }, github.context.repo)); + let createResp = null; + let baseUrl = ''; + let check_run_id = 0; + switch (this.outputTo) { + case 'checks': { + core.info(`Creating check run ${name}`); + createResp = yield this.octokit.rest.checks.create(Object.assign({ head_sha: this.context.sha, name, status: 'in_progress', output: { + title: name, + summary: '' + } }, github.context.repo)); + baseUrl = (_a = createResp.data.html_url) !== null && _a !== void 0 ? _a : ''; + check_run_id = createResp.data.id; + break; + } + case 'step-summary': { + const run_attempt = (_b = process.env['GITHUB_RUN_ATTEMPT']) !== null && _b !== void 0 ? _b : 1; + baseUrl = `https://github.com/${github.context.repo.owner}/${github.context.repo.repo}/actions/runs/${github.context.runId}/attempts/${run_attempt}`; + break; + } + } core.info('Creating report summary'); - const { listSuites, listTests, onlySummary } = this; - const baseUrl = createResp.data.html_url; - const summary = (0, get_report_1.getReport)(results, { listSuites, listTests, baseUrl, onlySummary }); + const { listSuites, listTests, onlySummary, slugPrefix } = this; + const summary = (0, get_report_1.getReport)(results, { listSuites, listTests, baseUrl, slugPrefix, onlySummary }); core.info('Creating annotations'); const annotations = (0, get_annotations_1.getAnnotations)(results, this.maxAnnotations); const isFailed = this.failOnError && results.some(tr => tr.result === 'failed'); const conclusion = isFailed ? 'failure' : 'success'; - const icon = isFailed ? markdown_utils_1.Icon.fail : markdown_utils_1.Icon.success; + const passed = results.reduce((sum, tr) => sum + tr.passed, 0); + const failed = results.reduce((sum, tr) => sum + tr.failed, 0); + const skipped = results.reduce((sum, tr) => sum + tr.skipped, 0); + const shortSummary = `${passed} passed, ${failed} failed and ${skipped} skipped `; core.info(`Updating check run conclusion (${conclusion}) and output`); - const resp = yield this.octokit.rest.checks.update(Object.assign({ check_run_id: createResp.data.id, conclusion, status: 'completed', output: { - title: `${name} ${icon}`, - summary, - annotations - } }, github.context.repo)); - core.info(`Check run create response: ${resp.status}`); - core.info(`Check run URL: ${resp.data.url}`); - core.info(`Check run HTML: ${resp.data.html_url}`); + switch (this.outputTo) { + case 'checks': { + const resp = yield this.octokit.rest.checks.update(Object.assign({ check_run_id, + conclusion, status: 'completed', output: { + title: shortSummary, + summary, + annotations + } }, github.context.repo)); + core.info(`Check run create response: ${resp.status}`); + core.info(`Check run URL: ${resp.data.url}`); + core.info(`Check run HTML: ${resp.data.html_url}`); + break; + } + case 'step-summary': { + core.summary.addRaw(`# ${shortSummary}`); + core.summary.addRaw(summary); + yield core.summary.write(); + for (const annotation of annotations) { + let fn; + switch (annotation.annotation_level) { + case 'failure': + fn = core.error; + break; + case 'warning': + fn = core.warning; + break; + case 'notice': + fn = core.notice; + break; + default: + continue; + } + fn(annotation.message, { + title: annotation.title, + file: annotation.path, + startLine: annotation.start_line, + endLine: annotation.end_line, + startColumn: annotation.start_column, + endColumn: annotation.end_column + }); + } + break; + } + } return results; }); } @@ -1526,6 +1598,7 @@ const MAX_REPORT_LENGTH = 65535; const defaultOptions = { listSuites: 'all', listTests: 'all', + slugPrefix: '', baseUrl: '', onlySummary: false }; @@ -1629,7 +1702,7 @@ function getTestRunsReport(testRuns, options) { const tableData = testRuns.map((tr, runIndex) => { const time = (0, markdown_utils_1.formatTime)(tr.time); const name = tr.path; - const addr = options.baseUrl + makeRunSlug(runIndex).link; + const addr = options.baseUrl + makeRunSlug(runIndex, options.slugPrefix).link; const nameLink = (0, markdown_utils_1.link)(name, addr); const statusIcon = tr.failed > 0 ? markdown_utils_1.Icon.fail : tr.passed > 0 ? markdown_utils_1.Icon.success : markdown_utils_1.Icon.skip; const passed = tr.passed === 0 ? '' : tr.passed; @@ -1648,7 +1721,7 @@ function getTestRunsReport(testRuns, options) { } function getSuitesReport(tr, runIndex, options) { const sections = []; - const trSlug = makeRunSlug(runIndex); + const trSlug = makeRunSlug(runIndex, options.slugPrefix); const nameLink = `${tr.path}`; const icon = getResultIcon(tr.result); sections.push(`## ${icon}\xa0${nameLink}`); @@ -1663,7 +1736,7 @@ function getSuitesReport(tr, runIndex, options) { const tsTime = (0, markdown_utils_1.formatTime)(s.time); const tsName = s.name; const skipLink = options.listTests === 'none' || (options.listTests === 'failed' && s.result !== 'failed'); - const tsAddr = options.baseUrl + makeSuiteSlug(runIndex, suiteIndex).link; + const tsAddr = options.baseUrl + makeSuiteSlug(runIndex, suiteIndex, options.slugPrefix).link; const tsNameLink = skipLink ? tsName : (0, markdown_utils_1.link)(tsName, tsAddr); const statusIcon = s.failed > 0 ? markdown_utils_1.Icon.fail : s.passed > 0 ? markdown_utils_1.Icon.success : markdown_utils_1.Icon.skip; const passed = s.passed === 0 ? '' : s.passed; @@ -1692,7 +1765,7 @@ function getTestsReport(ts, runIndex, suiteIndex, options) { } const sections = []; const tsName = ts.name; - const tsSlug = makeSuiteSlug(runIndex, suiteIndex); + const tsSlug = makeSuiteSlug(runIndex, suiteIndex, options.slugPrefix); const tsNameLink = `${tsName}`; const icon = getResultIcon(ts.result); sections.push(`### ${icon}\xa0${tsNameLink}`); @@ -1716,13 +1789,13 @@ function getTestsReport(ts, runIndex, suiteIndex, options) { sections.push('```'); return sections; } -function makeRunSlug(runIndex) { +function makeRunSlug(runIndex, slugPrefix) { // use prefix to avoid slug conflicts after escaping the paths - return (0, slugger_1.slug)(`r${runIndex}`); + return (0, slugger_1.slug)(`r${slugPrefix}${runIndex}`); } -function makeSuiteSlug(runIndex, suiteIndex) { +function makeSuiteSlug(runIndex, suiteIndex, slugPrefix) { // use prefix to avoid slug conflicts after escaping the paths - return (0, slugger_1.slug)(`r${runIndex}s${suiteIndex}`); + return (0, slugger_1.slug)(`r${slugPrefix}${runIndex}s${suiteIndex}`); } function getResultIcon(result) { switch (result) { diff --git a/src/main.ts b/src/main.ts index 3c4bb68..3a8c488 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import * as core from '@actions/core' import * as github from '@actions/github' +import {createHash} from 'crypto' import {GitHub} from '@actions/github/lib/utils' import {ArtifactProvider} from './input-providers/artifact-provider' @@ -18,7 +19,6 @@ import {MochaJsonParser} from './parsers/mocha-json/mocha-json-parser' import {normalizeDirPath, normalizeFilePath} from './utils/path-utils' import {getCheckRunContext} from './utils/github-utils' -import {Icon} from './utils/markdown-utils' async function main(): Promise { try { @@ -30,6 +30,16 @@ async function main(): Promise { } } +function createSlugPrefix(): string { + const step_summary = process.env['GITHUB_STEP_SUMMARY'] + if (!step_summary || step_summary === '') { + return '' + } + const hash = createHash('sha1') + hash.update(step_summary) + return hash.digest('hex').substring(0, 8) +} + class TestReporter { readonly artifact = core.getInput('artifact', {required: false}) readonly name = core.getInput('name', {required: true}) @@ -43,7 +53,9 @@ class TestReporter { readonly workDirInput = core.getInput('working-directory', {required: false}) readonly buildDirInput = core.getInput('build-directory', {required: false}) readonly onlySummary = core.getInput('only-summary', {required: false}) === 'true' + readonly outputTo = core.getInput('output-to', {required: false}) readonly token = core.getInput('token', {required: true}) + readonly slugPrefix: string = '' readonly octokit: InstanceType readonly context = getCheckRunContext() @@ -64,6 +76,15 @@ class TestReporter { core.setFailed(`Input parameter 'max-annotations' has invalid value`) return } + + if (this.outputTo !== 'checks' && this.outputTo !== 'step-summary') { + core.setFailed(`Input parameter 'output-to' has invalid value`) + return + } + + if (this.outputTo === 'step-summary') { + this.slugPrefix = createSlugPrefix() + } } async run(): Promise { @@ -164,45 +185,100 @@ class TestReporter { } } - core.info(`Creating check run ${name}`) - const createResp = await this.octokit.rest.checks.create({ - head_sha: this.context.sha, - name, - status: 'in_progress', - output: { - title: name, - summary: '' - }, - ...github.context.repo - }) + let createResp = null + let baseUrl = '' + let check_run_id = 0 + + switch (this.outputTo) { + case 'checks': { + core.info(`Creating check run ${name}`) + createResp = await this.octokit.rest.checks.create({ + head_sha: this.context.sha, + name, + status: 'in_progress', + output: { + title: name, + summary: '' + }, + ...github.context.repo + }) + baseUrl = createResp.data.html_url ?? '' + check_run_id = createResp.data.id + break + } + case 'step-summary': { + const run_attempt = process.env['GITHUB_RUN_ATTEMPT'] ?? 1 + baseUrl = `https://github.com/${github.context.repo.owner}/${github.context.repo.repo}/actions/runs/${github.context.runId}/attempts/${run_attempt}` + break + } + } core.info('Creating report summary') - const {listSuites, listTests, onlySummary} = this - const baseUrl = createResp.data.html_url as string - const summary = getReport(results, {listSuites, listTests, baseUrl, onlySummary}) + const {listSuites, listTests, onlySummary, slugPrefix} = this + const summary = getReport(results, {listSuites, listTests, baseUrl, slugPrefix, onlySummary}) core.info('Creating annotations') const annotations = getAnnotations(results, this.maxAnnotations) const isFailed = this.failOnError && results.some(tr => tr.result === 'failed') const conclusion = isFailed ? 'failure' : 'success' - const icon = isFailed ? Icon.fail : Icon.success + + const passed = results.reduce((sum, tr) => sum + tr.passed, 0) + const failed = results.reduce((sum, tr) => sum + tr.failed, 0) + const skipped = results.reduce((sum, tr) => sum + tr.skipped, 0) + const shortSummary = `${passed} passed, ${failed} failed and ${skipped} skipped ` core.info(`Updating check run conclusion (${conclusion}) and output`) - const resp = await this.octokit.rest.checks.update({ - check_run_id: createResp.data.id, - conclusion, - status: 'completed', - output: { - title: `${name} ${icon}`, - summary, - annotations - }, - ...github.context.repo - }) - core.info(`Check run create response: ${resp.status}`) - core.info(`Check run URL: ${resp.data.url}`) - core.info(`Check run HTML: ${resp.data.html_url}`) + switch (this.outputTo) { + case 'checks': { + const resp = await this.octokit.rest.checks.update({ + check_run_id, + conclusion, + status: 'completed', + output: { + title: shortSummary, + summary, + annotations + }, + ...github.context.repo + }) + core.info(`Check run create response: ${resp.status}`) + core.info(`Check run URL: ${resp.data.url}`) + core.info(`Check run HTML: ${resp.data.html_url}`) + break + } + case 'step-summary': { + core.summary.addRaw(`# ${shortSummary}`) + core.summary.addRaw(summary) + await core.summary.write() + for (const annotation of annotations) { + let fn + switch (annotation.annotation_level) { + case 'failure': + fn = core.error + break + case 'warning': + fn = core.warning + break + case 'notice': + fn = core.notice + break + default: + continue + } + + fn(annotation.message, { + title: annotation.title, + file: annotation.path, + startLine: annotation.start_line, + endLine: annotation.end_line, + startColumn: annotation.start_column, + endColumn: annotation.end_column + }) + } + break + } + } return results } diff --git a/src/report/get-report.ts b/src/report/get-report.ts index 88bb32e..2918da1 100644 --- a/src/report/get-report.ts +++ b/src/report/get-report.ts @@ -10,6 +10,7 @@ const MAX_REPORT_LENGTH = 65535 export interface ReportOptions { listSuites: 'all' | 'failed' listTests: 'all' | 'failed' | 'none' + slugPrefix: string baseUrl: string onlySummary: boolean } @@ -17,6 +18,7 @@ export interface ReportOptions { const defaultOptions: ReportOptions = { listSuites: 'all', listTests: 'all', + slugPrefix: '', baseUrl: '', onlySummary: false } @@ -139,7 +141,7 @@ function getTestRunsReport(testRuns: TestRunResult[], options: ReportOptions): s const tableData = testRuns.map((tr, runIndex) => { const time = formatTime(tr.time) const name = tr.path - const addr = options.baseUrl + makeRunSlug(runIndex).link + const addr = options.baseUrl + makeRunSlug(runIndex, options.slugPrefix).link const nameLink = link(name, addr) const statusIcon = tr.failed > 0 ? Icon.fail : tr.passed > 0 ? Icon.success : Icon.skip const passed = tr.passed === 0 ? '' : tr.passed @@ -166,7 +168,7 @@ function getTestRunsReport(testRuns: TestRunResult[], options: ReportOptions): s function getSuitesReport(tr: TestRunResult, runIndex: number, options: ReportOptions): string[] { const sections: string[] = [] - const trSlug = makeRunSlug(runIndex) + const trSlug = makeRunSlug(runIndex, options.slugPrefix) const nameLink = `${tr.path}` const icon = getResultIcon(tr.result) sections.push(`## ${icon}\xa0${nameLink}`) @@ -187,7 +189,7 @@ function getSuitesReport(tr: TestRunResult, runIndex: number, options: ReportOpt const tsTime = formatTime(s.time) const tsName = s.name const skipLink = options.listTests === 'none' || (options.listTests === 'failed' && s.result !== 'failed') - const tsAddr = options.baseUrl + makeSuiteSlug(runIndex, suiteIndex).link + const tsAddr = options.baseUrl + makeSuiteSlug(runIndex, suiteIndex, options.slugPrefix).link const tsNameLink = skipLink ? tsName : link(tsName, tsAddr) const statusIcon = s.failed > 0 ? Icon.fail : s.passed > 0 ? Icon.success : Icon.skip const passed = s.passed === 0 ? '' : s.passed @@ -222,7 +224,7 @@ function getTestsReport(ts: TestSuiteResult, runIndex: number, suiteIndex: numbe const sections: string[] = [] const tsName = ts.name - const tsSlug = makeSuiteSlug(runIndex, suiteIndex) + const tsSlug = makeSuiteSlug(runIndex, suiteIndex, options.slugPrefix) const tsNameLink = `${tsName}` const icon = getResultIcon(ts.result) sections.push(`### ${icon}\xa0${tsNameLink}`) @@ -251,14 +253,14 @@ function getTestsReport(ts: TestSuiteResult, runIndex: number, suiteIndex: numbe return sections } -function makeRunSlug(runIndex: number): {id: string; link: string} { +function makeRunSlug(runIndex: number, slugPrefix: string): {id: string; link: string} { // use prefix to avoid slug conflicts after escaping the paths - return slug(`r${runIndex}`) + return slug(`r${slugPrefix}${runIndex}`) } -function makeSuiteSlug(runIndex: number, suiteIndex: number): {id: string; link: string} { +function makeSuiteSlug(runIndex: number, suiteIndex: number, slugPrefix: string): {id: string; link: string} { // use prefix to avoid slug conflicts after escaping the paths - return slug(`r${runIndex}s${suiteIndex}`) + return slug(`r${slugPrefix}${runIndex}s${suiteIndex}`) } function getResultIcon(result: TestExecutionResult): string {