@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 CHANGED
@@ -25,6 +25,62 @@ $ nb account me tokens create --data '{"name":"ci"}'
25
25
  - **Testing:** Vitest
26
26
  - **Distribution:** npm (`npx @nasebanal/cli`); single-binary builds are a planned follow-up
27
27
 
28
+ ## Reporting CI/CD test results (Assurance)
29
+
30
+ `nb assurance report upload` reads a test report on the runner, reduces it to a
31
+ small summary (counts, coverage-free metrics, the first failures, and for JUnit
32
+ the per-test cases) and reports it to a NASEBANAL Assurance project. The same line works in a pipeline and on a
33
+ laptop; in CI it needs only a token:
34
+
35
+ The **kind** is one of a fixed set of perspectives (`unit`, `e2e`, `contract`,
36
+ `security`, `load`); the **tool** is a free label (`vitest`, `pytest`, `playwright`,
37
+ `specmatic`, `zap`, `locust`, or `trivy`, `k6`, `cypress`...). What a result
38
+ means is carried by its kind and its numbers, so a scanner or runner can be swapped
39
+ without a change in Assurance. The CLI reads **JUnit XML** (any tool) and
40
+ **Locust CSV**; for a tool that writes something else, convert it to JUnit or give the
41
+ numbers with `--metric` (for a scanner: `critical`, `high`, `medium`, `low`, `info`).
42
+
43
+ ```bash
44
+ export NB_TOKEN=nbpat_… # a PAT or service-account token (no browser needed)
45
+
46
+ # JUnit XML: vitest (--reporter=junit), pytest (--junitxml), Specmatic, Playwright (--reporter=junit)
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
49
+
50
+ # Specmatic: the JUnit XML, plus its HTML report for the API coverage (coverage_pct = operations exercised / operations in the spec)
51
+ nb assurance report upload --file specmatic/junit/TEST-junit-jupiter.xml --kind contract --tool specmatic \
52
+ --coverage-report specmatic/report/test/html/index.html
53
+
54
+ # One repository, several API versions: label the result (the version is part of the suite's identity, so v1 and v2 of the same name sit side by side)
55
+ nb assurance report upload --file specmatic/v2/junit.xml --kind contract --tool specmatic --api-version v2
56
+ # unit tests hold for every version: leave --api-version out
57
+
58
+ # Extra numbers for the suite (repeatable), e.g. ZAP's findings by severity - what the Assurance security gate reads
59
+ nb assurance report upload --file zap/report/baseline-junit.xml --kind security --tool zap --metric high=0 --metric medium=3 --metric low=2
60
+
61
+ # Locust (--csv <prefix>): the *_stats.csv, with its *_failures / *_exceptions / *_stats_history siblings
62
+ nb assurance report upload --dir locust/logs/20261001_090000
63
+ ```
64
+
65
+ - **Project:** found by name, created when missing (`--no-create-project` to fail
66
+ instead). In CI the name defaults to the repository (`GITHUB_REPOSITORY` /
67
+ `CI_PROJECT_PATH`); elsewhere pass `--project`.
68
+ - **Run:** every job of one pipeline run shares a run key (CI: `GITHUB_RUN_ID` +
69
+ attempt, `CI_PIPELINE_ID`; locally `local-<sha>-<day>`), so a unit job and an e2e
70
+ job land in the same run, and re-running a job replaces its own result.
71
+ Commit, branch and CI URL are detected the same way; override with `--run-key`,
72
+ `--commit`, `--branch`, `--ci-url`.
73
+ - **Pass / fail:** JUnit - any failed or errored test fails the suite. Locust - an
74
+ unhandled task exception, or a failure ratio above `--max-fail-ratio` (default
75
+ `$LOCUST_MAX_FAIL_RATIO`, else 0), fails it - the same rule as nb-quickstarts'
76
+ `locust/bin/exit_code.py`. `--status passed|failed` overrides either.
77
+ - **Per-test cases (JUnit):** every test is sent with its name, outcome, time and, for a
78
+ failure, its message plus the stack trace, so the Assurance screens can list them. Up to
79
+ 5000 cases per suite - over that, failed and skipped tests are kept first and passed ones
80
+ fill the rest; a failure text is clipped to 2000 characters, and about 1 MB of failure text
81
+ is sent in total (names and outcomes always stay). Locust reports have no cases.
82
+ - `--dry-run` prints what would be sent and calls nothing.
83
+
28
84
  ## Installation
