@nasebanal/cli 0.3.0 → 0.4.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 CHANGED
@@ -45,7 +45,17 @@ export NB_TOKEN=nbpat_… # a PAT or service-account token (no browser ne
45
45
 
46
46
  # JUnit XML: vitest (--reporter=junit), pytest (--junitxml), Specmatic, Playwright (--reporter=junit)
47
47
  nb assurance report upload --file report/junit.xml --kind unit --tool pytest
48
- nb assurance report upload --file results/junit.xml --kind e2e --tool playwright --suite web
48
+ nb assurance report upload --file results/junit.xml --kind e2e --tool playwright --component web
49
+
50
+ # Two scans of one tool on the same components (ZAP's baseline and full scan): a label tells them apart
51
+ nb assurance report upload --file report/baseline-junit.xml --kind security --tool zap --component frontend --label baseline
52
+ nb assurance report upload --file report/full-scan-junit.xml --kind security --tool zap --component frontend --label full-scan
53
+
54
+ # The tool's own version is informational and optional
55
+ nb assurance report upload --file report/junit.xml --kind unit --tool pytest --tool-version 9.1.0
56
+
57
+ # A result may be about several components: repeat the flag (new names are created in the project; none = `default`)
58
+ nb assurance report upload --file results/junit.xml --kind e2e --tool playwright --component frontend --component backend
49
59
 
50
60
  # Specmatic: the JUnit XML, plus its HTML report for the API coverage (coverage_pct = operations exercised / operations in the spec)
51
61
  nb assurance report upload --file specmatic/junit/TEST-junit-jupiter.xml --kind contract --tool specmatic \
@@ -7,6 +7,20 @@
7
7
  * task exception, or a failure ratio above the allowed one, fails the run.
8
8
  */
9
9
  import { toRecords } from "./csv.js";
