Feature/create check (#97)

This commit is contained in:
David Finol
2021-02-27 12:13:19 -06:00
committed by GitHub
parent 345f4c64bd
commit 43d90c252f
25 changed files with 1018 additions and 13 deletions

View File

@@ -1,5 +1,5 @@
import * as core from '@actions/core';
import { Action, Docker, Input, ImageTag, Output } from './model';
import { Action, Docker, Input, ImageTag, Output, ResultsCheck } from './model';
async function action() {
Action.checkCompatibility();
@@ -12,6 +12,9 @@ async function action() {
testMode,
artifactsPath,
useHostNetwork,
createCheck,
checkName,
githubToken,
customParameters,
} = Input.getFromUser();
const baseImage = ImageTag.createForBase({ version: unityVersion, customImage });
@@ -28,12 +31,20 @@ async function action() {
testMode,
artifactsPath,
useHostNetwork,
createCheck,
customParameters,
});
} finally {
// Set output
await Output.setArtifactsPath(artifactsPath);
}
if (createCheck) {
const failedTestCount = await ResultsCheck.createCheck(artifactsPath, checkName, githubToken);
if (failedTestCount >= 1) {
core.setFailed(`Test(s) Failed! Check '${checkName}' for details.`);
}
}
}
action().catch(error => {

View File

@@ -25,6 +25,7 @@ class Docker {
testMode,
artifactsPath,
useHostNetwork,
createCheck,
customParameters,
} = parameters;
@@ -62,6 +63,7 @@ class Docker {
--volume "/home/runner/work/_temp/_github_workflow":"/github/workflow" \
--volume "${workspace}":"/github/workspace" \
${useHostNetwork ? '--net=host' : ''} \
${createCheck ? '--env USE_EXIT_CODE=false' : '--env USE_EXIT_CODE=true'} \
${image}`;
await exec(command, undefined, { silent });

View File

@@ -3,5 +3,6 @@ import Docker from './docker';
import Input from './input';
import ImageTag from './image-tag';
import Output from './output';
import ResultsCheck from './results-check';
export { Action, Docker, Input, ImageTag, Output };
export { Action, Docker, Input, ImageTag, Output, ResultsCheck };

View File

@@ -1,7 +1,10 @@
import * as Index from '.';
describe('Index', () => {
test.each(['Action', 'Docker', 'ImageTag', 'Input', 'Output'])('exports %s', exportedModule => {
expect(typeof Index[exportedModule]).toStrictEqual('function');
});
test.each(['Action', 'Docker', 'ImageTag', 'Input', 'Output', 'ResultsCheck'])(
'exports %s',
exportedModule => {
expect(typeof Index[exportedModule]).toStrictEqual('function');
},
);
});

View File

@@ -21,6 +21,9 @@ class Input {
const rawProjectPath = getInput('projectPath') || '.';
const rawArtifactsPath = getInput('artifactsPath') || 'artifacts';
const rawUseHostNetwork = getInput('useHostNetwork') || 'false';
const rawCreateCheck = getInput('createCheck') || 'false';
const checkName = getInput('checkName') || 'Test Results';
const githubToken = getInput('githubToken') || '';
const customParameters = getInput('customParameters') || '';
// Validate input
@@ -44,6 +47,7 @@ class Input {
const projectPath = rawProjectPath.replace(/\/$/, '');
const artifactsPath = rawArtifactsPath.replace(/\/$/, '');
const useHostNetwork = rawUseHostNetwork === 'true';
const createCheck = rawCreateCheck === 'true';
const unityVersion =
rawUnityVersion === 'auto' ? UnityVersionParser.read(projectPath) : rawUnityVersion;
@@ -55,6 +59,9 @@ class Input {
testMode,
artifactsPath,
useHostNetwork,
createCheck,
checkName,
githubToken,
customParameters,
};
}

114
src/model/results-check.js Normal file
View File

@@ -0,0 +1,114 @@
import * as core from '@actions/core';
import * as github from '@actions/github';
import * as fs from 'fs';
import path from 'path';
import Handlebars from 'handlebars';
import ResultsParser from './results-parser';
import { RunMeta } from './ts/results-meta.ts';
class ResultsCheck {
static async createCheck(artifactsPath, checkName, githubToken) {
// Validate input
if (!artifactsPath || !checkName || !githubToken) {
throw new Error(
`Missing input! {"artifactsPath": "${artifactsPath}", "checkName": "${checkName}", "githubToken": "${githubToken}`,
);
}
// Parse all results files
const runs = [];
const files = fs.readdirSync(artifactsPath);
await Promise.all(
files.map(async filepath => {
if (!filepath.endsWith('.xml')) return;
core.info(`Processing file ${filepath}...`);
const fileData = await ResultsParser.parseResults(path.join(artifactsPath, filepath));
core.info(fileData.summary);
runs.push(fileData);
}),
);
// Combine all results into a single run summary
const runSummary = new RunMeta(checkName);
runs.forEach(run => {
runSummary.total += run.total;
runSummary.passed += run.passed;
runSummary.skipped += run.skipped;
runSummary.failed += run.failed;
runSummary.duration += run.duration;
run.suites.forEach(suite => {
runSummary.addTests(suite.tests);
});
});
// Log
core.info('=================');
core.info('Analyze result:');
core.info(runSummary.summary);
// Call GitHub API
await ResultsCheck.requestGitHubCheck(checkName, githubToken, runs, runSummary);
return runSummary.failed;
}
static async requestGitHubCheck(checkName, githubToken, runs, runSummary) {
const pullRequest = github.context.payload.pull_request;
const headSha = (pullRequest && pullRequest.head.sha) || github.context.sha;
const title = runSummary.summary;
const summary = await ResultsCheck.renderSummary(runs);
const details = await ResultsCheck.renderDetails(runs);
const rawAnnotations = runSummary.extractAnnotations();
const annotations = rawAnnotations.map(rawAnnotation => {
const annotation = rawAnnotation;
annotation.path = rawAnnotation.path.replace('/github/workspace/', '');
return annotation;
});
core.info(`Posting results for ${headSha}`);
const createCheckRequest = {
...github.context.repo,
name: checkName,
head_sha: headSha,
status: 'completed',
conclusion: 'neutral',
output: {
title,
summary,
text: details,
annotations: annotations.slice(0, 50),
},
};
const octokit = github.getOctokit(githubToken);
await octokit.checks.create(createCheckRequest);
}
static async renderSummary(runMetas) {
return ResultsCheck.render(`${__dirname}/../views/results-check-summary.hbs`, runMetas);
}
static async renderDetails(runMetas) {
return ResultsCheck.render(`${__dirname}/../views/results-check-details.hbs`, runMetas);
}
static async render(viewPath, runMetas) {
Handlebars.registerHelper('indent', toIndent =>
toIndent
.split('\n')
.map(s => ` ${s.replace('/github/workspace/', '')}`)
.join('\n'),
);
const source = await fs.promises.readFile(viewPath, 'utf8');
const template = Handlebars.compile(source);
return template(
{ runs: runMetas },
{
allowProtoMethodsByDefault: true,
allowProtoPropertiesByDefault: true,
},
);
}
}
export default ResultsCheck;

