Skip to content

Commit

Permalink
Improve reports loading and clean code
Browse files Browse the repository at this point in the history
- Improve JUnit test reports loading
  - Refine file patterns
  - Load all reports instead of only the first one
  - Read only top-level `<testsuite>` tags
- Exclude some directories from file search
- Improve the documentation
  - Clarify how reports are loaded
  - Add more examples
- Clean code (quotes, semicolons, ...)
  • Loading branch information
GaelGirodon committed May 20, 2024
1 parent c718821 commit 4379d86
Show file tree
Hide file tree
Showing 13 changed files with 103 additions and 44 deletions.
32 changes: 19 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ Only matched report formats will get a file uploaded to the Gist.

### Go

Write the verbose test output (`>` or `tee`) with coverage enabled to a
Write the verbose test output (`>` or `tee`) with coverage enabled to a single
`test*.{out,txt}` file next to the `go.mod` file:

- `RUN`, `PASS` and `FAIL` flags will be used to count tests
Expand All @@ -100,21 +100,26 @@ sure the last percentage is the global coverage value.

### JUnit

Write the test report to a file matching:
Write test reports to files matching:

- `**/TEST-*.xml`
- `**/report.xml`
- `**/*TEST*.xml`
- `**/*test*.xml`
- `**/*junit*.xml`

This is the default format and location with JUnit, but most test runners
support this format too, natively or using an additional reporter, e.g.:

- **Mocha**: `mocha --reporter mocha-junit-reporter`
- **Jest**: `jest --reporters="jest-junit"`
support this format too, natively or using an additional reporter:

- **Maven**: `mvn test` → `target/{surefire,failsafe}-reports/TEST-*.xml`
- **Gradle**: `gradle test` → `build/test-results/test/**/TEST-*.xml`
- **Node.js**: `node --test --test-reporter=junit --test-reporter-destination=report.xml`
- **Mocha**: `mocha --reporter mocha-junit-reporter` → `test-results.xml`
- **Jest**: `jest --reporters="jest-junit"` → `junit.xml`
- **Deno**: `deno test --junit-path=report.xml`
- **PHPUnit**: `phpunit --log-junit report.xml`

The number of tests and failures will be extracted from `<testsuite>` tags.
The number of tests and failures will be extracted from top-level `<testsuite>`
tags, from all matching and valid report files.

➡️ `{repo}-[{ref}-]junit-tests.json`

Expand All @@ -126,14 +131,14 @@ Write the coverage report to a file matching:
- `**/*coverage*.xml`

This is the default format and location with Cobertura, but most code coverage
tools support this format too, natively or using an additional reporter, e.g.:
tools support this format too, natively or using an additional reporter:

- **c8**: `c8 --reporter cobertura [...]`
- **nyc**: `nyc --reporter cobertura [...]`
- **c8**: `c8 --reporter cobertura [...]` → `coverage/cobertura-coverage.xml`
- **nyc**: `nyc --reporter cobertura [...]` → `coverage/cobertura-coverage.xml`
- **PHPUnit**: `phpunit --coverage-cobertura coverage.xml`

The coverage will be extracted from the `line-rate` attribute of the
`<coverage>` tag.
`<coverage>` tag, from the first matching and valid report file.

➡️ `{repo}-[{ref}-]cobertura-coverage.json`

Expand All @@ -147,7 +152,8 @@ Write the coverage report to a file matching:
This is the default format and location with JaCoCo, but some code coverage
tools may support this format too.

The coverage will be extracted from the last `<counter>` tag with type `LINE`.
The coverage will be extracted from the last `<counter>` tag with type `LINE`,
from the first matching and valid report file.

➡️ `{repo}-[{ref}-]jacoco-coverage.json`

Expand Down
4 changes: 2 additions & 2 deletions src/reports/cobertura.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { getReports } from './cobertura.js';

