@nasebanal/cli 0.1.0 → 0.2.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.
@@ -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,288 @@
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
+ export const SUITE_KINDS = ["unit", "e2e", "load", "contract", "security"];
25
+ /**
26
+ * The tools Assurance accepts, per kind of suite. The CLI filters here, on the CI
27
+ * runner, so only reports that match are ever sent; the API enforces the same list
28
+ * (nb-assurance-api `ALLOWED_TOOLS`) because the CLI is not the only possible client.
29
+ * Keep the two in step with the `tool` enum in nb-api-specs' assurance package.
30
+ */
31
+ export const ALLOWED_TOOLS = {
32
+ unit: ["vitest", "pytest"],
33
+ e2e: ["playwright"],
34
+ contract: ["specmatic"],
35
+ security: ["zap"],
36
+ load: ["locust"],
37
+ };
38
+ /** Tools whose report is not JUnit XML. Every other allowed tool writes JUnit. */
39
+ const NON_JUNIT_TOOLS = { locust: "locust" };
40
+ /**
41
+ * Check a kind / tool / format combination before anything is parsed or sent.
42
+ * Returns the normalised (lower-case) tool name; throws a message naming what is supported.
43
+ */
44
+ export function checkSuite(kind, tool, format) {
45
+ const name = tool.trim().toLowerCase();
46
+ if (!ALLOWED_TOOLS[kind].includes(name)) {
47
+ throw new Error(`Tool '${tool}' is not supported for kind '${kind}'. Supported: ${ALLOWED_TOOLS[kind].join(", ")}`);
48
+ }
49
+ const expected = NON_JUNIT_TOOLS[name] ?? "junit";
50
+ if (format !== expected) {
51
+ throw new Error(`Tool '${name}' reports in ${expected} format, but --format is ${format}`);
52
+ }
53
+ return name;
54
+ }
55
+ const MAX_FAILURES = 20;
56
+ /** Per-test cases: the API accepts this many (MAX_CASES), each message up to 2000 characters. */
57
+ export const MAX_CASES = 5000;
58
+ const MAX_CASE_MESSAGE = 2000;
59
+ /** Total failure text sent across all cases, so a suite where everything fails stays a modest upload. */
60
+ const MAX_CASE_MESSAGE_BUDGET = 1_000_000;
61
+ const clip = (s, max) => (s.length > max ? s.slice(0, max - 1) + "…" : s);
62
+ function toFailures(list) {
63
+ const out = list.slice(0, MAX_FAILURES).map((f) => ({
64
+ name: clip(f.name, 300),
65
+ ...(f.message ? { message: clip(f.message, 500) } : {}),
66
+ }));
67
+ return out.length > 0 ? out : undefined;
68
+ }
69
+ /**
70
+ * The per-test cases to send. Over the limit, failed and skipped tests are kept first (they are
71
+ * what people open the list for) and passed ones fill the rest, all in their original order.
72
+ * Failure text is clipped per case and against a total budget; names and outcomes always stay.
73
+ */
74
+ export function toCases(list) {
75
+ if (list.length === 0)
76
+ return undefined;
77
+ let keep = list.map((_, i) => i);
78
+ if (list.length > MAX_CASES) {
79
+ const notPassed = keep.filter((i) => list[i].status !== "passed");
80
+ const passed = keep.filter((i) => list[i].status === "passed");
81
+ keep = [...notPassed, ...passed].slice(0, MAX_CASES).sort((a, b) => a - b);
82
+ }
83
+ let budget = MAX_CASE_MESSAGE_BUDGET;
84
+ return keep.map((i) => {
85
+ const c = list[i];
86
+ let message;
87
+ if (c.message && budget > 0) {
88
+ message = clip(c.message, Math.min(MAX_CASE_MESSAGE, budget));
89
+ budget -= message.length;
90
+ }
91
+ return {
92
+ name: clip(c.name, 300),
93
+ status: c.status,
94
+ ...(c.duration_ms !== null ? { duration_ms: c.duration_ms } : {}),
95
+ ...(message ? { message } : {}),
96
+ };
97
+ });
98
+ }
99
+ /** The sibling file Locust wrote next to `<prefix>_stats.csv`, if there is one. */
100
+ function locustSibling(statsFile, suffix) {
101
+ const path = statsFile.replace(/_stats\.csv$/, suffix);
102
+ return path !== statsFile && existsSync(path) ? readFileSync(path, "utf8") : undefined;
103
+ }
104
+ export function parseReport(opts) {
105
+ const text = readFileSync(opts.file, "utf8");
106
+ const base = { kind: opts.kind, tool: opts.tool, ...(opts.suiteName ? { name: opts.suiteName } : {}) };
107
+ if (opts.format === "junit") {
108
+ const j = parseJunit(text);
109
+ const status = opts.status ?? (j.failed === 0 ? "passed" : "failed");
110
+ return {
111
+ note: `${j.total} tests, ${j.failed} failed, ${j.skipped} skipped`,
112
+ suite: {
113
+ ...base,
114
+ status,
115
+ tests: { total: j.total, passed: j.passed, failed: j.failed, skipped: j.skipped },
116
+ ...(j.duration_ms !== null ? { duration_ms: j.duration_ms } : {}),
117
+ ...(toFailures(j.failures) ? { failures: toFailures(j.failures) } : {}),
118
+ ...(toCases(j.cases) ? { cases: toCases(j.cases) } : {}),
119
+ },
120
+ };
121
+ }
122
+ const l = summariseLocust({
123
+ stats: text,
124
+ failures: locustSibling(opts.file, "_failures.csv"),
125
+ exceptions: locustSibling(opts.file, "_exceptions.csv"),
126
+ history: locustSibling(opts.file, "_stats_history.csv"),
127
+ }, opts.maxFailRatio);
128
+ return {
129
+ note: `${l.total} requests, ${l.failed} failed${l.reasons.length ? ` (${l.reasons.join("; ")})` : ""}`,
130
+ suite: {
131
+ ...base,
132
+ status: opts.status ?? l.status,
133
+ tests: { total: l.total, passed: l.passed, failed: l.failed },
134
+ ...(l.duration_ms !== null ? { duration_ms: l.duration_ms } : {}),
135
+ metrics: l.metrics,
136
+ ...(toFailures(l.failures) ? { failures: toFailures(l.failures) } : {}),
137
+ },
138
+ };
139
+ }
140
+ /** Find the project by name, or create it (unless told not to). Returns its id. */
141
+ export async function ensureProject(conn, name, repo, create) {
142
+ const find = async () => {
143
+ const res = await request({ ...conn, method: "GET", path: "/api/v1/projects", query: { name } });
144
+ const list = res.data.data.data;
145
+ return list[0]?.id ?? null;
146
+ };
147
+ const existing = await find();
148
+ if (existing)
149
+ return { id: existing, created: false };
150
+ if (!create) {
151
+ throw new Error(`No project named '${name}' (create it first, or drop --no-create-project).`);
152
+ }
153
+ try {
154
+ const res = await request({
155
+ ...conn,
156
+ method: "POST",
157
+ path: "/api/v1/projects",
158
+ body: { name, ...(repo ? { repo } : {}) },
159
+ });
160
+ return { id: res.data.data.id, created: true };
161
+ }
162
+ catch (err) {
163
+ // Another job of the same pipeline created it between our lookup and our create.
164
+ if (err instanceof ApiError && err.status === 409) {
165
+ const raced = await find();
166
+ if (raced)
167
+ return { id: raced, created: false };
168
+ }
169
+ throw err;
170
+ }
171
+ }
172
+ export async function uploadReport(p) {
173
+ const project = await ensureProject(p.conn, p.project, p.repo, p.createProject);
174
+ const res = await request({
175
+ ...p.conn,
176
+ method: "POST",
177
+ path: `/api/v1/projects/${encodeURIComponent(project.id)}/results`,
178
+ body: p.payload,
179
+ });
180
+ const data = res.data.data;
181
+ return { project_id: project.id, project_created: project.created, ...data };
182
+ }
183
+ function fail(message) {
184
+ throw new Error(message);
185
+ }
186
+ /** `--dir`: the newest-looking `*_stats.csv` (Locust writes `<prefix>_stats.csv` per run). */
187
+ function statsFileIn(dir) {
188
+ const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith("_stats.csv")) : [];
189
+ if (files.length === 0)
190
+ fail(`No *_stats.csv file in ${dir}`);
191
+ return join(dir, files.sort()[files.length - 1]);
192
+ }
193
+ function resolveFormat(opts, file) {
194
+ if (opts.format !== undefined) {
195
+ if (opts.format !== "junit" && opts.format !== "locust")
196
+ fail("--format must be junit or locust");
197
+ return opts.format;
198
+ }
199
+ if (opts.dir !== undefined || file.endsWith(".csv"))
200
+ return "locust";
201
+ if (file.endsWith(".xml"))
202
+ return "junit";
203
+ return fail("Cannot tell the report format from the file name; pass --format junit|locust");
204
+ }
205
+ export function buildReportUploadCommand() {
206
+ return new Command("upload")
207
+ .description("Report a test result file (JUnit XML or Locust CSV) to an Assurance project")
208
+ .option("--file <path>", "the report file (JUnit XML, or Locust *_stats.csv)")
209
+ .option("--dir <dir>", "a Locust output directory (uses its *_stats.csv)")
210
+ .option("--format <format>", "junit | locust (default: from the file name)")
211
+ .option("--kind <kind>", `what the suite proves: ${SUITE_KINDS.join(" | ")} (locust: load)`)
212
+ .option("--tool <name>", "the producing tool, e.g. vitest, pytest, playwright, specmatic (locust: locust)")
213
+ .option("--suite <name>", "tells apart several suites of one kind in a run (e.g. api, web)")
214
+ .option("--project <name>", "project name (CI: the repository); found, or created if missing")
215
+ .option("--repo <repo>", "repository recorded on a newly created project (CI: detected)")
216
+ .option("--run-key <key>", "identity of the pipeline run (CI: detected); all jobs of a run share it")
217
+ .option("--commit <sha>", "commit SHA (CI: detected)")
218
+ .option("--branch <name>", "branch (CI: detected)")
219
+ .option("--ci-url <url>", "link to the CI run (CI: detected)")
220
+ .option("--status <status>", "passed | failed; default is derived from the report")
221
+ .option("--max-fail-ratio <ratio>", "locust: allowed failure ratio 0..1 (default: $LOCUST_MAX_FAIL_RATIO or 0)")
222
+ .option("--no-create-project", "fail instead of creating the project when it does not exist")
223
+ .option("--dry-run", "print what would be sent and exit without calling the API")
224
+ .action(async (_flags, command) => {
225
+ const opts = command.optsWithGlobals();
226
+ const file = opts.file ?? (opts.dir !== undefined ? statsFileIn(opts.dir) : fail("Pass --file <path> (or --dir for Locust)"));
227
+ if (!existsSync(file))
228
+ fail(`File not found: ${file}`);
229
+ const format = resolveFormat(opts, file);
230
+ const kind = (opts.kind ?? (format === "locust" ? "load" : undefined));
231
+ if (!kind || !SUITE_KINDS.includes(kind))
232
+ fail(`--kind is required: ${SUITE_KINDS.join(" | ")}`);
233
+ const tool = opts.tool ?? (format === "locust" ? "locust" : undefined);
234
+ if (!tool)
235
+ fail("--tool is required for JUnit reports (e.g. --tool pytest)");
236
+ const checkedTool = checkSuite(kind, tool, format);
237
+ if (opts.status !== undefined && opts.status !== "passed" && opts.status !== "failed")
238
+ fail("--status must be passed or failed");
239
+ const ratioRaw = opts.maxFailRatio ?? process.env.LOCUST_MAX_FAIL_RATIO ?? "0";
240
+ const maxFailRatio = Number(ratioRaw);
241
+ if (!Number.isFinite(maxFailRatio) || maxFailRatio < 0 || maxFailRatio > 1)
242
+ fail("--max-fail-ratio must be a number between 0 and 1");
243
+ const parsed = parseReport({
244
+ format,
245
+ kind,
246
+ tool: checkedTool,
247
+ suiteName: opts.suite,
248
+ status: opts.status,
249
+ maxFailRatio,
250
+ file,
251
+ });
252
+ const ci = detectCi(process.env);
253
+ const runKey = opts.runKey ?? ci.runKey ?? fail("Cannot determine the run key; pass --run-key");
254
+ const project = opts.project ?? ci.project ?? fail("Cannot determine the project name; pass --project <name>");
255
+ const commit = opts.commit ?? ci.commitSha;
256
+ const branch = opts.branch ?? ci.branch;
257
+ const ciUrl = opts.ciUrl ?? ci.ciUrl;
258
+ const payload = {
259
+ run: {
260
+ key: runKey,
261
+ ...(commit ? { commit_sha: commit } : {}),
262
+ ...(branch ? { branch } : {}),
263
+ ...(ciUrl ? { ci_url: ciUrl } : {}),
264
+ },
265
+ suite: parsed.suite,
266
+ };
267
+ const repo = opts.repo ?? ci.repo;
268
+ if (opts.dryRun) {
269
+ printJson({ project, repo, payload });
270
+ process.stderr.write(`(dry run) ${basename(dirname(file)) || "."}/${basename(file)}: ${parsed.note}\n`);
271
+ return;
272
+ }
273
+ const env = opts.env || resolveEnv();
274
+ const override = process.env.NB_BASE_URL?.replace(/\/$/, "");
275
+ const baseUrl = override ?? resolveBaseUrl(loadSpec("assurance.yaml").servers, env);
276
+ const token = opts.token || (await resolveAccessToken());
277
+ const result = await uploadReport({
278
+ conn: { baseUrl, token },
279
+ project,
280
+ repo,
281
+ createProject: opts.createProject,
282
+ payload,
283
+ });
284
+ printJson(result);
285
+ process.stderr.write(`✓ ${kind}/${checkedTool} ${payload.suite.status}: ${parsed.note} -> project '${project}'` +
286
+ `${result.project_created ? " (created)" : ""}, run '${runKey}'${result.replaced ? " (replaced)" : ""}\n`);
287
+ });
288
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nasebanal/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "gcloud-style command-line interface for NASEBANAL APIs, generated from nb-api-specs OpenAPI contracts.",
5
5
  "license": "MIT",
6
6
  "type": "module",