View File

@@ -0,0 +1,9 @@
import ResultsCheck from './results-check';
describe('ResultsCheck', () => {
describe('createCheck', () => {
it('throws for missing input', () => {
expect(() => ResultsCheck.createCheck('', '', '')).rejects.toEqual(Error);
});
});
});

121
src/model/results-parser.js Normal file
View File

@@ -0,0 +1,121 @@
import * as core from '@actions/core';
import * as xmljs from 'xml-js';
import * as fs from 'fs';
import path from 'path';
import { RunMeta, TestMeta } from './ts/results-meta.ts';
class ResultsParser {
static async parseResults(filepath) {
if (!fs.existsSync(filepath)) {
throw new Error(`Missing file! {"filepath": "${filepath}"}`);
}
core.info(`Trying to open ${filepath}`);
const file = await fs.promises.readFile(filepath, 'utf8');
const results = xmljs.xml2js(file, { compact: true });
core.info(`File ${filepath} parsed...`);
return ResultsParser.convertResults(path.basename(filepath), results);
}
static convertResults(filename, filedata) {
core.info(`Start analyzing results: ${filename}`);
const run = filedata['test-run'];
const runMeta = new RunMeta(filename);
runMeta.total = Number(run._attributes.total);
runMeta.failed = Number(run._attributes.failed);
runMeta.skipped = Number(run._attributes.skipped);
runMeta.passed = Number(run._attributes.passed);
runMeta.duration = Number(run._attributes.duration);
runMeta.addTests(ResultsParser.convertSuite(run['test-suite']));
return runMeta;
}
static convertSuite(suites) {
if (Array.isArray(suites)) {
const innerResult = [];
suites.forEach(suite => {
innerResult.push(ResultsParser.convertSuite(suite));
});
return innerResult;
}
const result = [];
const innerSuite = suites['test-suite'];
if (innerSuite) {
result.push(...ResultsParser.convertSuite(innerSuite));
}
const tests = suites['test-case'];
if (tests) {
result.push(...ResultsParser.convertTests(suites._attributes.fullname, tests));
}
return result;
}
static convertTests(suite, tests) {
if (Array.isArray(tests)) {
const result = [];
tests.forEach(test => {
result.push(ResultsParser.convertTestCase(suite, test));
});
return result;
}
return [ResultsParser.convertTestCase(suite, tests)];
}
static convertTestCase(suite, testCase) {
const { _attributes, failure } = testCase;
const { name, fullname, result, duration } = _attributes;
const testMeta = new TestMeta(suite, name);
testMeta.result = result;
testMeta.duration = Number(duration);
if (!failure) {
core.debug(`Skip test ${fullname} without failure data`);
return testMeta;
}
core.debug(`Convert data for test ${fullname}`);
if (failure['stack-trace'] === undefined) {
core.warning(`No stack trace for test case: ${fullname}`);
return testMeta;
}
const trace = failure['stack-trace']._cdata;
const point = ResultsParser.findAnnotationPoint(trace);
if (!point.path || !point.line) {
core.warning(`Not able to find annotation point for failed test! Test trace: ${trace}`);
return testMeta;
}
testMeta.annotation = {
path: point.path,
start_line: point.line,
end_line: point.line,
annotation_level: 'failure',
title: fullname,
message: failure.message._cdata,
raw_details: trace,
};
core.info(
`- ${testMeta.annotation.path}:${testMeta.annotation.start_line} - ${testMeta.annotation.title}`,
);
return testMeta;
}
static findAnnotationPoint(trace) {
const match = trace.match(/at .* in ((?<path>[^:]+):(?<line>\d+))/);
return {
path: match ? match.groups.path : '',
line: match ? Number(match.groups.line) : 0,
};
}
}
export default ResultsParser;

