@nasebanal/cli 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -4
- package/dist/apis.js +1 -0
- package/dist/index.js +9 -1
- package/dist/loginPage.js +112 -0
- package/dist/logo.js +7 -0
- package/dist/oauth.js +13 -5
- package/dist/reports/ci.js +59 -0
- package/dist/reports/csv.js +56 -0
- package/dist/reports/junit.js +119 -0
- package/dist/reports/locust.js +68 -0
- package/dist/reports/specmatic.js +69 -0
- package/dist/reports/upload.js +335 -0
- package/package.json +1 -1
- package/specs/account.yaml +484 -298
- package/specs/app-template.yaml +64 -62
- package/specs/assurance.yaml +1730 -0
- package/specs/recorder.yaml +375 -422
- package/specs/target.yaml +355 -363
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locust's CSV output (`--csv <prefix>`) -> the normalised `load` summary.
|
|
3
|
+
*
|
|
4
|
+
* The pass/fail rule is the one nb-quickstarts' `locust/bin/exit_code.py`
|
|
5
|
+
* applies to Locust's own exit code, reproduced from the files so a report can
|
|
6
|
+
* be uploaded after the run without carrying the exit code along: any unhandled
|
|
7
|
+
* task exception, or a failure ratio above the allowed one, fails the run.
|
|
8
|
+
*/
|
|
9
|
+
import { toRecords } from "./csv.js";
|
|
10
|
+
function num(value) {
|
|
11
|
+
if (value === undefined || value === "" || value === "N/A")
|
|
12
|
+
return null;
|
|
13
|
+
const n = Number(value);
|
|
14
|
+
return Number.isFinite(n) ? n : null;
|
|
15
|
+
}
|
|
16
|
+
const round = (n, digits = 2) => Math.round(n * 10 ** digits) / 10 ** digits;
|
|
17
|
+
export function summariseLocust(files, maxFailRatio) {
|
|
18
|
+
const stats = toRecords(files.stats);
|
|
19
|
+
const total = stats.find((r) => r.Name === "Aggregated");
|
|
20
|
+
if (!total)
|
|
21
|
+
throw new Error('No "Aggregated" row in the stats CSV - is this a Locust *_stats.csv file?');
|
|
22
|
+
const requests = num(total["Request Count"]) ?? 0;
|
|
23
|
+
const failed = num(total["Failure Count"]) ?? 0;
|
|
24
|
+
const metrics = { requests, failures: failed };
|
|
25
|
+
const put = (key, value) => {
|
|
26
|
+
if (value !== null)
|
|
27
|
+
metrics[key] = round(value);
|
|
28
|
+
};
|
|
29
|
+
put("avg_ms", num(total["Average Response Time"]));
|
|
30
|
+
put("p50_ms", num(total["50%"]) ?? num(total["Median Response Time"]));
|
|
31
|
+
put("p95_ms", num(total["95%"]));
|
|
32
|
+
put("p99_ms", num(total["99%"]));
|
|
33
|
+
put("max_ms", num(total["Max Response Time"]));
|
|
34
|
+
put("rps", num(total["Requests/s"]));
|
|
35
|
+
const ratio = requests > 0 ? failed / requests : 0;
|
|
36
|
+
metrics.error_rate_pct = round(ratio * 100);
|
|
37
|
+
const exceptions = files.exceptions ? toRecords(files.exceptions).length : 0;
|
|
38
|
+
if (files.exceptions !== undefined)
|
|
39
|
+
metrics.exceptions = exceptions;
|
|
40
|
+
const failures = (files.failures ? toRecords(files.failures) : []).map((r) => ({
|
|
41
|
+
name: `${r.Method ?? ""} ${r.Name ?? ""}`.trim() || "(unnamed request)",
|
|
42
|
+
message: r.Error ? `${r.Error}${r.Occurrences ? ` (x${r.Occurrences})` : ""}` : null,
|
|
43
|
+
}));
|
|
44
|
+
let duration_ms = null;
|
|
45
|
+
if (files.history) {
|
|
46
|
+
const stamps = toRecords(files.history)
|
|
47
|
+
.map((r) => num(r.Timestamp))
|
|
48
|
+
.filter((n) => n !== null);
|
|
49
|
+
if (stamps.length > 1)
|
|
50
|
+
duration_ms = Math.round((Math.max(...stamps) - Math.min(...stamps)) * 1000);
|
|
51
|
+
}
|
|
52
|
+
const reasons = [];
|
|
53
|
+
if (exceptions > 0)
|
|
54
|
+
reasons.push(`${exceptions} unhandled task exception(s)`);
|
|
55
|
+
if (ratio > maxFailRatio) {
|
|
56
|
+
reasons.push(`failure ratio ${(ratio * 100).toFixed(1)}% > allowed ${(maxFailRatio * 100).toFixed(1)}%`);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
total: requests,
|
|
60
|
+
passed: requests - failed,
|
|
61
|
+
failed,
|
|
62
|
+
duration_ms,
|
|
63
|
+
metrics,
|
|
64
|
+
failures,
|
|
65
|
+
status: reasons.length === 0 ? "passed" : "failed",
|
|
66
|
+
reasons,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Specmatic's API coverage -> the `coverage_pct` metric of a `contract` suite.
|
|
3
|
+
*
|
|
4
|
+
* `specmatic test` writes JUnit XML (the pass/fail counts) and an HTML report. The coverage - how many of
|
|
5
|
+
* the spec's operations a test actually exercised - is only in that report, as the JSON the page renders
|
|
6
|
+
* from (`const report = {...}`, a CTRF-style document: `results.summary.extra.executionDetails[]` with a
|
|
7
|
+
* `coverageMetrics` per spec). This reads the number out of either that HTML file or a plain JSON copy of
|
|
8
|
+
* the same document, so the spec-derived coverage reaches Assurance without the spec itself being uploaded.
|
|
9
|
+
*
|
|
10
|
+
* The HTML embedding is Specmatic's own, not a documented interface: when it changes the parse fails with
|
|
11
|
+
* a message naming the file, and the upload (without coverage) is still one flag away.
|
|
12
|
+
*/
|
|
13
|
+
/** The end index (exclusive) of the JSON object that starts at `start`, honouring strings and escapes. */
|
|
14
|
+
function objectEnd(text, start) {
|
|
15
|
+
let depth = 0;
|
|
16
|
+
let inString = false;
|
|
17
|
+
for (let i = start; i < text.length; i++) {
|
|
18
|
+
const c = text[i];
|
|
19
|
+
if (inString) {
|
|
20
|
+
if (c === "\\")
|
|
21
|
+
i++;
|
|
22
|
+
else if (c === '"')
|
|
23
|
+
inString = false;
|
|
24
|
+
}
|
|
25
|
+
else if (c === '"')
|
|
26
|
+
inString = true;
|
|
27
|
+
else if (c === "{")
|
|
28
|
+
depth++;
|
|
29
|
+
else if (c === "}" && --depth === 0)
|
|
30
|
+
return i + 1;
|
|
31
|
+
}
|
|
32
|
+
throw new Error("unterminated JSON object");
|
|
33
|
+
}
|
|
34
|
+
function reportJson(text) {
|
|
35
|
+
const trimmed = text.trimStart();
|
|
36
|
+
if (trimmed.startsWith("{"))
|
|
37
|
+
return JSON.parse(trimmed);
|
|
38
|
+
const marker = "const report = ";
|
|
39
|
+
const at = text.indexOf(marker);
|
|
40
|
+
if (at < 0)
|
|
41
|
+
throw new Error("no embedded report found");
|
|
42
|
+
const start = at + marker.length;
|
|
43
|
+
return JSON.parse(text.slice(start, objectEnd(text, start)));
|
|
44
|
+
}
|
|
45
|
+
export function parseSpecmaticCoverage(text) {
|
|
46
|
+
let doc;
|
|
47
|
+
try {
|
|
48
|
+
doc = reportJson(text);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
throw new Error(`Cannot read the Specmatic report (${err instanceof Error ? err.message : String(err)})`);
|
|
52
|
+
}
|
|
53
|
+
const details = doc?.results?.summary?.extra?.executionDetails;
|
|
54
|
+
if (!Array.isArray(details) || details.length === 0) {
|
|
55
|
+
throw new Error("The Specmatic report has no executionDetails (was it written by `specmatic test`?)");
|
|
56
|
+
}
|
|
57
|
+
let covered = 0;
|
|
58
|
+
let total = 0;
|
|
59
|
+
for (const d of details) {
|
|
60
|
+
const m = d.coverageMetrics;
|
|
61
|
+
if (!m)
|
|
62
|
+
continue;
|
|
63
|
+
covered += m.coveredOperations ?? 0;
|
|
64
|
+
total += m.totalOperationsWithFilters ?? m.totalOperations ?? 0;
|
|
65
|
+
}
|
|
66
|
+
if (total <= 0)
|
|
67
|
+
throw new Error("The Specmatic report lists no operations to cover");
|
|
68
|
+
return { coverage_pct: Math.round((covered / total) * 1000) / 10, covered, total };
|
|
69
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nb assurance report upload` - read a test report on the CI runner, reduce it
|
|
3
|
+
* to the normalised summary, and report it to a NASEBANAL Assurance project.
|
|
4
|
+
*
|
|
5
|
+
* The same command runs in a pipeline and on a laptop (nb-quickstarts' `make
|
|
6
|
+
* <tool>:report-upload` calls it): it takes its run key, commit, branch and
|
|
7
|
+
* project from the CI environment when there is one, and authenticates with
|
|
8
|
+
* `NB_TOKEN` (a PAT or service-account token) so no browser is needed.
|
|
9
|
+
*
|
|
10
|
+
* The parsing happens here, not on the server: the server stays small, parsers
|
|
11
|
+
* are testable offline, and what is uploaded is a few hundred bytes of summary
|
|
12
|
+
* instead of a megabyte of report.
|
|
13
|
+
*/
|
|
14
|
+
import { Command } from "commander";
|
|
15
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
16
|
+
import { basename, dirname, join } from "node:path";
|
|
17
|
+
import { resolveAccessToken, resolveEnv } from "../config.js";
|
|
18
|
+
import { request, resolveBaseUrl, ApiError } from "../http.js";
|
|
19
|
+
import { loadSpec } from "../spec/loader.js";
|
|
20
|
+
import { printJson } from "../output.js";
|
|
21
|
+
import { detectCi } from "./ci.js";
|
|
22
|
+
import { parseJunit } from "./junit.js";
|
|
23
|
+
import { summariseLocust } from "./locust.js";
|
|
24
|
+
import { parseSpecmaticCoverage } from "./specmatic.js";
|
|
25
|
+
export const SUITE_KINDS = ["unit", "e2e", "load", "contract", "security"];
|
|
26
|
+
/**
|
|
27
|
+
* The tool is a label (nb-assurance-api `TOOL_PATTERN`): what a result means is carried by its kind and its
|
|
28
|
+
* numbers, so any scanner or runner can be reported without a change here, as long as its report is
|
|
29
|
+
* JUnit XML (or the numbers are given with --metric). Only the formats this CLI can read matter.
|
|
30
|
+
*/
|
|
31
|
+
const TOOL_PATTERN = /^[a-z0-9][a-z0-9._-]{0,49}$/;
|
|
32
|
+
/** Tools whose report is not JUnit XML; every other tool is read as JUnit. */
|
|
33
|
+
const NON_JUNIT_TOOLS = { locust: "locust" };
|
|
34
|
+
/**
|
|
35
|
+
* Check a kind / tool / format combination before anything is parsed or sent.
|
|
36
|
+
* Returns the normalised (lower-case) tool label; throws a message naming what is wrong.
|
|
37
|
+
*/
|
|
38
|
+
export function checkSuite(kind, tool, format) {
|
|
39
|
+
const name = tool.trim().toLowerCase();
|
|
40
|
+
if (!TOOL_PATTERN.test(name)) {
|
|
41
|
+
throw new Error(`Tool '${tool}' is not a label: use lower-case letters, digits, '.', '_' and '-' (up to 50), e.g. vitest or trivy`);
|
|
42
|
+
}
|
|
43
|
+
const expected = NON_JUNIT_TOOLS[name] ?? "junit";
|
|
44
|
+
if (format !== expected) {
|
|
45
|
+
throw new Error(`Tool '${name}' reports in ${expected} format, but --format is ${format}`);
|
|
46
|
+
}
|
|
47
|
+
if (kind === "load" && name !== "locust" && format !== "junit")
|
|
48
|
+
throw new Error("Only locust reports are read as CSV");
|
|
49
|
+
return name;
|
|
50
|
+
}
|
|
51
|
+
/** The same label rule as the API (`utils/apiVersion.ts` in nb-assurance-api): checked here so a typo fails before anything is sent. */
|
|
52
|
+
const API_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
|
|
53
|
+
const METRIC_KEY = /^[a-z][a-z0-9_]{0,39}$/;
|
|
54
|
+
const MAX_METRICS = 20;
|
|
55
|
+
/** `--metric high=0 --metric medium=3`: extra numbers for the suite (the API keeps at most 20). */
|
|
56
|
+
export function parseMetrics(pairs) {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const pair of pairs) {
|
|
59
|
+
const eq = pair.indexOf("=");
|
|
60
|
+
const key = eq < 0 ? pair : pair.slice(0, eq).trim();
|
|
61
|
+
const raw = eq < 0 ? "" : pair.slice(eq + 1).trim();
|
|
62
|
+
const value = Number(raw);
|
|
63
|
+
if (!METRIC_KEY.test(key) || raw === "" || !Number.isFinite(value)) {
|
|
64
|
+
throw new Error(`--metric '${pair}' must be <key>=<number> with a snake_case key (e.g. high=0)`);
|
|
65
|
+
}
|
|
66
|
+
out[key] = value;
|
|
67
|
+
}
|
|
68
|
+
if (Object.keys(out).length > MAX_METRICS)
|
|
69
|
+
throw new Error(`At most ${MAX_METRICS} --metric values per suite`);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
export function checkApiVersion(label) {
|
|
73
|
+
const v = label.trim();
|
|
74
|
+
if (!API_VERSION_PATTERN.test(v)) {
|
|
75
|
+
throw new Error(`--api-version '${label}' is not a version label: use letters, digits, '.', '_' and '-' (up to 32), e.g. v1 or 2026-10`);
|
|
76
|
+
}
|
|
77
|
+
return v;
|
|
78
|
+
}
|
|
79
|
+
const MAX_FAILURES = 20;
|
|
80
|
+
/** Per-test cases: the API accepts this many (MAX_CASES), each message up to 2000 characters. */
|
|
81
|
+
export const MAX_CASES = 5000;
|
|
82
|
+
const MAX_CASE_MESSAGE = 2000;
|
|
83
|
+
/** Total failure text sent across all cases, so a suite where everything fails stays a modest upload. */
|
|
84
|
+
const MAX_CASE_MESSAGE_BUDGET = 1_000_000;
|
|
85
|
+
const clip = (s, max) => (s.length > max ? s.slice(0, max - 1) + "…" : s);
|
|
86
|
+
function toFailures(list) {
|
|
87
|
+
const out = list.slice(0, MAX_FAILURES).map((f) => ({
|
|
88
|
+
name: clip(f.name, 300),
|
|
89
|
+
...(f.message ? { message: clip(f.message, 500) } : {}),
|
|
90
|
+
}));
|
|
91
|
+
return out.length > 0 ? out : undefined;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The per-test cases to send. Over the limit, failed and skipped tests are kept first (they are
|
|
95
|
+
* what people open the list for) and passed ones fill the rest, all in their original order.
|
|
96
|
+
* Failure text is clipped per case and against a total budget; names and outcomes always stay.
|
|
97
|
+
*/
|
|
98
|
+
export function toCases(list) {
|
|
99
|
+
if (list.length === 0)
|
|
100
|
+
return undefined;
|
|
101
|
+
let keep = list.map((_, i) => i);
|
|
102
|
+
if (list.length > MAX_CASES) {
|
|
103
|
+
const notPassed = keep.filter((i) => list[i].status !== "passed");
|
|
104
|
+
const passed = keep.filter((i) => list[i].status === "passed");
|
|
105
|
+
keep = [...notPassed, ...passed].slice(0, MAX_CASES).sort((a, b) => a - b);
|
|
106
|
+
}
|
|
107
|
+
let budget = MAX_CASE_MESSAGE_BUDGET;
|
|
108
|
+
return keep.map((i) => {
|
|
109
|
+
const c = list[i];
|
|
110
|
+
let message;
|
|
111
|
+
if (c.message && budget > 0) {
|
|
112
|
+
message = clip(c.message, Math.min(MAX_CASE_MESSAGE, budget));
|
|
113
|
+
budget -= message.length;
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
name: clip(c.name, 300),
|
|
117
|
+
status: c.status,
|
|
118
|
+
...(c.duration_ms !== null ? { duration_ms: c.duration_ms } : {}),
|
|
119
|
+
...(message ? { message } : {}),
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/** The sibling file Locust wrote next to `<prefix>_stats.csv`, if there is one. */
|
|
124
|
+
function locustSibling(statsFile, suffix) {
|
|
125
|
+
const path = statsFile.replace(/_stats\.csv$/, suffix);
|
|
126
|
+
return path !== statsFile && existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
127
|
+
}
|
|
128
|
+
export function parseReport(opts) {
|
|
129
|
+
const text = readFileSync(opts.file, "utf8");
|
|
130
|
+
const base = { kind: opts.kind, tool: opts.tool, ...(opts.suiteName ? { name: opts.suiteName } : {}) };
|
|
131
|
+
if (opts.format === "junit") {
|
|
132
|
+
const j = parseJunit(text);
|
|
133
|
+
const status = opts.status ?? (j.failed === 0 ? "passed" : "failed");
|
|
134
|
+
return {
|
|
135
|
+
note: `${j.total} tests, ${j.failed} failed, ${j.skipped} skipped`,
|
|
136
|
+
suite: {
|
|
137
|
+
...base,
|
|
138
|
+
status,
|
|
139
|
+
tests: { total: j.total, passed: j.passed, failed: j.failed, skipped: j.skipped },
|
|
140
|
+
...(j.duration_ms !== null ? { duration_ms: j.duration_ms } : {}),
|
|
141
|
+
...(toFailures(j.failures) ? { failures: toFailures(j.failures) } : {}),
|
|
142
|
+
...(toCases(j.cases) ? { cases: toCases(j.cases) } : {}),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const l = summariseLocust({
|
|
147
|
+
stats: text,
|
|
148
|
+
failures: locustSibling(opts.file, "_failures.csv"),
|
|
149
|
+
exceptions: locustSibling(opts.file, "_exceptions.csv"),
|
|
150
|
+
history: locustSibling(opts.file, "_stats_history.csv"),
|
|
151
|
+
}, opts.maxFailRatio);
|
|
152
|
+
return {
|
|
153
|
+
note: `${l.total} requests, ${l.failed} failed${l.reasons.length ? ` (${l.reasons.join("; ")})` : ""}`,
|
|
154
|
+
suite: {
|
|
155
|
+
...base,
|
|
156
|
+
status: opts.status ?? l.status,
|
|
157
|
+
tests: { total: l.total, passed: l.passed, failed: l.failed },
|
|
158
|
+
...(l.duration_ms !== null ? { duration_ms: l.duration_ms } : {}),
|
|
159
|
+
metrics: l.metrics,
|
|
160
|
+
...(toFailures(l.failures) ? { failures: toFailures(l.failures) } : {}),
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** Find the project by name, or create it (unless told not to). Returns its id. */
|
|
165
|
+
export async function ensureProject(conn, name, repo, create) {
|
|
166
|
+
const find = async () => {
|
|
167
|
+
const res = await request({ ...conn, method: "GET", path: "/api/v1/projects", query: { name } });
|
|
168
|
+
const list = res.data.data.data;
|
|
169
|
+
return list[0]?.id ?? null;
|
|
170
|
+
};
|
|
171
|
+
const existing = await find();
|
|
172
|
+
if (existing)
|
|
173
|
+
return { id: existing, created: false };
|
|
174
|
+
if (!create) {
|
|
175
|
+
throw new Error(`No project named '${name}' (create it first, or drop --no-create-project).`);
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const res = await request({
|
|
179
|
+
...conn,
|
|
180
|
+
method: "POST",
|
|
181
|
+
path: "/api/v1/projects",
|
|
182
|
+
body: { name, ...(repo ? { repo } : {}) },
|
|
183
|
+
});
|
|
184
|
+
return { id: res.data.data.id, created: true };
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
// Another job of the same pipeline created it between our lookup and our create.
|
|
188
|
+
if (err instanceof ApiError && err.status === 409) {
|
|
189
|
+
const raced = await find();
|
|
190
|
+
if (raced)
|
|
191
|
+
return { id: raced, created: false };
|
|
192
|
+
}
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
export async function uploadReport(p) {
|
|
197
|
+
const project = await ensureProject(p.conn, p.project, p.repo, p.createProject);
|
|
198
|
+
const res = await request({
|
|
199
|
+
...p.conn,
|
|
200
|
+
method: "POST",
|
|
201
|
+
path: `/api/v1/projects/${encodeURIComponent(project.id)}/results`,
|
|
202
|
+
body: p.payload,
|
|
203
|
+
});
|
|
204
|
+
const data = res.data.data;
|
|
205
|
+
return { project_id: project.id, project_created: project.created, ...data };
|
|
206
|
+
}
|
|
207
|
+
function fail(message) {
|
|
208
|
+
throw new Error(message);
|
|
209
|
+
}
|
|
210
|
+
/** `--dir`: the newest-looking `*_stats.csv` (Locust writes `<prefix>_stats.csv` per run). */
|
|
211
|
+
function statsFileIn(dir) {
|
|
212
|
+
const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith("_stats.csv")) : [];
|
|
213
|
+
if (files.length === 0)
|
|
214
|
+
fail(`No *_stats.csv file in ${dir}`);
|
|
215
|
+
return join(dir, files.sort()[files.length - 1]);
|
|
216
|
+
}
|
|
217
|
+
function resolveFormat(opts, file) {
|
|
218
|
+
if (opts.format !== undefined) {
|
|
219
|
+
if (opts.format !== "junit" && opts.format !== "locust")
|
|
220
|
+
fail("--format must be junit or locust");
|
|
221
|
+
return opts.format;
|
|
222
|
+
}
|
|
223
|
+
if (opts.dir !== undefined || file.endsWith(".csv"))
|
|
224
|
+
return "locust";
|
|
225
|
+
if (file.endsWith(".xml"))
|
|
226
|
+
return "junit";
|
|
227
|
+
return fail("Cannot tell the report format from the file name; pass --format junit|locust");
|
|
228
|
+
}
|
|
229
|
+
export function buildReportUploadCommand() {
|
|
230
|
+
return new Command("upload")
|
|
231
|
+
.description("Report a test result file (JUnit XML or Locust CSV) to an Assurance project")
|
|
232
|
+
.option("--file <path>", "the report file (JUnit XML, or Locust *_stats.csv)")
|
|
233
|
+
.option("--dir <dir>", "a Locust output directory (uses its *_stats.csv)")
|
|
234
|
+
.option("--coverage-report <path>", "specmatic: its report (the HTML or a JSON copy); adds the API coverage as the coverage_pct metric")
|
|
235
|
+
.option("--format <format>", "junit | locust (default: from the file name)")
|
|
236
|
+
.option("--kind <kind>", `what the suite proves: ${SUITE_KINDS.join(" | ")} (locust: load)`)
|
|
237
|
+
.option("--tool <label>", "the producing tool, a free label, e.g. vitest, pytest, playwright, specmatic, zap, trivy (locust: locust)")
|
|
238
|
+
.option("--suite <name>", "tells apart several suites of one kind in a run (e.g. api, web)")
|
|
239
|
+
.option("--metric <key=value>", "an extra number for the suite, repeatable (e.g. --metric high=0 --metric medium=3; for security: critical, high, medium, low, info)", (v, all = []) => [...all, v])
|
|
240
|
+
.option("--api-version <label>", "the API version the result is for (e.g. v1, v2), part of the suite's identity next to its kind and name; leave it out for results that hold for every version, such as unit tests")
|
|
241
|
+
.option("--project <name>", "project name (CI: the repository); found, or created if missing")
|
|
242
|
+
.option("--repo <repo>", "repository recorded on a newly created project (CI: detected)")
|
|
243
|
+
.option("--run-key <key>", "identity of the pipeline run (CI: detected); all jobs of a run share it")
|
|
244
|
+
.option("--commit <sha>", "commit SHA (CI: detected)")
|
|
245
|
+
.option("--branch <name>", "branch (CI: detected)")
|
|
246
|
+
.option("--ci-url <url>", "link to the CI run (CI: detected)")
|
|
247
|
+
.option("--status <status>", "passed | failed; default is derived from the report")
|
|
248
|
+
.option("--max-fail-ratio <ratio>", "locust: allowed failure ratio 0..1 (default: $LOCUST_MAX_FAIL_RATIO or 0)")
|
|
249
|
+
.option("--no-create-project", "fail instead of creating the project when it does not exist")
|
|
250
|
+
.option("--dry-run", "print what would be sent and exit without calling the API")
|
|
251
|
+
.action(async (_flags, command) => {
|
|
252
|
+
const opts = command.optsWithGlobals();
|
|
253
|
+
const file = opts.file ?? (opts.dir !== undefined ? statsFileIn(opts.dir) : fail("Pass --file <path> (or --dir for Locust)"));
|
|
254
|
+
if (!existsSync(file))
|
|
255
|
+
fail(`File not found: ${file}`);
|
|
256
|
+
const format = resolveFormat(opts, file);
|
|
257
|
+
const kind = (opts.kind ?? (format === "locust" ? "load" : undefined));
|
|
258
|
+
if (!kind || !SUITE_KINDS.includes(kind))
|
|
259
|
+
fail(`--kind is required: ${SUITE_KINDS.join(" | ")}`);
|
|
260
|
+
const tool = opts.tool ?? (format === "locust" ? "locust" : undefined);
|
|
261
|
+
if (!tool)
|
|
262
|
+
fail("--tool is required for JUnit reports (e.g. --tool pytest)");
|
|
263
|
+
const checkedTool = checkSuite(kind, tool, format);
|
|
264
|
+
if (opts.status !== undefined && opts.status !== "passed" && opts.status !== "failed")
|
|
265
|
+
fail("--status must be passed or failed");
|
|
266
|
+
const ratioRaw = opts.maxFailRatio ?? process.env.LOCUST_MAX_FAIL_RATIO ?? "0";
|
|
267
|
+
const maxFailRatio = Number(ratioRaw);
|
|
268
|
+
if (!Number.isFinite(maxFailRatio) || maxFailRatio < 0 || maxFailRatio > 1)
|
|
269
|
+
fail("--max-fail-ratio must be a number between 0 and 1");
|
|
270
|
+
const apiVersion = opts.apiVersion === undefined ? undefined : checkApiVersion(opts.apiVersion);
|
|
271
|
+
const parsed = parseReport({
|
|
272
|
+
format,
|
|
273
|
+
kind,
|
|
274
|
+
tool: checkedTool,
|
|
275
|
+
suiteName: opts.suite,
|
|
276
|
+
status: opts.status,
|
|
277
|
+
maxFailRatio,
|
|
278
|
+
file,
|
|
279
|
+
});
|
|
280
|
+
if (opts.coverageReport !== undefined) {
|
|
281
|
+
if (kind !== "contract" || checkedTool !== "specmatic")
|
|
282
|
+
fail("--coverage-report is only for --kind contract --tool specmatic");
|
|
283
|
+
if (!existsSync(opts.coverageReport))
|
|
284
|
+
fail(`File not found: ${opts.coverageReport}`);
|
|
285
|
+
const cov = parseSpecmaticCoverage(readFileSync(opts.coverageReport, "utf8"));
|
|
286
|
+
parsed.suite.metrics = { ...parsed.suite.metrics, coverage_pct: cov.coverage_pct };
|
|
287
|
+
parsed.note += `, API coverage ${cov.coverage_pct}% (${cov.covered}/${cov.total} operations)`;
|
|
288
|
+
}
|
|
289
|
+
if (apiVersion !== undefined)
|
|
290
|
+
parsed.suite.api_version = apiVersion;
|
|
291
|
+
if (opts.metric !== undefined && opts.metric.length > 0) {
|
|
292
|
+
const extra = parseMetrics(opts.metric);
|
|
293
|
+
const merged = { ...parsed.suite.metrics, ...extra };
|
|
294
|
+
if (Object.keys(merged).length > MAX_METRICS)
|
|
295
|
+
fail(`At most ${MAX_METRICS} metrics per suite`);
|
|
296
|
+
parsed.suite.metrics = merged;
|
|
297
|
+
parsed.note += `, metrics ${Object.entries(extra).map(([k, v]) => `${k}=${v}`).join(" ")}`;
|
|
298
|
+
}
|
|
299
|
+
const ci = detectCi(process.env);
|
|
300
|
+
const runKey = opts.runKey ?? ci.runKey ?? fail("Cannot determine the run key; pass --run-key");
|
|
301
|
+
const project = opts.project ?? ci.project ?? fail("Cannot determine the project name; pass --project <name>");
|
|
302
|
+
const commit = opts.commit ?? ci.commitSha;
|
|
303
|
+
const branch = opts.branch ?? ci.branch;
|
|
304
|
+
const ciUrl = opts.ciUrl ?? ci.ciUrl;
|
|
305
|
+
const payload = {
|
|
306
|
+
run: {
|
|
307
|
+
key: runKey,
|
|
308
|
+
...(commit ? { commit_sha: commit } : {}),
|
|
309
|
+
...(branch ? { branch } : {}),
|
|
310
|
+
...(ciUrl ? { ci_url: ciUrl } : {}),
|
|
311
|
+
},
|
|
312
|
+
suite: parsed.suite,
|
|
313
|
+
};
|
|
314
|
+
const repo = opts.repo ?? ci.repo;
|
|
315
|
+
if (opts.dryRun) {
|
|
316
|
+
printJson({ project, repo, payload });
|
|
317
|
+
process.stderr.write(`(dry run) ${basename(dirname(file)) || "."}/${basename(file)}: ${parsed.note}\n`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const env = opts.env || resolveEnv();
|
|
321
|
+
const override = process.env.NB_BASE_URL?.replace(/\/$/, "");
|
|
322
|
+
const baseUrl = override ?? resolveBaseUrl(loadSpec("assurance.yaml").servers, env);
|
|
323
|
+
const token = opts.token || (await resolveAccessToken());
|
|
324
|
+
const result = await uploadReport({
|
|
325
|
+
conn: { baseUrl, token },
|
|
326
|
+
project,
|
|
327
|
+
repo,
|
|
328
|
+
createProject: opts.createProject,
|
|
329
|
+
payload,
|
|
330
|
+
});
|
|
331
|
+
printJson(result);
|
|
332
|
+
process.stderr.write(`✓ ${kind}/${checkedTool} ${payload.suite.status}: ${parsed.note} -> project '${project}'` +
|
|
333
|
+
`${result.project_created ? " (created)" : ""}, run '${runKey}'${result.replaced ? " (replaced)" : ""}\n`);
|
|
334
|
+
});
|
|
335
|
+
}
|