Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"devDependencies": {
"@actions/exec": "^1.0.4",
"@types/jest": "^26.0.15",
"@types/mock-fs": "^4.13.0",
"@types/node": "^14.14.9",
"@typescript-eslint/parser": "^4.8.1",
"@vercel/ncc": "^0.27.0",
Expand All @@ -54,6 +55,8 @@
"jest-circus": "^26.6.3",
"js-yaml": "^4.0.0",
"lint-staged": "^10.5.3",
"mock-fs": "^4.13.0",
"nock": "^13.0.7",
"prettier": "2.2.1",
"ts-jest": "^26.5.0",
"typescript": "^4.1.3"
Expand Down
59 changes: 59 additions & 0 deletions src/__tests__/comment.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import nock from 'nock';
import updateOrCreateComment from '../comment';

jest.mock('@actions/github', () => ({
...jest.requireActual<any>('@actions/github'),
context: {
repo: {
repo: 'github-action-jest',
owner: 'jlndk',
},
payload: {
number: 42,
},
},
}));

describe('updateOrCreateComment', () => {
const url = 'https://api.github.com';
const basePath = '/repos/jlndk/github-action-jest/issues';

it('creates a new comment if old one does not exist', async () => {
let postedBody: any = null;
nock(url).get(`${basePath}/42/comments`).reply(200, []);

nock(url)
.post(`${basePath}/42/comments`, (body) => {
postedBody = eval(body);
return body;
})
.reply(200, []);

const comment = 'hello world';

await updateOrCreateComment(comment, 'abc', 'id');

expect(postedBody).toEqual({ body: comment + 'id' });
});

it('can update an existing comment if it exists', async () => {
let postedBody: any = null;
nock(url)
.get(`${basePath}/42/comments`)
.reply(200, [{ id: 42, body: 'id foobar' }]);

nock(url)
.patch(`${basePath}/comments/42`, (body) => {
postedBody = eval(body);
return body;
})
.reply(200, []);

const comment = 'hello world';

await updateOrCreateComment(comment, 'abc', 'id');

expect(postedBody).toEqual({ body: comment + 'id' });
});
});
30 changes: 29 additions & 1 deletion src/__tests__/run.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { exec } from '@actions/exec';
import runJest, { makeJestArgs, getJestCommand } from '../run';
import mockFs from 'mock-fs';
import runJest, { makeJestArgs, getJestCommand, readTestResults } from '../run';

beforeEach(() => {
mockFs({
'foobar.json': JSON.stringify({ foo: 'bar', baz: 2 }),
'invalid.json': 'this is invalid json',
});
});

afterEach(() => {
mockFs.restore();
});

jest.mock('@actions/github', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand All @@ -15,6 +27,8 @@ jest.mock('@actions/github', () => ({
},
}));

jest.mock('@actions/core');

jest.mock('@actions/exec');

describe('runJest', () => {
Expand All @@ -31,6 +45,20 @@ describe('runJest', () => {
});
});

describe('readTestResults', () => {
it('reads and parses test results', () => {
const actual = readTestResults('foobar.json');

expect(actual).toEqual({ foo: 'bar', baz: 2 });
});
it('throws if not able to parse test results', () => {
expect(() => readTestResults('invalid.json')).toThrow();
});
it('throws if test results does not exist', () => {
expect(() => readTestResults('notexisting.json')).toThrow();
});
});

describe('makeJestArgs', () => {
const baseArgs = ['--testLocationInResults', '--json', `--outputFile=foo.json`];
it('returns base args', () => {
Expand Down
88 changes: 88 additions & 0 deletions src/__tests__/testResults.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { FormattedTestResults } from '@jest/test-result/build/types';
import { startGroup, endGroup } from '@actions/core';
import { issueCommand } from '@actions/core/lib/command';
import { reportTestResults, printAnnotation } from '../testResults';

jest.mock('@actions/core');
jest.mock('@actions/core/lib/command');

describe('reportTestResults', () => {
it('does nothing if numFailedTests is zero', () => {
reportTestResults({
numFailedTests: 0,
} as FormattedTestResults);

expect(startGroup).not.toBeCalled();
expect(issueCommand).toBeCalledTimes(0);
expect(endGroup).not.toBeCalled();
});

it('prints failureMessages', () => {
const results = {
numFailedTests: 1,
testResults: [
{
assertionResults: [{ failureMessages: ['(foobar.js:42:69)this is a failure'] }],
},
],
} as FormattedTestResults;

reportTestResults(results);

expect(startGroup).toBeCalledWith('Jest Annotations');
expect(issueCommand).toBeCalledTimes(1);
expect(endGroup).toBeCalled();
});

it('skips printing message if it does not match regex', () => {
const results = {
numFailedTests: 1,
testResults: [
{
assertionResults: [{ failureMessages: ['this does not match regex'] }],
},
],
} as FormattedTestResults;

reportTestResults(results);

expect(startGroup).toBeCalledWith('Jest Annotations');
expect(issueCommand).not.toBeCalled();
expect(endGroup).toBeCalled();
});

it('skips printing message failureMessages does not exist', () => {
const results = {
numFailedTests: 1,
testResults: [{ assertionResults: [{ failureMessages: null }] }],
} as FormattedTestResults;

reportTestResults(results);

expect(startGroup).toBeCalledWith('Jest Annotations');
expect(issueCommand).not.toBeCalled();
expect(endGroup).toBeCalled();
});
});

describe('printAnnotation', () => {
it('parses and prints message', () => {
const msg = '(foobar.js:42:69)this is a failure';

printAnnotation(msg);

expect(issueCommand).toBeCalledWith(
'error',
{ file: 'foobar.js', line: '42', col: '69' },
'(foobar.js:42:69)this is a failure'
);
});

it('skips printing message if it does not match regex', () => {
const msg = 'this does not match regex';

printAnnotation(msg);

expect(issueCommand).not.toBeCalled();
});
});
6 changes: 6 additions & 0 deletions src/args.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from 'path';
import { getInput } from '@actions/core';

export function getBooleanArg(key: string, required = false): boolean {
Expand All @@ -14,3 +15,8 @@ export function getGithubToken(): string {

return token;
}

export function getCWD(): string {
const workingDirectory = getInput('working-directory', { required: false });
return workingDirectory ? path.resolve(workingDirectory) : process.cwd();
}
76 changes: 49 additions & 27 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,48 @@
import path, { sep } from 'path';
import * as core from '@actions/core';
import { getGithubToken, getBooleanArg } from './args';
import { FormattedTestResults } from '@jest/test-result/build/types';
import { getGithubToken, getBooleanArg, getCWD } from './args';
import { generateCommentBody } from './coverage';
import updateOrCreateComment from './comment';
import runJest, { getJestCommand, readTestResults } from './run';
import { reportTestResults } from './testResults';

async function main(): Promise<void> {
// Get args
const baseCommand = core.getInput('test-command', { required: false }) ?? 'npm test';
const workingDirectory = core.getInput('working-directory', { required: false });
const shouldCommentCoverage = getBooleanArg('coverage-comment');
const dryRun = getBooleanArg('dry-run');
const runOnlyChangedFiles = getBooleanArg('changes-only');
const exitOnJestFail = getBooleanArg('fail-action-if-jest-fails');
const cwd = getCWD();

// Compute paths
const cwd = workingDirectory ? path.resolve(workingDirectory) : process.cwd();
const coverageFilePath = path.join(cwd + sep, 'jest.results.json');

// Run jest and read the results file
core.info('Executing jest');
const statusCode = await executeJest(coverageFilePath, cwd, shouldCommentCoverage);
const results = readTestResults(coverageFilePath);

// Parse the result file, output annotations, and exit if tests failed
core.info('Reporting test results');
reportTestResults(results);
exitIfFailed(statusCode);

// Return early if we should not post code coverage comment
if (!shouldCommentCoverage) {
core.info('Code coverage commenting is disabled. Skipping...');
return;
}

// Generate table for coverage data and output it
await outputCoverageTable(results);
}

async function executeJest(
coverageFilePath: string,
cwd: string,
shouldCommentCoverage: boolean
): Promise<number> {
const baseCommand = core.getInput('test-command', { required: false }) ?? 'npm test';
const runOnlyChangedFiles = getBooleanArg('changes-only');

// Make the jest command
const cmd = getJestCommand({
coverageFilePath,
Expand All @@ -27,50 +51,48 @@ async function main(): Promise<void> {
withCoverage: shouldCommentCoverage,
});

core.info('Executing jest');

// execute jest
const statusCode = await runJest({ cmd, cwd });
const results = readTestResults(coverageFilePath);
return await runJest({ cmd, cwd });
}

core.info('Reporting test results');
reportTestResults(results);
function exitIfFailed(statusCode: number): void {
const exitOnJestFail = getBooleanArg('fail-action-if-jest-fails');

if (exitOnJestFail && statusCode !== 0) {
throw new Error(
'Jest returned non-zero exit code. Check annotations or debug output for more information.'
);
} else if (!exitOnJestFail) {
}

// Output status message if "fail-action-if-jest-fails" is disabled
if (!exitOnJestFail) {
core.info(
'Continuing even though jest failed, since "fail-action-if-jest-fails" is false.'
);
}
}

// Return early if we should not post code coverage comment
if (!shouldCommentCoverage) {
core.info('Code coverage commenting is disabled. Skipping...');
return;
}

const coverageMap = results.coverageMap;
async function outputCoverageTable({ coverageMap }: FormattedTestResults): Promise<void> {
const dryRun = getBooleanArg('dry-run');

if (!coverageMap) {
throw new Error('Could not find coverage info in jest results file');
}

core.info('Generating coverage table');

// Make the comment content
const commentContent = generateCommentBody(coverageMap);

if (dryRun) {
core.debug(`Dry run detected: Here's the content of the comment:`);
core.debug(commentContent);
} else {
core.info('Posting comment');
const githubToken = getGithubToken();
// Post the comment.
await updateOrCreateComment(commentContent, githubToken);
return;
}

core.info('Posting comment');
const githubToken = getGithubToken();
// Post the comment.
await updateOrCreateComment(commentContent, githubToken);
}

main().catch((err) => {
Expand Down
14 changes: 4 additions & 10 deletions src/run.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { readFileSync } from 'fs';
import { exec } from '@actions/exec';
import { context } from '@actions/github';
import type { FormattedTestResults } from '@jest/test-result/build/types';
import { debug, endGroup, startGroup } from '@actions/core';
import { FormattedTestResults } from '@jest/test-result/build/types';

export type RunJestOptions = {
cmd: string;
Expand Down Expand Up @@ -34,13 +34,7 @@ export default async function runJest({ cmd, cwd }: RunJestOptions): Promise<num
export function readTestResults(coverageFilePath: string): FormattedTestResults {
const content = readFileSync(coverageFilePath, 'utf-8');

const results = JSON.parse(content) as FormattedTestResults;

if (!results) {
throw new Error('Could not read test results from file');
}

return results;
return JSON.parse(content) as FormattedTestResults;
}

export function getJestCommand({ baseCommand, ...rest }: GetJestCommandArgs): string {
Expand All @@ -61,14 +55,14 @@ export function makeJestArgs({
withCoverage,
runOnlyChangedFiles,
}: MakeJestArgs): string[] {
const baseRef = context.payload.pull_request?.base.ref;

const args = ['--testLocationInResults', '--json', `--outputFile=${coverageFilePath}`];

if (withCoverage) {
args.push('--coverage');
}

const baseRef = context.payload.pull_request?.base.ref;

if (runOnlyChangedFiles && baseRef) {
args.push(`--changedSince=${baseRef}`);
}
Expand Down
Loading