View File

@@ -0,0 +1,181 @@
import ResultsParser from './results-parser';
describe('ResultsParser', () => {
describe('parseResults', () => {
it('throws for missing file', () => {
expect(() => ResultsParser.parseResults('')).rejects.toEqual(Error);
});
});
describe('convertSuite', () => {
test('convert single', () => {
const targetSuite = {
_attributes: {
fullname: 'Suite Full Name',
},
'test-case': [{ _attributes: { name: 'testA' } }, { _attributes: { name: 'testB' } }],
'test-suite': [
{
_attributes: {
fullname: 'Inner Suite Full Name',
},
'test-case': { _attributes: { name: 'testC' } },
'test-suite': [],
},
],
};
const result = ResultsParser.convertSuite(targetSuite);
expect(result).toMatchObject([
[
{
annotation: undefined,
duration: Number.NaN,
result: undefined,
suite: 'Inner Suite Full Name',
title: 'testC',
},
],
{
annotation: undefined,
duration: Number.NaN,
result: undefined,
suite: 'Suite Full Name',
title: 'testA',
},
{
annotation: undefined,
duration: Number.NaN,
result: undefined,
suite: 'Suite Full Name',
title: 'testB',
},
]);
});
});
describe('convertTests', () => {
test('convert array', () => {
const testA = { _attributes: { name: 'testA' } };
const testB = { _attributes: { name: 'testB' } };
const testResult = [testA, testB];
const result = ResultsParser.convertTests('Test Suite', testResult);
expect(result).toMatchObject([
{ suite: 'Test Suite', title: 'testA' },
{ suite: 'Test Suite', title: 'testB' },
]);
});
test('convert single', () => {
const testA = { _attributes: { name: 'testA' } };
const result = ResultsParser.convertTests('Test Suite', testA);
expect(result).toMatchObject([{ suite: 'Test Suite', title: 'testA' }]);
});
});
describe('convertTestCase', () => {
test('not failed', () => {
const result = ResultsParser.convertTestCase('Test Suite', {
_attributes: {
name: 'Test Name',
duration: '3.14',
},
});
expect(result.suite).toBe('Test Suite');
expect(result.title).toBe('Test Name');
expect(result.duration).toBe(3.14);
expect(result.annotation).toBeUndefined();
});
test('no stack trace', () => {
const result = ResultsParser.convertTestCase('Test Suite', {
_attributes: {
name: 'Test Name',
duration: '3.14',
},
failure: {
message: { _cdata: 'Message CDATA' },
},
});
expect(result.suite).toBe('Test Suite');
expect(result.title).toBe('Test Name');
expect(result.duration).toBe(3.14);
expect(result.annotation).toBeUndefined();
});
test('no annotation path', () => {
const result = ResultsParser.convertTestCase('Test Suite', {
_attributes: {
name: 'Test Name',
duration: '3.14',
},
failure: {
message: { _cdata: 'Message CDATA' },
'stack-trace': { _cdata: 'Test CDATA' },
},
});
expect(result.suite).toBe('Test Suite');
expect(result.title).toBe('Test Name');
expect(result.duration).toBe(3.14);
expect(result.annotation).toBeUndefined();
});
test('prepare annotation', () => {
const result = ResultsParser.convertTestCase('Test Suite', {
_attributes: {
name: 'Test Name',
fullname: 'Test Full Name',
duration: '3.14',
},
failure: {
message: { _cdata: 'Message CDATA' },
'stack-trace': {
_cdata:
'at Tests.SetupFailedTest.SetUp () [0x00000] in /github/workspace/unity-project/Assets/Tests/SetupFailedTest.cs:10',
},
},
});
expect(result.suite).toBe('Test Suite');
expect(result.title).toBe('Test Name');
expect(result.duration).toBe(3.14);
expect(result.annotation).toMatchObject({
annotation_level: 'failure',
end_line: 10,
message: 'Message CDATA',
path: '/github/workspace/unity-project/Assets/Tests/SetupFailedTest.cs',
raw_details:
'at Tests.SetupFailedTest.SetUp () [0x00000] in /github/workspace/unity-project/Assets/Tests/SetupFailedTest.cs:10',
start_line: 10,
title: 'Test Full Name',
});
});
});
describe('findAnnotationPoint', () => {
test('keep working if not matching', () => {
const result = ResultsParser.findAnnotationPoint('');
expect(result.path).toBe('');
expect(result.line).toBe(0);
});
test('simple annotation point', () => {
const result = ResultsParser.findAnnotationPoint(`at Tests.PlayModeTest+<FailedUnityTest>d__5.MoveNext () [0x0002e] in /github/workspace/unity-project/Assets/Tests/PlayModeTest.cs:39
at UnityEngine.TestTools.TestEnumerator+<Execute>d__6.MoveNext () [0x00038] in /github/workspace/unity-project/Library/PackageCache/com.unity.test-framework@1.1.19/UnityEngine.TestRunner/NUnitExtensions/Attributes/TestEnumerator.cs:36`);
expect(result.path).toBe('/github/workspace/unity-project/Assets/Tests/PlayModeTest.cs');
expect(result.line).toBe(39);
});
test('setup annotation point', () => {
const result = ResultsParser.findAnnotationPoint(`SetUp
at Tests.SetupFailedTest.SetUp () [0x00000] in /github/workspace/unity-project/Assets/Tests/SetupFailedTest.cs:10`);
expect(result.path).toBe('/github/workspace/unity-project/Assets/Tests/SetupFailedTest.cs');
expect(result.line).toBe(10);
});
});
});

