@plune-ai/cli 0.31.0 → 0.32.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.
Files changed (3) hide show
  1. package/README.md +39 -9
  2. package/dist/cli.cjs +108 -70
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -122,7 +122,7 @@ the last resort, used only when `PLUNE_TOKEN` is set nowhere else. Nothing write
122
122
  | `plune login` | Save a [Plune platform](https://plune.ai/platform) API token so `sync` and `ingest` can reach it. The token is **checked against the API before it is saved**, so a wrong one fails here rather than two commands later. Get one at `https://beta.plune.ai` → Account → API tokens. Flags: `--token <token>` (omit to paste it or pipe it via stdin), `--skip-verify` (save without checking, for offline setup). In CI, skip this step: every platform command reads `PLUNE_TOKEN` from the environment first, and the saved login only when it is not set. |
123
123
  | `plune logout` | Remove the saved token. |
124
124
  | `plune sync` | Upload the latest local run to the platform. Flags: `--file <path>` to send a specific run JSON, `--fail-on-failures` to read the run's summary back once after the upload and exit with [the gate](#a-release-gate---fail-on-failures)'s code. Each upload carries an `Idempotency-Key` (SHA-256 of the body), so sending the same run again returns the same run rather than creating a second one. Against a platform with `POST /v1/jobs` the upload is also reported as a job (kind `sync`, counting the run's rows) that ends completed, or failed with the error text if the sync fails; an older platform without the route is not told, and a job report never fails the sync: when the sync goes through, one the platform did not take is said in one line on stderr. |
125
- | `plune run import <file>` | Turn a **JUnit XML** or **Playwright JSON** report, or an **Allure `allure-results` folder**, into a run in Plune. Needs no provider key — nothing is generated. Flags: `--format junit\|playwright-json\|allure` (detected from the file when omitted; a folder is Allure), `--name <text>` to call the run something other than the [folder-based default](#what-the-run-is-called), `--key <externalKey>` to land several reports in one run (with `PLUNE_SHARED_RUN=1` — see below), `--create` to offer unmatched tests to the review queue, `--fail-on-missing-attachments` to exit 1 when a file a JUnit report names with `[[ATTACHMENT\|path]]`, or an Allure result names as a `source`, is not there (the results are sent either way), `--upload-concurrency <n>` for files uploaded at once ([1 to 32, default 16](#many-screenshots-upload-concurrency-and-retries)), `--fail-on-failures` to exit with [the gate](#a-release-gate---fail-on-failures)'s code on the summary the platform froze when this import finished the run. |
125
+ | `plune run import <file \| folder>` | Turn a **JUnit XML** or **Playwright JSON** report, a **folder of JUnit XML files** (Maven Surefire's `target/surefire-reports`, Gradle's `build/test-results/test`), or an **Allure `allure-results` folder**, into a run in Plune. Needs no provider key — nothing is generated. Flags: `--format junit\|playwright-json\|allure` (detected when omitted: a folder with a `*-result.json` is Allure, any other folder is read as the JUnit `*.xml` files in it), `--name <text>` to call the run something other than the [folder-based default](#what-the-run-is-called), `--key <externalKey>` to land several reports in one run (with `PLUNE_SHARED_RUN=1` — see below), `--create` to offer unmatched tests to the review queue, `--fail-on-missing-attachments` to exit 1 when a file a JUnit report names with `[[ATTACHMENT\|path]]`, or an Allure result names as a `source`, is not there (the results are sent either way), `--upload-concurrency <n>` for files uploaded at once ([1 to 32, default 16](#many-screenshots-upload-concurrency-and-retries)), `--fail-on-failures` to exit with [the gate](#a-release-gate---fail-on-failures)'s code on the summary the platform froze when this import finished the run. |
126
126
  | `plune run start` | Open a platform run — or join the one already carrying `--key` — and print its id. Flags: `--key <externalKey>` (generated when absent), `--name <text>` to call the run something other than the [default](#what-the-run-is-called), `--json` for one machine-readable line. |
127
127
  | `plune run finish <id>` | Close a platform run. This is what the reporter tells you to do for a run it had to leave open. Flags: `--terminate` to record it as cut short, `--reason <text>`, `--fail-on-failures` to exit with [the gate](#a-release-gate---fail-on-failures)'s code on the summary the platform froze. |
128
128
  | `plune run exec -- <command>` | Open a run, run the command inside it, close the run — and exit with whatever the command returned. Sets `PLUNE_SHARED_RUN` for you, so anything reporting inside joins that run. Flags: `--key <externalKey>`, `--name <text>` (as on `run start`), `--fail-on-failures` to exit with [the gate](#a-release-gate---fail-on-failures)'s code instead — or with the command's own when no result reached the run. |
@@ -239,6 +239,7 @@ costs nothing beyond a Plune token.
239
239
  ```bash
240
240
  plune login # once, on your machine; in CI export PLUNE_TOKEN instead
241
241
  plune run import ./junit.xml # Jest, Vitest, pytest, PHPUnit, Surefire, Cypress, …
242
+ plune run import target/surefire-reports # or a folder of JUnit files: Maven, Gradle — see Java below
242
243
  plune run import ./playwright.json # or Playwright's own JSON report
243
244
  plune run import ./allure-results # or the folder an Allure adapter wrote — see below
244
245
  ```
@@ -301,13 +302,15 @@ pytest --junitxml=report.xml -o junit_logging=system-out
301
302
  plune run import report.xml
302
303
  ```
303
304
 
304
- Java, JUnit 5 (Maven Surefire writes the test's output into its testcase):
305
+ Java, JUnit 5 (Maven Surefire writes the test's output into its testcase; Gradle only with
306
+ `reports.junitXml.outputPerTestCase = true`). The path counts from the folder the XML file is in
307
+ (`target/surefire-reports`, `build/test-results/test`), so the file has to be below it:
305
308
 
306
309
  ```java
307
310
  @Test
308
311
  void checkout() {
309
- // ...
310
- System.out.println("[[ATTACHMENT|target/artifacts/checkout.png]]");
312
+ // ... save the screenshot as target/surefire-reports/artifacts/checkout.png
313
+ System.out.println("[[ATTACHMENT|artifacts/checkout.png]]");
311
314
  }
312
315
  ```
313
316
 
@@ -330,7 +333,7 @@ For Jest, use Allure below.
330
333
 
331
334
  Every Allure adapter (pytest, JUnit 5, TestNG, Playwright, Cypress, Jest, Vitest, WebdriverIO, .NET) writes
332
335
  the same folder: a `*-result.json` per test, a `*-container.json` per suite, and the files the tests attached.
333
- Point `plune run import` at the folder (a folder is read as Allure, no flag needed). Each test arrives with its
336
+ Point `plune run import` at the folder (a folder with a `*-result.json` in it is read as Allure, no flag needed). Each test arrives with its
334
337
  name, its status as Allure shows it (`passed`, `failed`, `broken`, `skipped`), its time, the failure message and
335
338
  trace, and its steps; a test run again (same `historyId`) is one test with attempts, the last one counting.
336
339
  The files come from the test, from its steps and from its fixtures (`befores`, `afters`), each with the
@@ -354,11 +357,36 @@ def test_checkout(page):
354
357
  allure.attach.file("artifacts/checkout.png", name="checkout", attachment_type=allure.attachment_type.PNG)
355
358
  ```
356
359
 
357
- Java, JUnit 5 (`allure-junit5`, with the AspectJ agent from Allure's setup; the results folder is
358
- `target/allure-results` unless `allure.results.directory` says otherwise):
360
+ Java. Maven Surefire and Gradle write JUnit XML on their own, a `TEST-<class>.xml` per class, so a Java suite
361
+ needs no Allure: point the import at the folder.
359
362
 
360
363
  ```bash
361
364
  mvn test
365
+ plune run import target/surefire-reports
366
+
367
+ ./gradlew test
368
+ plune run import build/test-results/test
369
+ ```
370
+
371
+ Every `*.xml` in the folder that is a JUnit report is read, in the order of the file names, and together they make
372
+ one run. The folder's other files and its subfolders (Gradle's `binary/`) are not opened; an `*.xml` that is not a
373
+ JUnit report is skipped with a line in the output. Each test arrives with its class and name as the key
374
+ (`cart.CartTest#addsAnItem` from Surefire; Gradle keeps the brackets, `cart.CartTest#addsAnItem()`), its status
375
+ (`pass`, `failure`, `error`, `skipped`), the failure's message, type and stack trace, and its duration when the suite
376
+ has a `timestamp`. Gradle writes one; Surefire's report has none, so Maven tests arrive without a duration for now
377
+ (plune-ai/plune#2310). A file reaches a test only
378
+ through a `[[ATTACHMENT|path]]` line in its own output, [as above](#files-from-a-junit-report), the path counted from
379
+ the folder the XML file is in. Gradle puts that output in the test's testcase only with
380
+ `reports.junitXml.outputPerTestCase = true` (by default it writes it once per suite).
381
+
382
+ Allure adds the steps, and files attached without printing a line. The JUnit 5 adapter is
383
+ `io.qameta.allure:allure-jupiter` (from 2.35.1, `allure-junit5` is only a relocation to it, with no jar), with the
384
+ AspectJ agent from Allure's setup. It writes `allure-results` in the working folder; to have it in the build's
385
+ output, set `allure.results.directory` (in `src/test/resources/allure.properties`, or as a system property) to
386
+ `target/allure-results` for Maven or `build/allure-results` for Gradle:
387
+
388
+ ```bash
389
+ mvn test # with allure.results.directory=target/allure-results
362
390
  plune run import target/allure-results
363
391
  ```
364
392
 
@@ -372,11 +400,13 @@ void checkout() {
372
400
  Jest has no per-test output in JUnit; `allure-jest` gives it one (`allure.attachment(...)` in a test or a step).
373
401
  Tests are matched to Plune cases by the same key as a JUnit report's, `classname#name`, where the adapter's
374
402
  names allow it (pytest, JUnit 5, Playwright); for Vitest, Jest and Cypress the key is the runner, the file and the
375
- test's full name (`vt:test/cart.test.js#cart adds an item`, `jest:…`, `cy:…`). A file whose name starts with `_`
403
+ test's full name (`vt:test/cart.test.js#cart adds an item`, `jest:…`, `cy:…`). For JUnit 5 the name carries the
404
+ method's parentheses in Allure and in Gradle's JUnit XML (`shop.CartTest#coupon()`) but not in Surefire's
405
+ (`shop.CartTest#coupon`), so a project that moves between Allure and Surefire meets its cases under other keys. A file whose name starts with `_`
376
406
  is not sent: the core hides it as Playwright's own reports do, and Allure has no such convention, so name
377
407
  attachments without a leading underscore.
378
408
 
379
- Check that the output reaches the report: Gradle writes it only with `reports.junitXml.outputPerTestCase = true`,
409
+ For a JUnit report, check that the output reaches the report: Gradle writes it only with `reports.junitXml.outputPerTestCase = true`,
380
410
  and Maven Surefire moves it out of the XML when `redirectTestOutputToFile` is `true`. As in the Playwright
381
411
  reporter, a file whose name starts with an underscore is skipped, and a file several tests name is uploaded once per test.
382
412
 
package/dist/cli.cjs CHANGED
@@ -2565,7 +2565,7 @@ function createClient(opts) {
2565
2565
  }
2566
2566
  const base = opts.apiUrl.replace(/\/+$/, "");
2567
2567
  const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
2568
- async function send(method, path18, payload, retries = DEFAULT_RETRIES) {
2568
+ async function send(method, path19, payload, retries = DEFAULT_RETRIES) {
2569
2569
  let last = {
2570
2570
  kind: "unavailable",
2571
2571
  status: null,
@@ -2576,7 +2576,7 @@ function createClient(opts) {
2576
2576
  await respectPause(mine);
2577
2577
  let res;
2578
2578
  try {
2579
- res = await doFetch(`${base}${path18}`, {
2579
+ res = await doFetch(`${base}${path19}`, {
2580
2580
  method,
2581
2581
  headers: { "content-type": payload?.type ?? "application/json", authorization: `Bearer ${opts.token}` },
2582
2582
  // A DELETE carries none. Sending `"undefined"` as a body is what a naive
@@ -2615,10 +2615,10 @@ function createClient(opts) {
2615
2615
  return { ok: false, ...last };
2616
2616
  }
2617
2617
  return {
2618
- post: (path18, body, retries) => send("POST", path18, { body: JSON.stringify(body), type: "application/json" }, retries),
2619
- upload: (path18, bytes, contentType) => send("POST", path18, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
2620
- del: (path18) => send("DELETE", path18),
2621
- get: (path18) => send("GET", path18),
2618
+ post: (path19, body, retries) => send("POST", path19, { body: JSON.stringify(body), type: "application/json" }, retries),
2619
+ upload: (path19, bytes, contentType) => send("POST", path19, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
2620
+ del: (path19) => send("DELETE", path19),
2621
+ get: (path19) => send("GET", path19),
2622
2622
  async put(url, headers, source) {
2623
2623
  const putRetries = uploadRetries ?? 3;
2624
2624
  let last = { ok: false, status: null, code: "network" };
@@ -2899,12 +2899,12 @@ function machineless(raw, repoRoot, home) {
2899
2899
  if (repoRoot === void 0) text2 = text2.replace(ANY_HOME, "~");
2900
2900
  return text2;
2901
2901
  }
2902
- function pathIn(path18) {
2903
- const parts = path18.split(/[\\/]+/);
2902
+ function pathIn(path19) {
2903
+ const parts = path19.split(/[\\/]+/);
2904
2904
  while (parts.length > 1 && parts.at(-1) === "") parts.pop();
2905
2905
  if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
2906
2906
  const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
2907
- const escaped = /^\/(?!\/)/.test(path18) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
2907
+ const escaped = /^\/(?!\/)/.test(path19) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
2908
2908
  const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
2909
2909
  return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
2910
2910
  }
@@ -2950,12 +2950,12 @@ function placeOf(error, testFile) {
2950
2950
  }
2951
2951
  function fileOf(raw) {
2952
2952
  if (!raw.startsWith("file://")) return raw;
2953
- let path18 = raw.slice("file://".length);
2953
+ let path19 = raw.slice("file://".length);
2954
2954
  try {
2955
- path18 = decodeURIComponent(path18);
2955
+ path19 = decodeURIComponent(path19);
2956
2956
  } catch {
2957
2957
  }
2958
- return /^\/[A-Za-z]:\//.test(path18) ? path18.slice(1) : path18;
2958
+ return /^\/[A-Za-z]:\//.test(path19) ? path19.slice(1) : path19;
2959
2959
  }
2960
2960
  function fromRoot(place, root) {
2961
2961
  if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
@@ -2995,14 +2995,14 @@ var init_failure_detail = __esm({
2995
2995
  FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
2996
2996
  FRAME_MAX = 8192;
2997
2997
  firstLine = (text2) => (0, import_node_util.stripVTControlCharacters)(text2).split("\n").map((line) => line.trim()).find((line) => line !== "");
2998
- normal = (path18) => import_node_path2.posix.normalize(path18.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
2998
+ normal = (path19) => import_node_path2.posix.normalize(path19.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
2999
2999
  }
3000
3000
  });
3001
3001
 
3002
3002
  // src/reporter-core/files-batch.ts
3003
- async function sha256File(path18) {
3003
+ async function sha256File(path19) {
3004
3004
  const hash = (0, import_node_crypto2.createHash)("sha256");
3005
- for await (const part of (0, import_node_fs4.createReadStream)(path18)) hash.update(part);
3005
+ for await (const part of (0, import_node_fs4.createReadStream)(path19)) hash.update(part);
3006
3006
  return hash.digest("hex");
3007
3007
  }
3008
3008
  var import_node_crypto2, import_node_fs4, FILES_BATCH_MAX, DIRECT_BATCH_BYTES, BATCH_PIPELINES, sha256Bytes, BUCKET_CODES;
@@ -4632,10 +4632,10 @@ function platformGet(deps) {
4632
4632
  const token = (deps.loadToken ?? loadToken)();
4633
4633
  if (token === null) throw new NotLoggedInError();
4634
4634
  const doFetch = deps.fetchImpl ?? fetch;
4635
- return async (path18) => {
4635
+ return async (path19) => {
4636
4636
  let res;
4637
4637
  try {
4638
- res = await doFetch(`${apiUrl}${path18}`, { headers: { authorization: `Bearer ${token}` } });
4638
+ res = await doFetch(`${apiUrl}${path19}`, { headers: { authorization: `Bearer ${token}` } });
4639
4639
  } catch {
4640
4640
  throw new SyncNetworkError(apiUrl);
4641
4641
  }
@@ -4976,9 +4976,9 @@ var init_attachments = __esm({
4976
4976
  });
4977
4977
 
4978
4978
  // src/importers/allure.ts
4979
- function isFolder(path18) {
4979
+ function isFolder(path19) {
4980
4980
  try {
4981
- return fs11.statSync(path18).isDirectory();
4981
+ return fs11.statSync(path19).isDirectory();
4982
4982
  } catch {
4983
4983
  return false;
4984
4984
  }
@@ -5345,7 +5345,7 @@ var init_xml = __esm({
5345
5345
  init_failure_detail();
5346
5346
  XmlParseError = class extends Error {
5347
5347
  constructor(file, line, detail) {
5348
- super(`${file}:${line} \u2014 ${detail}`);
5348
+ super(`${printable(file)}:${line} \u2014 ${detail}`);
5349
5349
  this.file = file;
5350
5350
  this.line = line;
5351
5351
  this.name = "XmlParseError";
@@ -5486,10 +5486,10 @@ function attemptOf(result, file, ctx) {
5486
5486
  ...typeof at?.file === "string" && typeof at.line === "number" ? { location: { file: at.file, line: at.line, ...typeof at.column === "number" ? { column: at.column } : {} } } : {}
5487
5487
  })),
5488
5488
  steps: steps(result.steps),
5489
- attachments: (result.attachments ?? []).map(({ name, contentType, path: path18, body }) => ({
5489
+ attachments: (result.attachments ?? []).map(({ name, contentType, path: path19, body }) => ({
5490
5490
  name: name ?? "",
5491
5491
  ...contentType !== void 0 ? { contentType } : {},
5492
- ...path18 !== void 0 ? { path: path18 } : {},
5492
+ ...path19 !== void 0 ? { path: path19 } : {},
5493
5493
  // Decoded only where the platform would take it (plune-ai/plune#928): a report of screenshots kept as
5494
5494
  // bodies is megabytes of base64 that nothing here will read.
5495
5495
  ...typeof body === "string" && isTextType(contentType) ? { body: Buffer.from(body, "base64") } : {}
@@ -5628,12 +5628,40 @@ function detectFormat(source, file) {
5628
5628
  );
5629
5629
  }
5630
5630
  function readFolder(dir, format) {
5631
- if (format !== void 0 && format !== "allure") {
5632
- throw new UnknownFormatError(dir, `this is a folder, and only allure-results is read from one, not ${format}.`);
5631
+ if (format === "playwright-json") {
5632
+ throw new UnknownFormatError(dir, "this is a folder, and only allure-results or JUnit XML files are read from one, not playwright-json.");
5633
5633
  }
5634
5634
  const missing = [];
5635
5635
  const notes = [];
5636
- return { format: "allure", results: readAllure(dir, missing, notes), missing, notes };
5636
+ if (format === "allure" || format === void 0 && namesIn(dir, RESULT).length > 0) {
5637
+ return { format: "allure", results: readAllure(dir, missing, notes), missing, notes };
5638
+ }
5639
+ const results = [];
5640
+ const skipped = [];
5641
+ let reports = 0;
5642
+ for (const name of namesIn(dir, /\.xml$/)) {
5643
+ const file = path13.join(dir, name);
5644
+ let source;
5645
+ try {
5646
+ source = fs12.readFileSync(file, "utf8");
5647
+ } catch {
5648
+ throw new RunCommandError(`Cannot read ${printable(file)}.`);
5649
+ }
5650
+ if (!looksLikeJUnit(source)) {
5651
+ skipped.push(name);
5652
+ notes.push(`plune: ${printable(file)} is not a JUnit report \u2014 skipped.`);
5653
+ continue;
5654
+ }
5655
+ reports += 1;
5656
+ for (const r of readJUnit(source, file, missing, notes)) results.push(r);
5657
+ }
5658
+ if (reports === 0) {
5659
+ const names = skipped.slice(0, 3).map(printable).join(", ") + (skipped.length > 3 ? ` and ${skipped.length - 3} more` : "");
5660
+ throw new AllureReportError(
5661
+ `${printable(dir)} \u2014 no ${format === void 0 ? "*-result.json files and no " : ""}JUnit XML files in it${skipped.length > 0 ? ` (skipped, not JUnit: ${names})` : ""}, so there is no report to read. Point at the folder an Allure adapter wrote (pytest --alluredir, allure-results), the one Maven or Gradle wrote (target/surefire-reports, build/test-results/test), or at a JUnit or Playwright JSON file.`
5662
+ );
5663
+ }
5664
+ return { format: "junit", results, missing, notes };
5637
5665
  }
5638
5666
  function readReport(source, file, format) {
5639
5667
  const chosen2 = format ?? detectFormat(source, file);
@@ -5643,11 +5671,15 @@ function readReport(source, file, format) {
5643
5671
  if (chosen2 === "junit") return { format: chosen2, results: readJUnit(source, file, missing, notes), missing, notes };
5644
5672
  return { format: chosen2, ...readPlaywrightJson(source, file), missing, notes };
5645
5673
  }
5646
- var IMPORT_FORMATS, UnknownFormatError;
5674
+ var fs12, path13, IMPORT_FORMATS, UnknownFormatError;
5647
5675
  var init_importers = __esm({
5648
5676
  "src/importers/index.ts"() {
5649
5677
  "use strict";
5650
5678
  init_cjs_shims();
5679
+ fs12 = __toESM(require("fs"), 1);
5680
+ path13 = __toESM(require("path"), 1);
5681
+ init_run_lifecycle();
5682
+ init_failure_detail();
5651
5683
  init_allure();
5652
5684
  init_junit();
5653
5685
  init_playwright_json();
@@ -5694,9 +5726,9 @@ async function handleRunImport(options) {
5694
5726
  } else {
5695
5727
  let source;
5696
5728
  try {
5697
- source = fs12.readFileSync(options.file, "utf8");
5729
+ source = fs13.readFileSync(options.file, "utf8");
5698
5730
  } catch {
5699
- throw new RunCommandError(`Cannot read ${options.file}.`);
5731
+ throw new RunCommandError(`Cannot read ${printable(options.file)}.`);
5700
5732
  }
5701
5733
  read = readReport(source, options.file, options.format);
5702
5734
  }
@@ -5847,12 +5879,12 @@ function describe(result, apiUrl) {
5847
5879
  if (result.runId !== null) lines.push(runUrl2(apiUrl, result.runId));
5848
5880
  return lines;
5849
5881
  }
5850
- var fs12, ImportTimeoutError, NEXT;
5882
+ var fs13, ImportTimeoutError, NEXT;
5851
5883
  var init_run_import = __esm({
5852
5884
  "src/cli/commands/run-import.ts"() {
5853
5885
  "use strict";
5854
5886
  init_cjs_shims();
5855
- fs12 = __toESM(require("fs"), 1);
5887
+ fs13 = __toESM(require("fs"), 1);
5856
5888
  init_session();
5857
5889
  init_failure_detail();
5858
5890
  init_importers();
@@ -5884,10 +5916,10 @@ function isRunResult(v) {
5884
5916
  }
5885
5917
  function handleReport(opts = {}) {
5886
5918
  const cwd = opts.cwd ?? process.cwd();
5887
- const file = path13.join(cwd, ".plune", "last-run.json");
5919
+ const file = path14.join(cwd, ".plune", "last-run.json");
5888
5920
  let raw;
5889
5921
  try {
5890
- raw = fs13.readFileSync(file, "utf8");
5922
+ raw = fs14.readFileSync(file, "utf8");
5891
5923
  } catch {
5892
5924
  throw new ReportNotFoundError('No saved run found. Run "plune run" first.');
5893
5925
  }
@@ -5902,13 +5934,13 @@ function handleReport(opts = {}) {
5902
5934
  }
5903
5935
  return parsed;
5904
5936
  }
5905
- var fs13, path13, ReportNotFoundError;
5937
+ var fs14, path14, ReportNotFoundError;
5906
5938
  var init_report = __esm({
5907
5939
  "src/cli/commands/report.ts"() {
5908
5940
  "use strict";
5909
5941
  init_cjs_shims();
5910
- fs13 = __toESM(require("fs"), 1);
5911
- path13 = __toESM(require("path"), 1);
5942
+ fs14 = __toESM(require("fs"), 1);
5943
+ path14 = __toESM(require("path"), 1);
5912
5944
  ReportNotFoundError = class extends Error {
5913
5945
  code = "NO_SAVED_RUN";
5914
5946
  constructor(message) {
@@ -5978,7 +6010,7 @@ function isRunResult2(v) {
5978
6010
  function loadRunResult(file, label) {
5979
6011
  let raw;
5980
6012
  try {
5981
- raw = fs14.readFileSync(file, "utf8");
6013
+ raw = fs15.readFileSync(file, "utf8");
5982
6014
  } catch {
5983
6015
  throw new DiffInputError(`Cannot read ${label} run file: ${file}`);
5984
6016
  }
@@ -5998,12 +6030,12 @@ function handleDiff(opts) {
5998
6030
  const current = loadRunResult(opts.currentPath, "current");
5999
6031
  return diffRuns(baseline, current);
6000
6032
  }
6001
- var fs14, DiffInputError;
6033
+ var fs15, DiffInputError;
6002
6034
  var init_diff2 = __esm({
6003
6035
  "src/cli/commands/diff.ts"() {
6004
6036
  "use strict";
6005
6037
  init_cjs_shims();
6006
- fs14 = __toESM(require("fs"), 1);
6038
+ fs15 = __toESM(require("fs"), 1);
6007
6039
  init_diff();
6008
6040
  DiffInputError = class extends Error {
6009
6041
  code = "DIFF_INPUT";
@@ -6128,8 +6160,8 @@ async function runInitWizard(cwd) {
6128
6160
  process.exit(1);
6129
6161
  return;
6130
6162
  }
6131
- const outputPath = path14.join(cwd, "plune.yaml");
6132
- if (fs15.existsSync(outputPath)) {
6163
+ const outputPath = path15.join(cwd, "plune.yaml");
6164
+ if (fs16.existsSync(outputPath)) {
6133
6165
  const overwrite = await clack.confirm({
6134
6166
  message: "plune.yaml already exists. Overwrite?"
6135
6167
  });
@@ -6168,18 +6200,18 @@ async function runInitWizard(cwd) {
6168
6200
  `;
6169
6201
  const spin = clack.spinner();
6170
6202
  spin.start("Writing plune.yaml\u2026");
6171
- fs15.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
6203
+ fs16.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
6172
6204
  spin.stop("plune.yaml created!");
6173
6205
  clack.outro("plune.yaml is ready.");
6174
6206
  }
6175
- var clack, fs15, path14, import_yaml2;
6207
+ var clack, fs16, path15, import_yaml2;
6176
6208
  var init_wizard = __esm({
6177
6209
  "src/config/init/wizard.ts"() {
6178
6210
  "use strict";
6179
6211
  init_cjs_shims();
6180
6212
  clack = __toESM(require("@clack/prompts"), 1);
6181
- fs15 = __toESM(require("fs"), 1);
6182
- path14 = __toESM(require("path"), 1);
6213
+ fs16 = __toESM(require("fs"), 1);
6214
+ path15 = __toESM(require("path"), 1);
6183
6215
  import_yaml2 = require("yaml");
6184
6216
  init_errors();
6185
6217
  }
@@ -6187,21 +6219,21 @@ var init_wizard = __esm({
6187
6219
 
6188
6220
  // src/cli/templates/index.ts
6189
6221
  function writeTemplateFile(cwd, rel, content, force) {
6190
- const target = path15.join(cwd, rel);
6191
- if (!force && fs16.existsSync(target)) {
6222
+ const target = path16.join(cwd, rel);
6223
+ if (!force && fs17.existsSync(target)) {
6192
6224
  return "skipped";
6193
6225
  }
6194
- fs16.mkdirSync(path15.dirname(target), { recursive: true });
6195
- fs16.writeFileSync(target, content, "utf8");
6226
+ fs17.mkdirSync(path16.dirname(target), { recursive: true });
6227
+ fs17.writeFileSync(target, content, "utf8");
6196
6228
  return "written";
6197
6229
  }
6198
- var fs16, path15, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
6230
+ var fs17, path16, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
6199
6231
  var init_templates = __esm({
6200
6232
  "src/cli/templates/index.ts"() {
6201
6233
  "use strict";
6202
6234
  init_cjs_shims();
6203
- fs16 = __toESM(require("fs"), 1);
6204
- path15 = __toESM(require("path"), 1);
6235
+ fs17 = __toESM(require("fs"), 1);
6236
+ path16 = __toESM(require("path"), 1);
6205
6237
  ENV_EXAMPLE_TPL = `# Copy this file to .env and replace the placeholders with your real keys.
6206
6238
  # .env is gitignored; .env.example is safe to commit.
6207
6239
  # Plune reads whichever key matches your configured provider.
@@ -6381,14 +6413,14 @@ var init_logout = __esm({
6381
6413
 
6382
6414
  // src/cli/commands/cairn-artifact.ts
6383
6415
  async function findLatestCairnRun(runsDir = "runs") {
6384
- const entries = await fs17.readdir(runsDir, { withFileTypes: true }).catch(() => null);
6416
+ const entries = await fs18.readdir(runsDir, { withFileTypes: true }).catch(() => null);
6385
6417
  if (entries === null) throw new NoCairnRunsError(runsDir);
6386
6418
  let newest;
6387
6419
  for (const entry of entries) {
6388
6420
  if (!entry.isDirectory()) continue;
6389
- const dir = path16.join(runsDir, entry.name);
6421
+ const dir = path17.join(runsDir, entry.name);
6390
6422
  try {
6391
- const report = await fs17.lstat(path16.join(dir, "report.json"));
6423
+ const report = await fs18.lstat(path17.join(dir, "report.json"));
6392
6424
  if (!report.isFile()) continue;
6393
6425
  if (newest === void 0 || report.mtimeMs > newest.at) {
6394
6426
  newest = { dir, at: report.mtimeMs };
@@ -6401,13 +6433,13 @@ async function findLatestCairnRun(runsDir = "runs") {
6401
6433
  }
6402
6434
  async function readJson(file) {
6403
6435
  try {
6404
- return JSON.parse(await fs17.readFile(file, "utf8"));
6436
+ return JSON.parse(await fs18.readFile(file, "utf8"));
6405
6437
  } catch {
6406
6438
  return void 0;
6407
6439
  }
6408
6440
  }
6409
6441
  async function defaultReadEvidence(dir) {
6410
- const raw = await readJson(path16.join(dir, "api-evidence.json"));
6442
+ const raw = await readJson(path17.join(dir, "api-evidence.json"));
6411
6443
  if (!Array.isArray(raw)) return void 0;
6412
6444
  return raw.map((r) => {
6413
6445
  const rec = asRecord(r);
@@ -6450,7 +6482,7 @@ function casesFromApi(report, verdicts) {
6450
6482
  });
6451
6483
  }
6452
6484
  async function readCairnRun(dir, deps = {}) {
6453
- const raw = await readJson(path16.join(dir, "report.json"));
6485
+ const raw = await readJson(path17.join(dir, "report.json"));
6454
6486
  if (raw === void 0) throw new CairnRunNotFoundError(dir);
6455
6487
  const report = asRecord(raw);
6456
6488
  if (report["schemaVersion"] !== SUPPORTED_ARTIFACT_VERSION) {
@@ -6480,20 +6512,20 @@ async function readCairnRun(dir, deps = {}) {
6480
6512
  return {
6481
6513
  schemaVersion: INGEST_SCHEMA_VERSION,
6482
6514
  run: {
6483
- runId: str(report["runId"], path16.basename(dir)),
6515
+ runId: str(report["runId"], path17.basename(dir)),
6484
6516
  mode,
6485
6517
  target: str(report["url"], "unknown")
6486
6518
  },
6487
6519
  cases
6488
6520
  };
6489
6521
  }
6490
- var fs17, path16, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
6522
+ var fs18, path17, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
6491
6523
  var init_cairn_artifact = __esm({
6492
6524
  "src/cli/commands/cairn-artifact.ts"() {
6493
6525
  "use strict";
6494
6526
  init_cjs_shims();
6495
- fs17 = __toESM(require("fs/promises"), 1);
6496
- path16 = __toESM(require("path"), 1);
6527
+ fs18 = __toESM(require("fs/promises"), 1);
6528
+ path17 = __toESM(require("path"), 1);
6497
6529
  SUPPORTED_ARTIFACT_VERSION = 1;
6498
6530
  INGEST_SCHEMA_VERSION = 1;
6499
6531
  NoCairnRunsError = class extends Error {
@@ -6565,7 +6597,7 @@ async function handleIngest(deps) {
6565
6597
  const doFetch = deps.fetchImpl ?? fetch;
6566
6598
  const endpoint = `${apiUrl}/v1/ingest/cairn`;
6567
6599
  const job = new JobReporter(doFetch, apiUrl, token, "ingest");
6568
- await job.open(path17.basename(path17.resolve(dir)), payload.cases.length);
6600
+ await job.open(path18.basename(path18.resolve(dir)), payload.cases.length);
6569
6601
  try {
6570
6602
  const result = await upload();
6571
6603
  await job.uploaded({ subjectId: result.runId || void 0 });
@@ -6624,12 +6656,12 @@ function formatIngestResult(r) {
6624
6656
  return `${lines.join("\n")}
6625
6657
  `;
6626
6658
  }
6627
- var path17, IngestNotLoggedInError, IngestTokenRejectedError, IngestNetworkError, IngestHttpError;
6659
+ var path18, IngestNotLoggedInError, IngestTokenRejectedError, IngestNetworkError, IngestHttpError;
6628
6660
  var init_ingest = __esm({
6629
6661
  "src/cli/commands/ingest.ts"() {
6630
6662
  "use strict";
6631
6663
  init_cjs_shims();
6632
- path17 = __toESM(require("path"), 1);
6664
+ path18 = __toESM(require("path"), 1);
6633
6665
  init_api_url();
6634
6666
  init_credentials();
6635
6667
  init_cairn_artifact();
@@ -7577,7 +7609,13 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7577
7609
  await failRunCommand(err, verboseOf(command));
7578
7610
  }
7579
7611
  });
7580
- runCommand.command("import").argument("<file>", "A JUnit XML or Playwright JSON report, or an Allure allure-results folder").description("Turn a report another runner wrote into a run in Plune \u2014 no provider key needed").option("--format <fmt>", "junit | playwright-json | allure; detected from the file (a folder is allure) when omitted").option("--key <externalKey>", "The key several jobs share so their reports land in one run").option("--name <text>", "What to call the run in the list (default: the folder name, the time and where it ran; PLUNE_RUN_TITLE sets it too)").option("--create", "Offer tests nothing matched to the review queue", false).option(
7612
+ runCommand.command("import").argument(
7613
+ "<file>",
7614
+ "A JUnit XML or Playwright JSON report, a folder of JUnit XML files (target/surefire-reports, build/test-results/test), or an Allure allure-results folder"
7615
+ ).description("Turn a report another runner wrote into a run in Plune \u2014 no provider key needed").option(
7616
+ "--format <fmt>",
7617
+ "junit | playwright-json | allure; detected when omitted (a folder with a *-result.json is allure, any other folder is its *.xml files as junit)"
7618
+ ).option("--key <externalKey>", "The key several jobs share so their reports land in one run").option("--name <text>", "What to call the run in the list (default: the folder name, the time and where it ran; PLUNE_RUN_TITLE sets it too)").option("--create", "Offer tests nothing matched to the review queue", false).option(
7581
7619
  "--fail-on-missing-attachments",
7582
7620
  "Exit 1 when a file the report names ([[ATTACHMENT|path]] in JUnit, a source in allure-results) is not there; the results are sent either way",
7583
7621
  false
@@ -7707,10 +7745,10 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7707
7745
  const { handleLogin: handleLogin2, reportLoginFailure: reportLoginFailure2 } = await Promise.resolve().then(() => (init_login(), login_exports));
7708
7746
  try {
7709
7747
  const token = options.token ?? await readTokenFromStdin();
7710
- const { path: path18, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
7748
+ const { path: path19, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
7711
7749
  process.stdout.write(
7712
- verified ? `Logged in. Token checked against the API and saved to ${path18}
7713
- ` : `Token saved to ${path18} WITHOUT being checked (--skip-verify).
7750
+ verified ? `Logged in. Token checked against the API and saved to ${path19}
7751
+ ` : `Token saved to ${path19} WITHOUT being checked (--skip-verify).
7714
7752
  `
7715
7753
  );
7716
7754
  } catch (err) {
@@ -7727,9 +7765,9 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7727
7765
  const verbose = command.optsWithGlobals().verbose === true;
7728
7766
  const { handleLogout: handleLogout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
7729
7767
  try {
7730
- const { removed, path: path18 } = handleLogout2();
7768
+ const { removed, path: path19 } = handleLogout2();
7731
7769
  process.stdout.write(
7732
- removed ? `Logged out. Removed ${path18}
7770
+ removed ? `Logged out. Removed ${path19}
7733
7771
  ` : "Not logged in \u2014 nothing to remove.\n"
7734
7772
  );
7735
7773
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "AI-powered assertion testing for LLM apps — write assertions, run them against any provider, get a pass/fail report (local, CI, or diff).",
5
5
  "keywords": [
6
6
  "ai",