describe('reports/cobertura', function () {
describe('#getReports()', function () {
it(`should return coverage report`, async function () {
it('should return coverage report', async function () {
const reports = await getReports(join(process.cwd(), 'test/data/cobertura'));
assert.equal(reports.length, 1)
assert.equal(reports.length, 1);
assert.deepStrictEqual(reports, [
{ type: 'coverage', data: { coverage: 92.09999999999999 } }
]);
Expand Down
2 changes: 1 addition & 1 deletion src/reports/go.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export async function getReports(root) {
}
const passed = (report.match(/--- PASS/g) || []).length;
const failed = (report.match(/--- FAIL/g) || []).length;
badges.push({ type: 'tests', data: { passed, failed, tests } })
badges.push({ type: 'tests', data: { passed, failed, tests } });
const percentages = report.match(/(?<=\s)[0-9.]+(?=%)/g);
if (percentages && percentages.length >= 1) {
const coverage = parseFloat(percentages.slice(-1)[0]);
Expand Down
4 changes: 2 additions & 2 deletions src/reports/go.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { getReports } from './go.js';

describe('reports/go', function () {
describe('#getReports()', function () {
it(`should return tests and coverage reports`, async function () {
it('should return tests and coverage reports', async function () {
const reports = await getReports(join(process.cwd(), 'test/data/go'));
assert.equal(reports.length, 2)
assert.equal(reports.length, 2);
assert.deepStrictEqual(reports, [
{ type: 'tests', data: { passed: 3, failed: 0, tests: 3 } },
{ type: 'coverage', data: { coverage: 96.5 } }
Expand Down
2 changes: 1 addition & 1 deletion src/reports/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ export async function getReports() {
core.warning(`Skipping ${id} report format: ${error}`);
}
}
core.info(`Loaded ${all.length} reports`);
core.info(`Loaded ${all.length} report(s)`);
return all;
}
4 changes: 2 additions & 2 deletions src/reports/jacoco.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { getReports } from './jacoco.js';

describe('reports/jacoco', function () {
describe('#getReports()', function () {
it(`should return coverage report`, async function () {
it('should return coverage report', async function () {
const reports = await getReports(join(process.cwd(), 'test/data/jacoco'));
assert.equal(reports.length, 1)
assert.equal(reports.length, 1);
assert.deepStrictEqual(reports, [
{ type: 'coverage', data: { coverage: 65.61056105610561 } }
]);
Expand Down
53 changes: 39 additions & 14 deletions src/reports/junit.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,56 @@ import { globNearest } from '../util/index.js';
export async function getReports(root) {
core.info('Load JUnit tests report');
const patterns = [
join(root, '**/TEST-*.xml'),
join(root, '**/report.xml'),
join(root, '**/*TEST*.xml'),
join(root, '**/*test*.xml'),
join(root, '**/*junit*.xml')
];
const reports = await globNearest(patterns);
const badges = [];
const data = { passed: 0, failed: 0, tests: 0 };
let count = 0;
for (const r of reports) {
core.info(`Load JUnit report '${r}'`);
const report = await fs.readFile(r, { encoding: 'utf8' });
const testSuites = report.match(/<testsuite[^s]([^>]+)>/g);
if (!testSuites) {
const testSuites = await getTestSuiteTags(r);
if (testSuites.length === 0) {
core.info('Report is not a valid JUnit report');
continue; // Invalid report file, trying the next one
}
const data = { passed: 0, failed: 0, tests: 0 };
for (const ts of testSuites) {
data.failed += parseInt(ts.match(/failures="([0-9]+)"/)?.[1] ?? "0");
data.failed += parseInt(ts.match(/errors="([0-9]+)"/)?.[1] ?? "0");
data.tests += parseInt(ts.match(/tests="([0-9]+)"/)?.[1] ?? "0");
data.failed += parseInt(ts.match(/failures="([0-9]+)"/)?.[1] ?? '0');
data.failed += parseInt(ts.match(/errors="([0-9]+)"/)?.[1] ?? '0');
data.tests += parseInt(ts.match(/tests="([0-9]+)"/)?.[1] ?? '0');
}
data.passed = data.tests - data.failed;
badges.push({ type: 'tests', data })
break; // Successfully loaded a report file, can return now
count++;
}
core.info(`Loaded ${badges.length} JUnit report(s)`);
return badges;
data.passed = data.tests - data.failed;
core.info(`Loaded ${count} JUnit report(s)`);
return [{ type: 'tests', data }];
}

/**
* Extract top-level `<testsuite>` opening tags
* from the given JUnit test report file.
* Some test runners output nested `<testsuite>` tags
* (e.g. Node.js test runner), these nested tags must be
* ignored as values are aggregated in top-level ones.
* @param {string} path Path to the JUnit test report file
* @returns {Promise<string[]>} Top-level `<testsuite>` opening tags
*/
async function getTestSuiteTags(path) {
const testSuites = [];
let depth = 0;
const report = await fs.readFile(path, { encoding: 'utf8' });
const tags = report.match(/<\/?testsuite(?:[^s>][^>]+|\s*)>/g) ?? [];
for (const tag of tags) {
if (tag.startsWith('</')) {
depth--;
} else {
if (depth === 0) {
testSuites.push(tag);
}
depth++;
}
}
return testSuites;
}
6 changes: 3 additions & 3 deletions src/reports/junit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { getReports } from './junit.js';

describe('reports/junit', function () {
describe('#getReports()', function () {
it(`should return tests report`, async function () {
it('should return tests report', async function () {
const reports = await getReports(join(process.cwd(), 'test/data/junit'));
assert.equal(reports.length, 1)
assert.equal(reports.length, 1);
assert.deepStrictEqual(reports, [
{ type: 'tests', data: { passed: 6, failed: 4, tests: 10 } }
{ type: 'tests', data: { passed: 8, failed: 4, tests: 12 } }
]);
});
});
Expand Down
24 changes: 19 additions & 5 deletions src/util/index.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
import * as glob from '@actions/glob';

/**
* Returns files and directories matching the glob patterns,
* sorted by the nearest.
* Returns files matching the glob patterns,
* excluding some common unwanted directories from the search,
* sorted by ascending depth and name.
* @param {string[]} patterns Glob patterns
* @return {Promise<string[]>} Files sorted by the nearest.
*/
export async function globNearest(patterns) {
const globber = await glob.create(patterns.join('\n'));
const safePatterns = [
...patterns,
'!**/.git/**',
'!**/.idea/**',
'!**/.vscode/**',
'!**/node_modules/**',
'!**/vendor/**'
];
const globber = await glob.create(safePatterns.join('\n'), {
followSymbolicLinks: false,
implicitDescendants: false,
matchDirectories: false
});
const files = await globber.glob();
return files.sort((a, b) => {
return (a.match(/[\\/]/g)?.length ?? 0) - (b.match(/[\\/]/g)?.length ?? 0);
})
const depthDiff = (a.match(/[\\/]/g)?.length ?? 0) - (b.match(/[\\/]/g)?.length ?? 0);
return depthDiff !== 0 ? depthDiff : a.localeCompare(b);
});
}
File renamed without changes.
9 changes: 9 additions & 0 deletions test/data/junit/test-results-2.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="More suites" time="0.398" tests="2" failures="0" errors="0">
<testsuite name="Suite 4" tests="2" time="0.397" failures="0" errors="0">
<testsuite name="Nested suite 4.1" tests="2" time="0.397" failures="0" errors="0">
<testcase name="Case 4.1.1" time="0.192"></testcase>
<testcase name="Case 4.1.2" time="0.204"></testcase>
</testsuite>
</testsuite>
</testsuites>
5 changes: 5 additions & 0 deletions test/data/junit/vendor/test-results-3.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="Suites that should be excluded" time="9.999" tests="99" failures="19" errors="9">
<testsuite name="Ignore this" tests="99" time="9.999" failures="19" errors="9">
</testsuite>
</testsuites>
2 changes: 1 addition & 1 deletion test/e2e.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('CI Badges action', function () {
// Check update date
const updatedAt = new Date(response.data.updated_at).getTime();
const now = new Date().getTime();
assert.ok((now - updatedAt) / 1000 < 10)
assert.ok((now - updatedAt) / 1000 < 10);
// Check uploaded files
const files = Object.keys(response.data.files);
[
Expand Down

0 comments on commit 4379d86

Please sign in to comment.