View File

@@ -0,0 +1,118 @@
import { components } from '@octokit/openapi-types/dist-types/generated/types';
export function timeHelper(seconds: number): string {
return `${seconds.toFixed(3)}s`;
}
export abstract class Meta {
title: string;
duration = 0;
constructor(title: string) {
this.title = title;
}
abstract get summary(): string;
abstract get mark(): string;
}
export type Annotation = components['schemas']['check-annotation'];
export class RunMeta extends Meta {
total = 0;
passed = 0;
skipped = 0;
failed = 0;
tests: TestMeta[] = [];
suites: RunMeta[] = [];
extractAnnotations(): Annotation[] {
const result = [] as Annotation[];
for (const suite of this.suites) {
result.push(...suite.extractAnnotations());
}
for (const test of this.tests) {
if (test.annotation !== undefined) {
result.push(test.annotation);
}
}
return result;
}
addTests(testsToAdd: TestMeta[]): void {
testsToAdd.forEach(test => {
this.addTest(test);
});
}
addTest(test: TestMeta): void {
if (test.suite === undefined) {
return;
}
if (test.suite === this.title) {
this.total++;
this.duration += test.duration;
this.tests.push(test);
if (test.result === 'Passed') this.passed++;
else if (test.result === 'Failed') this.failed++;
else this.skipped++;
return;
}
let target = this.suites.find(s => s.title === test.suite);
if (target === undefined) {
target = new RunMeta(test.suite);
this.suites.push(target);
}
target.addTest(test);
}
get summary(): string {
const result = this.failed > 0 ? 'Failed' : 'Passed';
const sPart = this.skipped > 0 ? `, skipped: ${this.skipped}` : '';
const fPart = this.failed > 0 ? `, failed: ${this.failed}` : '';
const dPart = ` in ${timeHelper(this.duration)}`;
return `${this.mark} ${this.title} - ${this.passed}/${this.total}${sPart}${fPart} - ${result}${dPart}`;
}
get mark(): string {
if (this.failed > 0) return '❌️';
else if (this.skipped === 0) return '✅';
return '⚠️';
}
}
export class TestMeta extends Meta {
suite: string;
result: string | undefined;
annotation: Annotation | undefined;
constructor(suite: string, title: string) {
super(title);
this.suite = suite;
}
isSkipped(): boolean {
return this.result === 'Skipped';
}
isFailed(): boolean {
return this.result === 'Failed';
}
get summary(): string {
const dPart = this.isSkipped()
? ''
: ` in ${timeHelper(this.duration)}`;
return `${this.mark} **${this.title}** - ${this.result}${dPart}`;
}
get mark(): string {
if (this.isFailed()) return '❌️';
else if (this.isSkipped()) return '⚠️';
return '✅';
}
}