29
85
 
30
86
  The CLI installs the `nb` command. Node ≥ 20 is required.
@@ -40,7 +96,7 @@ nb --version
40
96
  nb --help
41
97
 
42
98
  # or run without installing (pin a version for reproducible scripts)
43
- npx @nasebanal/cli@0.1.0 account me get
99
+ npx @nasebanal/cli@0.2.0 account me get
44
100
  ```
45
101
 
46
102
  The published package bundles the OpenAPI specs, so no `nb-api-specs` checkout is
@@ -55,9 +111,72 @@ needed for a registry install.
55
111
 
56
112
  Versions follow SemVer (0.x until 1.0: breaking changes bump the minor). Each
57
113
  release bundles a fixed set of API contract versions, recorded in
58
- [`spec-versions.json`](spec-versions.json). A release is cut by pushing a
59
- `vX.Y.Z` tag that matches `package.json`; the `Release` workflow publishes it to
60
- npm with provenance.
114
+ [`spec-versions.json`](spec-versions.json).
115
+
116
+ ### Releasing (maintainers)
117
+
118
+ Published to npmjs.org through npm **trusted publishing** (OIDC): no npm token is
119
+ stored anywhere, and none is needed to run the release script - what gates a release is
120
+ **write access to this repository** (the script pushes a branch and a tag; the `Release`
121
+ workflow then publishes, and npm accepts that only from this repo's `release.yml`). It does
122
+ not work from a fork.
123
+
124
+ Two commands, split by the PR merge (which stays yours; the script never merges):
125
+
126
+ ```bash
127
+ bin/release.sh prepare 0.3.0 # before the merge: checks, version bump, tests, opens the release PR
128
+ # ... review and merge the PR ...
129
+ git checkout main && git pull
130
+ bin/release.sh publish # after the merge: tags v0.3.0 (asks first), waits for the workflow, checks npm
131
+ ```
132
+
133
+ Add `--dry-run` to either to do every check and print what it would do, writing nothing.
134
+
135
+ What it checks, and why:
136
+
137
+ - **Pinned contracts equal `released.json`.** The specs host serves only the latest
138
+ version, so a stale `spec-versions.json` fails `sync-specs` with a 404 (it happened for
139
+ `account` 1.4.0 -> 1.5.0). `prepare` stops and lists the differences;
140
+ `prepare <version> --sync-pins` adopts the released versions in the release PR.
141
+ Check https://api-specs.nasebanal.com/specs/released.json
142
+ - **Tag equals `package.json`'s version**, or the workflow fails.
143
+ - **The version is newer than `main`'s and not on npm yet**, and the tag does not exist.
144
+ - If the tag is pushed but the version is not on npm (the workflow failed), `publish` says
145
+ so and prints the `gh run rerun <id> --failed` to carry on - **do not tag again**.
146
+
147
+ By hand (what the script does): bump `version` in `package.json` and `package-lock.json`
148
+ (PR to `main`, squash-merge), tag the merge commit (`git tag vX.Y.Z && git push origin vX.Y.Z`),
149
+ and the workflow type-checks, tests and runs `npm publish --provenance`.
150
+
151
+ #### One-time setup: the Trusted Publisher on npmjs.com
152
+
153
+ Trusted publishing must be switched on by hand, once per package, by a maintainer of
154
+ `@nasebanal/cli` (npm only allows it for a package that already exists, so the first version,
155
+ 0.1.0, was published by hand). On https://www.npmjs.com/package/@nasebanal/cli/access ->
156
+ **Trusted Publisher** -> **GitHub Actions**:
157
+
158
+ | Field | Value |
159
+ |---|---|
160
+ | Organization or user | `nasebanal` |
161
+ | Repository | `nb-cli` |
162
+ | Workflow filename | `release.yml` (the file name only, exact case) |
163
+ | Environment name | empty (the workflow uses none) |
164
+ | Allowed actions | **tick "Allow `npm publish`"**; leave "Allow `npm dist-tag`" unticked |
165
+
166
+ Without that tick the publish fails with `npm error 404 ... could not be found or you do not
167
+ have permission` on the `PUT` - npm answers 404 for "not allowed", which reads like a missing
168
+ package. (This is how the first run of the workflow, for 0.2.0, failed: the setup was not done.)
169
+
170
+ Notes:
171
+
172
+ - A manual `npm publish` needs `--@nasebanal:registry=https://registry.npmjs.org/`
173
+ if your `~/.npmrc` maps the `@nasebanal` scope to GitHub Packages (that mapping
174
+ outranks `publishConfig.registry`; `publishConfig` also pins the scoped key as a
175
+ safeguard). npm also requires a passkey (WebAuthn) 2FA for interactive publishes. The
176
+ same override is needed for `npm view @nasebanal/cli` against the public registry.
177
+ - A freshly published version can 404 on the package document for a few minutes
178
+ while npm's cache catches up; `npm view` with `--prefer-online` or a retry is
179
+ enough (the script retries for you).
61
180
 
