Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/flat-dingos-play.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@redocly/cli": minor
---

Added support for `junit` output in the `scorecard-classic` command.
20 changes: 18 additions & 2 deletions docs/@v2/commands/scorecard-classic.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The `scorecard-classic` command requires a scorecard configuration in your Redoc
redocly scorecard-classic <api> --project-url=<url>
redocly scorecard-classic <api> --config=<path>
redocly scorecard-classic <api> --format=json
redocly scorecard-classic <api> --format=junit
redocly scorecard-classic <api> --target-level=<level>
redocly scorecard-classic <api> --verbose
```
Expand All @@ -25,7 +26,7 @@ redocly scorecard-classic <api> --verbose
| -------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| api | string | Path to the API description filename or alias that you want to evaluate. See [the API section](#specify-api) for more details. |
| --config | string | Specify path to the [configuration file](#use-alternative-configuration-file). |
| --format | string | Format for the output.<br />**Possible values:** `stylish`, `json`, `checkstyle`. Default value is `stylish`. |
| --format | string | Format for the output.<br />**Possible values:** `stylish`, `json`, `checkstyle`, `junit`. Default value is `stylish`. |
| --help | boolean | Show help. |
| --project-url | string | URL to the project scorecard configuration. Required if not configured in the Redocly configuration file. Example: `https://app.cloud.redocly.com/org/my-org/projects/my-project/scorecard-classic`. |
| --target-level | string | Target scorecard level to achieve. The command validates that the API meets this level and all preceding levels without errors. Exits with an error if the target level is not achieved. |
Expand Down Expand Up @@ -89,6 +90,21 @@ The JSON output is grouped by scorecard level and includes:
- location information (file path, line/column range, and JSON pointer)
- descriptive message about the violation

### Use JUnit output format

To generate JUnit XML for test reporting integrations, use the JUnit format:

```bash
redocly scorecard-classic openapi/openapi.yaml --format=junit
```

The JUnit output includes:

- one test suite for the `scorecard-classic` run
- one test case per reported issue
- scorecard level, rule, severity, and location details for each issue
- warnings as skipped test cases and errors as failed test cases

### Validate against a target level

Use the `--target-level` option to ensure your API meets a specific quality level. The command validates that your API satisfies the target level and all preceding levels without errors:
Expand Down Expand Up @@ -149,7 +165,7 @@ The scorecard evaluation categorizes issues into multiple levels based on your p
Each issue is associated with a specific scorecard level, allowing you to prioritize improvements.

The command displays the achieved scorecard level, which is the highest level your API meets without errors.
The achieved level is shown in both stylish, JSON and checkstyle output formats.
The achieved level is shown in stylish, JSON, checkstyle, and JUnit output formats.