View File

@@ -0,0 +1,58 @@
interface CommonAttributes {
id: string;
result: string;
asserts: string;
'start-time': string;
'end-time': string;
duration: string;
}
interface CommonSuiteAttributes extends CommonAttributes {
total: string;
passed: string;
failed: string;
skipped: string;
}
export interface TestRun {
_attributes: TestRunAttributes;
'test-suite': TestSuite | TestSuite[];
}
export interface TestRunAttributes extends CommonSuiteAttributes {
testcasecount: string;
'engine-version': string;
}
export interface TestSuite {
_attributes: TestSuiteAttributes;
'test-suite': TestSuite | TestSuite[];
'test-case': TestCase | TestCase[];
failure?: FailureMessage;
}
export interface TestSuiteAttributes extends CommonSuiteAttributes {
type: string;
name: string;
fullname: string;
}
export interface TestCase {
_attributes: TestCaseAttributes;
failure?: FailureMessage;
}
export interface TestCaseAttributes extends CommonAttributes {
name: string;
fullname: string;
methodname: string;
classname: string;
runstate: string;
seed: string;
}
export interface FailureMessage {
message: { _cdata: string };
'stack-trace'?: { _cdata: string };
}

View File

@@ -0,0 +1,18 @@
{{#runs}}
<details><summary>{{summary}}</summary>
{{#suites}}
* {{summary}}
{{#tests}}
* {{summary}}
{{#if annotation}}
{{indent annotation.message}}
{{indent annotation.raw_details}}
{{/if}}
{{/tests}}
{{/suites}}
</details>
{{/runs}}

View File

@@ -0,0 +1,3 @@
{{#runs}}
### {{summary}}
{{/runs}}