62
181
  ### From source (contributors)
63
182
 
package/dist/apis.js CHANGED
@@ -2,5 +2,6 @@ export const API_CATALOG = [
2
2
  { name: "account", spec: "account.yaml", summary: "User profiles, subscriptions, billing, and API tokens" },
3
3
  { name: "recorder", spec: "recorder.yaml", summary: "Records and tags (NASEBANAL Recorder)" },
4
4
  { name: "target", spec: "target.yaml", summary: "Targets, cells, and shares (NASEBANAL Target)" },
5
+ { name: "assurance", spec: "assurance.yaml", summary: "Monitors, projects and CI/CD test results (NASEBANAL Assurance)" },
5
6
  { name: "app-template", spec: "app-template.yaml", summary: "Reference API surface (App Template)" },
6
7
  ];
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { buildApiCommand } from "./spec/build.js";
13
13
  import { buildAuthCommand } from "./auth.js";
14
14
  import { specExists } from "./spec/loader.js";
15
15
  import { printError } from "./output.js";
16
+ import { buildReportUploadCommand } from "./reports/upload.js";
16
17
  // Single source of truth for the version: package.json (dist/ and src/ both sit
17
18
  // next to it). A hard-coded string here drifts from the release tag.
18
19
  const { version } = createRequire(import.meta.url)("../package.json");
@@ -39,7 +40,14 @@ for (const api of API_CATALOG) {
39
40
  });
40
41
  continue;
41
42
  }
42
- program.addCommand(buildApiCommand(api));
43
+ const group = buildApiCommand(api);
44
+ // `assurance report` already holds the generated availability report; the
45
+ // hand-written `upload` (test-result files -> a project) joins it.
46
+ if (api.name === "assurance") {
47
+ const report = group.commands.find((c) => c.name() === "report") ?? group.command("report").description("Reports");
48
+ report.addCommand(buildReportUploadCommand());
49
+ }
50
+ program.addCommand(group);
43
51
  }