When all checks pass, the command displays a success message:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ describe('printScorecardResultsAsJson', () => {
it('should strip ANSI codes from messages', () => {
const problems: ScorecardProblem[] = [
{
message: '\u001b[31mError message with color\u001b[0m',
message: '\u001b[1;31mError message with color\u001b[0m',
ruleId: 'test-rule',
severity: 'error',
suggest: [],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import * as openapiCore from '@redocly/openapi-core';

import { printScorecardResultsAsJunit } from '../formatters/junit-formatter.js';
import type { ScorecardProblem } from '../types.js';

const createMockSource = (absoluteRef: string) => ({
absoluteRef,
getAst: () => ({}),
getRootAst: () => ({}),
getLineColLocation: () => ({ line: 1, col: 1 }),
});

describe('printScorecardResultsAsJunit', () => {
beforeEach(() => {
vi.spyOn(openapiCore.logger, 'output').mockImplementation(() => {});
vi.spyOn(openapiCore.logger, 'info').mockImplementation(() => {});
});

const getOutput = () =>
(openapiCore.logger.output as any).mock.calls.map((call: any) => call[0]).join('');

it('outputs an empty junit suite when there are no problems', () => {
printScorecardResultsAsJunit('/api/openapi.yaml', [], 'Gold', true);

expect(getOutput()).toMatchInlineSnapshot(`
"<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="redocly scorecard-classic" tests="0" failures="0" errors="0" skipped="0">
<testsuite name="scorecard-classic" tests="0" failures="0" errors="0" skipped="0">
<properties>
<property name="api" value="/api/openapi.yaml" />
<property name="achievedLevel" value="Gold" />
</properties>
</testsuite>
</testsuites>

"
`);
});

it('maps errors to failures and warnings to skipped test cases', () => {
const problems: ScorecardProblem[] = [
{
message: 'Missing summary',
ruleId: 'operation-summary',
severity: 'error',
suggest: [],
location: [
{
source: createMockSource('/api/openapi.yaml') as any,
pointer: '#/paths/~1pets/get/summary',
reportOnKey: false,
},
],
scorecardLevel: 'Gold',
},
{
message: 'Missing description',
ruleId: 'operation-description',
severity: 'warn',
suggest: [],
location: [
{
source: createMockSource('/api/openapi.yaml') as any,
pointer: '#/info',
reportOnKey: false,
},
],
scorecardLevel: 'Silver',
},
];

printScorecardResultsAsJunit('/api/openapi.yaml', problems, 'Silver', false);

expect(getOutput()).toMatchInlineSnapshot(`
"<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="redocly scorecard-classic" tests="2" failures="1" errors="0" skipped="1">
<testsuite name="scorecard-classic" tests="2" failures="1" errors="0" skipped="1">
<properties>
<property name="api" value="/api/openapi.yaml" />
</properties>
<testcase classname="Gold" name="operation-summary" file="/api/openapi.yaml" line="1">
<failure message="Missing summary" type="operation-summary">Level: Gold
Rule: operation-summary
Severity: error
File: /api/openapi.yaml
Line: 1
Column: 1
Pointer: #/paths/~1pets/get/summary
Message: Missing summary</failure>
</testcase>
<testcase classname="Silver" name="operation-description" file="/api/openapi.yaml" line="1">
<skipped message="Missing description">Level: Silver
Rule: operation-description
Severity: warn
File: /api/openapi.yaml
Line: 1
Column: 1
Pointer: #/info
Message: Missing description</skipped>
</testcase>
</testsuite>
</testsuites>

"
`);
});

it('XML-escapes details and strips ANSI sequences from messages', () => {
const problems: ScorecardProblem[] = [
{
message: '\u001b[1;31mValue must be < 5 & > 0\u001b[0m',
ruleId: 'custom/my-rule',
severity: 'error',
suggest: [],
location: [
{
source: createMockSource('/api/openapi.yaml') as any,
pointer: '#/info',
reportOnKey: false,
},
],
scorecardLevel: 'Gold',
},
];

printScorecardResultsAsJunit('/api/openapi.yaml', problems, 'Gold', false);

expect(getOutput()).toMatchInlineSnapshot(`
"<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="redocly scorecard-classic" tests="1" failures="1" errors="0" skipped="0">
<testsuite name="scorecard-classic" tests="1" failures="1" errors="0" skipped="0">
<properties>
<property name="api" value="/api/openapi.yaml" />
</properties>
<testcase classname="Gold" name="custom/my-rule" file="/api/openapi.yaml" line="1">
<failure message="Value must be &lt; 5 &amp; &gt; 0" type="custom/my-rule">Level: Gold
Rule: custom/my-rule
Severity: error
File: /api/openapi.yaml
Line: 1
Column: 1
Pointer: #/info
Message: Value must be &lt; 5 &amp; &gt; 0</failure>
</testcase>
</testsuite>
</testsuites>

"
`);
});

it('falls back to the api path and zero coordinates when a problem has no location', () => {
const problems: ScorecardProblem[] = [
{
message: 'No location error',
ruleId: 'some-rule',
severity: 'error',
suggest: [],
location: [],
scorecardLevel: undefined,
},
];

printScorecardResultsAsJunit('/api/openapi.yaml', problems, 'Non Conformant', false);

expect(getOutput()).toMatchInlineSnapshot(`
"<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="redocly scorecard-classic" tests="1" failures="1" errors="0" skipped="0">
<testsuite name="scorecard-classic" tests="1" failures="1" errors="0" skipped="0">
<properties>
<property name="api" value="/api/openapi.yaml" />
</properties>
<testcase classname="Unknown" name="some-rule" file="/api/openapi.yaml" line="0">
<failure message="No location error" type="some-rule">Level: Unknown
Rule: some-rule
Severity: error
File: /api/openapi.yaml
Line: 0
Column: 0
Message: No location error</failure>
</testcase>
</testsuite>
</testsuites>

"
`);
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { logger, getLineColLocation } from '@redocly/openapi-core';

import { stripAnsiCodes } from '../../../utils/strip-ansi-codes.js';
import type { ScorecardProblem } from '../types.js';

type ScorecardLevel = {
Expand Down Expand Up @@ -46,11 +47,6 @@ function getRuleUrl(ruleId: string): string | undefined {
return undefined;
}

function stripAnsiCodes(text: string): string {
// eslint-disable-next-line no-control-regex
return text.replace(/\u001b\[\d+m/g, '');
}

export function printScorecardResultsAsJson(
problems: ScorecardProblem[],
achievedLevel: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { getLineColLocation, logger, xmlEscape } from '@redocly/openapi-core';

import { stripAnsiCodes } from '../../../utils/strip-ansi-codes.js';
import type { ScorecardProblem } from '../types.js';

type ProblemLocation = {
file: string;
line: number;
column: number;
pointer?: string;
};

function formatDetail(label: string, value: string | number): string {
return `${label}: ${xmlEscape(String(value))}`;
}

function getProblemLocation(problem: ScorecardProblem, apiPath: string): ProblemLocation {
const location = problem.location[0];

if (!location) {
return {
file: apiPath,
line: 0,
column: 0,
};
}

const lineColLocation = getLineColLocation(location);

return {
file: location.source.absoluteRef,
line: lineColLocation.start.line,
column: lineColLocation.start.col,
pointer: location.pointer,
};
}

function formatProblemDetails(problem: ScorecardProblem, location: ProblemLocation): string {
const details = [
formatDetail('Level', problem.scorecardLevel || 'Unknown'),
formatDetail('Rule', problem.ruleId),
formatDetail('Severity', problem.severity),
formatDetail('File', location.file),
formatDetail('Line', location.line),
formatDetail('Column', location.column),
];

if (location.pointer) {
details.push(formatDetail('Pointer', location.pointer));
}

details.push(formatDetail('Message', stripAnsiCodes(problem.message)));

return details.join('\n');
}

export function printScorecardResultsAsJunit(
path: string,
problems: ScorecardProblem[],
achievedLevel: string,
targetLevelAchieved: boolean
): void {
const failures = problems.filter((problem) => problem.severity === 'error').length;
const skipped = problems.filter((problem) => problem.severity === 'warn').length;

logger.output('<?xml version="1.0" encoding="UTF-8"?>\n');
logger.output(
`<testsuites name="redocly scorecard-classic" tests="${problems.length}" failures="${failures}" errors="0" skipped="${skipped}">\n`
);
logger.output(
`<testsuite name="scorecard-classic" tests="${problems.length}" failures="${failures}" errors="0" skipped="${skipped}">\n`
);
logger.output('<properties>\n');
logger.output(`<property name="api" value="${xmlEscape(path)}" />\n`);
if (targetLevelAchieved) {
logger.output(`<property name="achievedLevel" value="${xmlEscape(achievedLevel)}" />\n`);
}
logger.output('</properties>\n');

for (const problem of problems) {
const location = getProblemLocation(problem, path);
const level = problem.scorecardLevel || 'Unknown';
const message = stripAnsiCodes(problem.message);
const details = formatProblemDetails(problem, location);

logger.output(
`<testcase classname="${xmlEscape(level)}" name="${xmlEscape(
problem.ruleId
)}" file="${xmlEscape(location.file)}" line="${location.line}">\n`
);

if (problem.severity === 'warn') {
logger.output(`<skipped message="${xmlEscape(message)}">${details}</skipped>\n`);
} else {
logger.output(
`<failure message="${xmlEscape(message)}" type="${xmlEscape(
problem.ruleId
)}">${details}</failure>\n`
);
}

logger.output('</testcase>\n');
}

logger.output('</testsuite>\n');
logger.output('</testsuites>\n\n');
}
3 changes: 3 additions & 0 deletions packages/cli/src/commands/scorecard-classic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { CommandArgs } from '../../wrapper.js';
import { handleLoginAndFetchToken } from './auth/login-handler.js';
import { printScorecardResultsAsCheckstyle } from './formatters/checkstyle-formatter.js';
import { printScorecardResultsAsJson } from './formatters/json-formatter.js';
import { printScorecardResultsAsJunit } from './formatters/junit-formatter.js';
import { printScorecardResults } from './formatters/stylish-formatter.js';
import { fetchRemoteScorecardAndPlugins } from './remote/fetch-scorecard.js';
import { getTarget } from './targets-handler/targets-handler.js';
Expand Down Expand Up @@ -156,6 +157,8 @@ export async function handleScorecardClassic({
printScorecardResultsAsJson(result, achievedLevel, targetLevelAchieved, version);
} else if (argv.format === 'checkstyle') {
printScorecardResultsAsCheckstyle(path, result, achievedLevel, targetLevelAchieved);
} else if (argv.format === 'junit') {
printScorecardResultsAsJunit(path, result, achievedLevel, targetLevelAchieved);
} else {
printScorecardResults(result, achievedLevel, targetLevelAchieved);
}
Expand Down
Loading
Loading