10
+ /**
11
+ * The CSV's column names changed between Locust versions: 1.x wrote "# requests" / "# failures" / "Median response
12
+ * time" (and named the aggregate row "Total"), 2.x writes "Request Count" / "Failure Count" / "Median Response Time"
13
+ * ("Aggregated"). Each value is read under every name it has had, the first present one winning.
14
+ */
15
+ const COLUMNS = {
16
+ requests: ["Request Count", "# requests"],
17
+ failures: ["Failure Count", "# failures"],
18
+ median: ["Median Response Time", "Median response time"],
19
+ average: ["Average Response Time", "Average response time"],
20
+ max: ["Max Response Time", "Max response time"],
21
+ };
22
+ const has = (row, names) => names.some((n) => n in row);
23
+ const read = (row, names) => num(row[names.find((n) => n in row) ?? ""]);
10
24
  function num(value) {
11
25
  if (value === undefined || value === "" || value === "N/A")
12
26
  return null;
@@ -16,21 +30,26 @@ function num(value) {
16
30
  const round = (n, digits = 2) => Math.round(n * 10 ** digits) / 10 ** digits;
17
31
  export function summariseLocust(files, maxFailRatio) {
18
32
  const stats = toRecords(files.stats);
19
- const total = stats.find((r) => r.Name === "Aggregated");
33
+ // The row of all requests together: "Aggregated" (2.x) or "Total" (1.x).
34
+ const total = stats.find((r) => r.Name === "Aggregated" || r.Name === "Total");
20
35
  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;
36
+ throw new Error('No "Aggregated" (or "Total") row in the stats CSV - is this a Locust *_stats.csv file?');
37
+ // Without the counts the numbers below would silently be 0, a green result for a report nobody understood: refuse it.
38
+ if (!has(total, COLUMNS.requests) || !has(total, COLUMNS.failures)) {
39
+ throw new Error(`The stats CSV has no request / failure count column (looked for ${[...COLUMNS.requests, ...COLUMNS.failures].map((n) => `"${n}"`).join(", ")}; found: ${Object.keys(total).join(", ")}) - is it from a Locust version this nb does not know?`);
40
+ }
41
+ const requests = read(total, COLUMNS.requests) ?? 0;
42
+ const failed = read(total, COLUMNS.failures) ?? 0;
24
43
  const metrics = { requests, failures: failed };
25
44
  const put = (key, value) => {
26
45
  if (value !== null)
27
46
  metrics[key] = round(value);
28
47
  };
29
- put("avg_ms", num(total["Average Response Time"]));
30
- put("p50_ms", num(total["50%"]) ?? num(total["Median Response Time"]));
48
+ put("avg_ms", read(total, COLUMNS.average));
49
+ put("p50_ms", num(total["50%"]) ?? read(total, COLUMNS.median));
31
50
  put("p95_ms", num(total["95%"]));
32
51
  put("p99_ms", num(total["99%"]));
33
- put("max_ms", num(total["Max Response Time"]));
52
+ put("max_ms", read(total, COLUMNS.max));
34
53
  put("rps", num(total["Requests/s"]));
35
54
  const ratio = requests > 0 ? failed / requests : 0;
36
55
  metrics.error_rate_pct = round(ratio * 100);
@@ -127,7 +127,13 @@ function locustSibling(statsFile, suffix) {
127
127
  }
128
128
  export function parseReport(opts) {
129
129
  const text = readFileSync(opts.file, "utf8");
130
- const base = { kind: opts.kind, tool: opts.tool, ...(opts.suiteName ? { name: opts.suiteName } : {}) };
130
+ const base = {
131
+ kind: opts.kind,
132
+ tool: opts.tool,
133
+ ...(opts.components && opts.components.length > 0 ? { components: opts.components } : {}),
134
+ ...(opts.label ? { label: opts.label } : {}),
135
+ ...(opts.toolVersion ? { tool_version: opts.toolVersion } : {}),
136
+ };
131
137
  if (opts.format === "junit") {
132
138
  const j = parseJunit(text);
133
139
  const status = opts.status ?? (j.failed === 0 ? "passed" : "failed");
@@ -214,6 +220,49 @@ function statsFileIn(dir) {
214
220
  fail(`No *_stats.csv file in ${dir}`);
215
221
  return join(dir, files.sort()[files.length - 1]);
216
222
  }
223
+ /** At most this many components per result (the API's limit) and this many characters in a name. */
224
+ const MAX_COMPONENTS = 20;
225
+ const MAX_COMPONENT_LENGTH = 100;
226
+ /** The `--label`: trimmed, not empty, within the API's limit. */
227
+ function checkLabel(raw) {
228
+ if (raw === undefined)
229
+ return undefined;
230
+ const label = raw.trim();
231
+ if (label === "")
232
+ fail("--label must not be empty");
233
+ if (label.length > MAX_COMPONENT_LENGTH)
234
+ fail(`--label must be ${MAX_COMPONENT_LENGTH} characters or fewer`);
235
+ return label;
236
+ }
237
+ /** The `--tool-version`: trimmed, not empty, within the API's limit. */
238
+ function checkToolVersion(raw) {
239
+ if (raw === undefined)
240
+ return undefined;
241
+ const version = raw.trim();
242
+ if (version === "")
243
+ fail("--tool-version must not be empty");
244
+ if (version.length > 50)
245
+ fail("--tool-version must be 50 characters or fewer");
246
+ return version;
247
+ }
248
+ /** The `--component` names: trimmed, no empty one, no two alike ignoring case, within the API's limits. */
249
+ function checkComponents(raw) {
250
+ if (raw === undefined)
251
+ return undefined;
252
+ const out = [];
253
+ for (const value of raw) {
254
+ const name = value.trim();
255
+ if (name === "")
256
+ fail("--component must not be empty");
257
+ if (name.length > MAX_COMPONENT_LENGTH)
258
+ fail(`--component must be ${MAX_COMPONENT_LENGTH} characters or fewer: ${name.slice(0, 20)}…`);
259
+ if (!out.some((o) => o.toLowerCase() === name.toLowerCase()))
260
+ out.push(name);
261
+ }
262
+ if (out.length > MAX_COMPONENTS)
263
+ fail(`At most ${MAX_COMPONENTS} --component values`);
264
+ return out;
265
+ }
217
266
  function resolveFormat(opts, file) {
218
267
  if (opts.format !== undefined) {
219
268
  if (opts.format !== "junit" && opts.format !== "locust")
@@ -235,9 +284,11 @@ export function buildReportUploadCommand() {
235
284
  .option("--format <format>", "junit | locust (default: from the file name)")
236
285
  .option("--kind <kind>", `what the suite proves: ${SUITE_KINDS.join(" | ")} (locust: load)`)
237
286
  .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)")
287
+ .option("--component <name>", "a component the result is for (e.g. frontend, backend), repeatable when it covers several (e.g. an end-to-end test: --component frontend --component backend); new names are created in the project; leave it out for the component `default`", (v, all = []) => [...all, v])
288
+ .option("--label <name>", "tells apart results that are otherwise the same, such as two scans of one tool on the same components (e.g. --label baseline, --label full-scan); part of the result's identity, so the same label again replaces the result; optional")
289
+ .option("--tool-version <version>", "the version of the tool that produced the report (e.g. 9.1.0): informational, shown next to the tool, handy when a tool's new version changes its layout; optional")
239
290
  .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")
291
+ .option("--api-version <label>", "the API version the result is for (e.g. v1, v2), part of the result's identity next to its kind and components; leave it out for results that hold for every version, such as unit tests")
241
292
  .option("--project <name>", "project name (CI: the repository); found, or created if missing")
242
293
  .option("--repo <repo>", "repository recorded on a newly created project (CI: detected)")
243
294
  .option("--run-key <key>", "identity of the pipeline run (CI: detected); all jobs of a run share it")
@@ -272,7 +323,9 @@ export function buildReportUploadCommand() {
272
323
  format,
273
324
  kind,
274
325
  tool: checkedTool,
275
- suiteName: opts.suite,
326
+ components: checkComponents(opts.component),
327
+ label: checkLabel(opts.label),
328
+ toolVersion: checkToolVersion(opts.toolVersion),
276
329
  status: opts.status,
277
330
  maxFailRatio,
278
331
  file,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nasebanal/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -2,7 +2,7 @@ openapi: 3.0.3
2
2
  info:
3
3
  title: NASEBANAL Account API
4
4
  description: Centralized user and billing management API for NASEBANAL applications. Manages user profiles, per-service subscriptions, Stripe billing, and Personal Access Tokens (PAT).
5
- version: 1.5.1
5
+ version: 1.6.0
6
6
  contact:
7
7
  name: NASEBANAL
8
8
  url: https://www.nasebanal.com