44
52
  async function main() {
45
53
  try {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The page the browser lands on after `nb auth login` (the loopback redirect).
3
+ *
4
+ * Self-contained on purpose: one HTML string, inline CSS, the logo as a data
5
+ * URI, no script and no external request — it is served by a throwaway server
6
+ * on 127.0.0.1 and must work offline and under a strict CSP. Everything that
7
+ * comes from the URL (the OAuth `error` / `error_description`) is escaped:
8
+ * anything can send the browser to http://localhost:<port>/callback?error=…
9
+ */
10
+ import { LOGO_PNG_BASE64 } from "./logo.js";
11
+ /** `ja` when the browser prefers Japanese, else English. */
12
+ export function pickLang(acceptLanguage) {
13
+ const first = (acceptLanguage ?? "").split(",")[0]?.trim().toLowerCase() ?? "";
14
+ return first.startsWith("ja") ? "ja" : "en";
15
+ }
16
+ export function escapeHtml(text) {
17
+ return text
18
+ .replace(/&/g, "&amp;")
19
+ .replace(/</g, "&lt;")
20
+ .replace(/>/g, "&gt;")
21
+ .replace(/"/g, "&quot;")
22
+ .replace(/'/g, "&#39;");
23
+ }
24
+ const COPY = {
25
+ en: {
26
+ okTitle: "Logged in",
27
+ okLead: "You can close this tab and return to your terminal.",
28
+ okHint: "Check it with",
29
+ errTitle: "Login failed",
30
+ errLead: "Return to your terminal and run the login again.",
31
+ errHint: "Try again with",
32
+ detail: "Details",
33
+ },
34
+ ja: {
35
+ okTitle: "ログインしました",
36
+ okLead: "このタブを閉じて、ターミナルに戻ってください。",
37
+ okHint: "次のコマンドで確認できます",
38
+ errTitle: "ログインに失敗しました",
39
+ errLead: "ターミナルに戻って、もう一度ログインしてください。",
40
+ errHint: "もう一度ログインするには",
41
+ detail: "詳細",
42
+ },
43
+ };
44
+ const CHECK_ICON = `<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>`;
45
+ const CROSS_ICON = `<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17"/></svg>`;
46
+ const STYLE = `
47
+ :root{color-scheme:light dark;--bg:#f3f6fa;--bg2:#dfeaf5;--card:#fff;--fg:#1b2430;--muted:#5c6b7d;--line:#e2e8f0;--accent:#2f8fc7;--ok:#1f9d63;--okbg:#e3f6ec;--err:#c8423a;--errbg:#fde8e6;--code:#f1f5f9}
48
+ @media (prefers-color-scheme:dark){.logo{background:#fff;border-radius:50%;padding:3px;width:76px;height:76px}:root{--bg:#0e141b;--bg2:#15202c;--card:#18212b;--fg:#e6ebf1;--muted:#97a4b4;--line:#263241;--accent:#5cb6e6;--ok:#4cc38a;--okbg:#14301f;--err:#f0766d;--errbg:#3a1a18;--code:#0f1720}}
49
+ *{box-sizing:border-box}
50
+ body{margin:0;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1.25rem;padding:1.5rem 1rem;background:linear-gradient(160deg,var(--bg) 0%,var(--bg2) 100%);color:var(--fg);font-family:system-ui,-apple-system,"Segoe UI","Hiragino Sans","Noto Sans JP",sans-serif;line-height:1.6}
51
+ .card{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:1.25rem;padding:2.25rem 2rem 2rem;text-align:center;box-shadow:0 18px 50px -24px rgba(15,35,60,.35)}
52
+ .logo{width:72px;height:72px;display:block;margin:0 auto .5rem}
53
+ .brand{margin:0 0 1.5rem;font-size:.8rem;font-weight:700;letter-spacing:.18em;color:var(--muted)}
54
+ .status{width:4rem;height:4rem;margin:0 auto 1.1rem;border-radius:50%;display:flex;align-items:center;justify-content:center}
55
+ .status.ok{background:var(--okbg);color:var(--ok)}
56
+ .status.err{background:var(--errbg);color:var(--err)}
57
+ h1{margin:0 0 .5rem;font-size:1.5rem;line-height:1.3}
58
+ .lead{margin:0 0 1.5rem;color:var(--muted);text-wrap:balance;word-break:auto-phrase}
59
+ .hint{display:flex;flex-direction:column;gap:.35rem;align-items:center;padding:.9rem 1rem;border-radius:.75rem;background:var(--code);border:1px solid var(--line);font-size:.85rem;color:var(--muted)}
60
+ code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.95rem;color:var(--fg);overflow-wrap:anywhere}
61
+ .detail{margin:1rem 0 0;font-size:.8rem;color:var(--muted);text-align:left;overflow-wrap:anywhere}
62
+ .detail code{font-size:.8rem}
63
+ footer{font-size:.8rem;color:var(--muted)}
64
+ footer a{color:var(--accent);text-decoration:none}
65
+ footer a:hover{text-decoration:underline}
66
+ `;
67
+ /** Longest OAuth error text we echo back, so a crafted URL cannot fill the page. */
68
+ const MAX_DETAIL = 200;
69
+ export function renderLoginPage({ ok, lang, error }) {
70
+ const t = COPY[lang];
71
+ const title = ok ? t.okTitle : t.errTitle;
72
+ const lead = ok ? t.okLead : t.errLead;
73
+ const hint = ok ? t.okHint : t.errHint;
74
+ const command = ok ? "nb auth status" : "nb auth login";
75
+ const detail = !ok && error
76
+ ? `<p class="detail">${t.detail}: <code>${escapeHtml(error.slice(0, MAX_DETAIL))}</code></p>`
77
+ : "";
78
+ return `<!doctype html>
79
+ <html lang="${lang}">
80
+ <head>
81
+ <meta charset="utf-8">
82
+ <meta name="viewport" content="width=device-width,initial-scale=1">
83
+ <meta name="robots" content="noindex">
84
+ <title>${escapeHtml(title)} · NASEBANAL CLI</title>
85
+ <style>${STYLE}</style>
86
+ </head>
87
+ <body>
88
+ <main class="card">
89
+ <img class="logo" src="data:image/png;base64,${LOGO_PNG_BASE64}" width="72" height="72" alt="NASEBANAL">
90
+ <p class="brand">NASEBANAL CLI</p>
91
+ <div class="status ${ok ? "ok" : "err"}" role="img" aria-label="${ok ? "success" : "error"}">${ok ? CHECK_ICON : CROSS_ICON}</div>
92
+ <h1>${escapeHtml(title)}</h1>
93
+ <p class="lead">${escapeHtml(lead)}</p>
94
+ <div class="hint"><span>${escapeHtml(hint)}</span><code>${command}</code></div>
95
+ ${detail}
96
+ </main>
97
+ <footer><a href="https://www.nasebanal.com">nasebanal.com</a></footer>
98
+ </body>
99
+ </html>`;
100
+ }
101
+ /**
102
+ * Headers for the callback page. The CSP allows only inline CSS and the
103
+ * data-URI logo: even a page that somehow echoed attacker text could not run
104
+ * script or load anything.
105
+ */
106
+ export const PAGE_HEADERS = {
107
+ "Content-Type": "text/html; charset=utf-8",
108
+ "Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'",
109
+ "X-Content-Type-Options": "nosniff",
110
+ "Referrer-Policy": "no-referrer",
111
+ "Cache-Control": "no-store",
112
+ };
package/dist/logo.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The NASEBANAL logo (128px PNG, same file as the web apps' nav bar) as base64,
3
+ * inlined so the browser page the loopback login serves needs no network
4
+ * request and no static-file route. Regenerate from
5
+ * nb-landing-page/public/icons/logo-128.png if the logo changes.
6
+ */
7
+ export const LOGO_PNG_BASE64 = "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";
package/dist/oauth.js CHANGED
@@ -27,6 +27,7 @@
27
27
  import { createServer } from "node:http";
28
28
  import { spawn } from "node:child_process";
29
29
  import { randomBytes, createHash } from "node:crypto";
30
+ import { PAGE_HEADERS, pickLang, renderLoginPage } from "./loginPage.js";
30
31
  const AUTH0_DOMAIN = process.env.NB_AUTH0_DOMAIN || "dev-bv0p1636dyofjbmg.us.auth0.com";
31
32
  const AUTH0_AUDIENCE = process.env.NB_AUTH0_AUDIENCE || "https://api.nasebanal.com";
32
33
  /** Public Client ID of the Native app (not a secret for public PKCE clients). */
@@ -104,9 +105,6 @@ function tokenError(data, fallback) {
104
105
  // ---------------------------------------------------------------------------
105
106
  // Loopback flow
106
107
  // ---------------------------------------------------------------------------
107
- const SUCCESS_HTML = `<!doctype html><meta charset="utf-8"><title>NASEBANAL CLI</title>
108
- <body style="font-family:system-ui;max-width:32rem;margin:4rem auto;text-align:center">
109
- <h1>✓ Logged in</h1><p>You can close this tab and return to your terminal.</p></body>`;
110
108
  function listen(server, port) {
111
109
  return new Promise((resolve, reject) => {
112
110
  server.once("error", reject);
@@ -131,8 +129,18 @@ function waitForCode(server, expectedState, timeoutMs) {
131
129
  const error = url.searchParams.get("error");
132
130
  const code = url.searchParams.get("code");
133
131
  const state = url.searchParams.get("state");
134
- res.writeHead(error ? 400 : 200, { "Content-Type": "text/html; charset=utf-8" });
135
- res.end(error ? `Login failed: ${error}. You can close this tab.` : SUCCESS_HTML);
132
+ // Decide success before answering, so the page never says "Logged in"
133
+ // for a callback the CLI is about to reject.
134
+ const failure = error
135
+ ? error
136
+ : state !== expectedState
137
+ ? "state_mismatch"
138
+ : !code
139
+ ? "missing_code"
140
+ : null;
141
+ const detail = [failure, error ? url.searchParams.get("error_description") : null].filter(Boolean).join(" — ");
142
+ res.writeHead(failure ? 400 : 200, PAGE_HEADERS);
143
+ res.end(renderLoginPage({ ok: !failure, lang: pickLang(req.headers["accept-language"]), error: detail }));
136
144
  clearTimeout(timer);
137
145
  if (error)
138
146
  return reject(tokenError({ error }, "Authorization failed"));
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Where a report came from, taken from the CI environment so a pipeline step
3
+ * needs no flags: GitHub Actions and GitLab CI are recognised; anywhere else
4
+ * falls back to git (and a per-day key, so a local re-run replaces rather than
5
+ * piles up). Every value can still be overridden on the command line.
6
+ */
7
+ import { execFileSync } from "node:child_process";
8
+ export function readGit() {
9
+ const git = (...args) => {
10
+ try {
11
+ return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ };
17
+ return { sha: git("rev-parse", "HEAD"), branch: git("branch", "--show-current") };
18
+ }
19
+ const nonEmpty = (v) => (v && v.trim() !== "" ? v : null);
20
+ export function detectCi(env, git = readGit, now = new Date()) {
21
+ if (env.GITHUB_ACTIONS === "true") {
22
+ const repo = nonEmpty(env.GITHUB_REPOSITORY);
23
+ const runId = nonEmpty(env.GITHUB_RUN_ID);
24
+ const server = nonEmpty(env.GITHUB_SERVER_URL) ?? "https://github.com";
25
+ return {
26
+ provider: "github",
27
+ runKey: runId ? `${runId}-${nonEmpty(env.GITHUB_RUN_ATTEMPT) ?? "1"}` : null,
28
+ commitSha: nonEmpty(env.GITHUB_SHA),
29
+ branch: nonEmpty(env.GITHUB_REF_NAME),
30
+ ciUrl: repo && runId ? `${server}/${repo}/actions/runs/${runId}` : null,
31
+ project: repo,
32
+ repo,
33
+ };
34
+ }
35
+ if (env.GITLAB_CI === "true") {
36
+ const path = nonEmpty(env.CI_PROJECT_PATH);
37
+ const host = nonEmpty(env.CI_SERVER_HOST);
38
+ return {
39
+ provider: "gitlab",
40
+ runKey: nonEmpty(env.CI_PIPELINE_ID),
41
+ commitSha: nonEmpty(env.CI_COMMIT_SHA),
42
+ branch: nonEmpty(env.CI_COMMIT_REF_NAME),
43
+ ciUrl: nonEmpty(env.CI_PIPELINE_URL),
44
+ project: path,
45
+ repo: path && host && host !== "gitlab.com" ? `${host}/${path}` : path,
46
+ };
47
+ }
48
+ const { sha, branch } = git();
49
+ const day = now.toISOString().slice(0, 10).replace(/-/g, "");
50
+ return {
51
+ provider: "local",
52
+ runKey: sha ? `local-${sha.slice(0, 7)}-${day}` : `local-${day}`,
53
+ commitSha: sha,
54
+ branch,
55
+ ciUrl: null,
56
+ project: null,
57
+ repo: null,
58
+ };
59
+ }
@@ -0,0 +1,56 @@
1
+ /** Minimal RFC 4180 CSV reader (quoted fields, doubled quotes, CRLF) - Locust's error text contains commas and quotes. */
2
+ export function parseCsv(text) {
3
+ const rows = [];
4
+ let row = [];
5
+ let field = "";
6
+ let quoted = false;
7
+ for (let i = 0; i < text.length; i++) {
8
+ const ch = text[i];
9
+ if (quoted) {
10
+ if (ch === '"') {
11
+ if (text[i + 1] === '"') {
12
+ field += '"';
13
+ i++;
14
+ }
15
+ else {
16
+ quoted = false;
17
+ }
18
+ }
19
+ else {
20
+ field += ch;
21
+ }
22
+ }
23
+ else if (ch === '"') {
24
+ quoted = true;
25
+ }
26
+ else if (ch === ",") {
27
+ row.push(field);
28
+ field = "";
29
+ }
30
+ else if (ch === "\n" || ch === "\r") {
31
+ if (ch === "\r" && text[i + 1] === "\n")
32
+ i++;
33
+ row.push(field);
34
+ field = "";
35
+ if (row.length > 1 || row[0] !== "")
36
+ rows.push(row);
37
+ row = [];
38
+ }
39
+ else {
40
+ field += ch;
41
+ }
42
+ }
43
+ if (field !== "" || row.length > 0) {
44
+ row.push(field);
45
+ if (row.length > 1 || row[0] !== "")
46
+ rows.push(row);
47
+ }
48
+ return rows;
49
+ }
50
+ /** Rows keyed by the header row. */
51
+ export function toRecords(text) {
52
+ const [header, ...rows] = parseCsv(text);
53
+ if (!header)
54
+ return [];
55
+ return rows.map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] ?? ""])));
56
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * JUnit XML -> the normalised summary Assurance stores.
3
+ *
4
+ * Covers what vitest (`--reporter=junit`), pytest (`--junitxml`), Specmatic
5
+ * (`--junitReportDir`) and Playwright (`--reporter=junit`) write. Counting is
6
+ * done from the leaf `<testcase>` elements only, never from the `tests=` /
7
+ * `failures=` attributes on `<testsuite>`: those disagree between tools (some
8
+ * count errors as failures, some omit skipped) and double-count nested suites.
9
+ *
10
+ * A small tag scanner instead of an XML dependency: a JUnit file is flat enough,
11
+ * CDATA and comments are skipped whole so a stack trace containing "<testcase"
12
+ * cannot be mistaken for a test, and the CLI keeps a tiny dependency surface.
13
+ */
14
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
15
+ function decode(text) {
16
+ return text.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (whole, body) => {
17
+ if (body[0] === "#") {
18
+ const code = body[1] === "x" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
19
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
20
+ }
21
+ return ENTITIES[body] ?? whole;
22
+ });
23
+ }
24
+ function parseAttrs(raw) {
25
+ const attrs = {};
26
+ for (const m of raw.matchAll(/([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
27
+ attrs[m[1]] = decode(m[2] ?? m[3] ?? "");
28
+ }
29
+ return attrs;
30
+ }
31
+ // comment | CDATA | processing instruction | </close> | <open attrs [/]>
32
+ const TAG = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>|<\/([A-Za-z_][\w:.-]*)\s*>|<([A-Za-z_][\w:.-]*)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>/g;
33
+ /** Element text with CDATA unwrapped, or entity-decoded when it is plain text. */
34
+ function elementText(raw) {
35
+ const cdata = [...raw.matchAll(/<!\[CDATA\[([\s\S]*?)\]\]>/g)].map((m) => m[1]);
36
+ const text = (cdata.length > 0 ? cdata.join("") : decode(raw)).trim();
37
+ return text === "" ? null : text;
38
+ }
39
+ function caseName(attrs) {
40
+ const name = attrs.name ?? "(unnamed test)";
41
+ const cls = attrs.classname;
42
+ return cls && !name.startsWith(cls) ? `${cls} > ${name}` : name;
43
+ }
44
+ export function parseJunit(xml) {
45
+ const cases = [];
46
+ let current = null;
47
+ let textFrom = -1; // where the text of the open <failure>/<error> starts
48
+ let firstSuiteTime = null;
49
+ for (const m of xml.matchAll(TAG)) {
50
+ const [, closeName, openName, rawAttrs, selfClose] = m;
51
+ if (closeName) {
52
+ if ((closeName === "failure" || closeName === "error") && current && textFrom >= 0) {
53
+ current.trace = current.trace ?? elementText(xml.slice(textFrom, m.index));
54
+ textFrom = -1;
55
+ }
56
+ if (closeName === "testcase" && current) {
57
+ cases.push(current);
58
+ current = null;
59
+ }
60
+ continue;
61
+ }
62
+ if (!openName)
63
+ continue; // comment / CDATA / PI
64
+ const attrs = rawAttrs ? parseAttrs(rawAttrs) : {};
65
+ if (openName === "testsuites" || openName === "testsuite") {
66
+ if (firstSuiteTime === null && attrs.time !== undefined && Number.isFinite(Number(attrs.time))) {
67
+ firstSuiteTime = Number(attrs.time);
68
+ }
69
+ }
70
+ else if (openName === "testcase") {
71
+ current = { attrs, state: attrs.status === "skipped" ? "skipped" : "passed", message: null, trace: null };
72
+ if (selfClose) {
73
+ cases.push(current);
74
+ current = null;
75
+ }
76
+ }
77
+ else if (current && (openName === "failure" || openName === "error")) {
78
+ current.state = "failed";
79
+ current.message = current.message ?? (attrs.message || attrs.type || null);
80
+ if (!selfClose)
81
+ textFrom = (m.index ?? 0) + m[0].length;
82
+ }
83
+ else if (current && openName === "skipped" && current.state !== "failed") {
84
+ current.state = "skipped";
85
+ }
86
+ }
87
+ if (cases.length === 0) {
88
+ throw new Error("No <testcase> elements found - is this a JUnit XML file?");
89
+ }
90
+ let seconds = 0;
91
+ for (const c of cases) {
92
+ const t = Number(c.attrs.time);
93
+ if (Number.isFinite(t))
94
+ seconds += t;
95
+ }
96
+ if (seconds === 0 && firstSuiteTime !== null)
97
+ seconds = firstSuiteTime;
98
+ const failed = cases.filter((c) => c.state === "failed");
99
+ const skipped = cases.filter((c) => c.state === "skipped").length;
100
+ return {
101
+ total: cases.length,
102
+ passed: cases.length - failed.length - skipped,
103
+ failed: failed.length,
104
+ skipped,
105
+ duration_ms: seconds > 0 ? Math.round(seconds * 1000) : null,
106
+ failures: failed.map((c) => ({ name: caseName(c.attrs), message: c.message })),
107
+ cases: cases.map((c) => {
108
+ const t = Number(c.attrs.time);
109
+ // The failure text is the attribute plus the trace when the trace adds something.
110
+ const message = [c.message, c.trace && c.trace !== c.message ? c.trace : null].filter(Boolean).join("\n");
111
+ return {
112
+ name: caseName(c.attrs),
113
+ status: c.state,
114
+ duration_ms: c.attrs.time !== undefined && Number.isFinite(t) ? Math.round(t * 1000) : null,
115
+ message: message === "" ? null : message,
116
+ };
117
+ }),
118
+ };
119
+ }