@plune-ai/cli 0.31.0 → 0.32.1

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 +122 -79
  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. Gradle writes
376
+ a `timestamp` on the suite, and a test's span starts there; Surefire writes none, so a Maven test's span ends when the
377
+ import reads the report (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";
@@ -5406,23 +5406,28 @@ function specRefOf(test, suite, classname) {
5406
5406
  }
5407
5407
  return classname === "" ? void 0 : classname;
5408
5408
  }
5409
- function executionOf(test, suite) {
5410
- const stamp = suite?.attrs["timestamp"];
5411
- if (stamp === void 0 || stamp === "") return void 0;
5412
- const started = new Date(stamp);
5413
- if (Number.isNaN(started.getTime())) return void 0;
5409
+ function executionOf(test, suite, now) {
5414
5410
  const seconds = Number.parseFloat(test.attrs["time"] ?? "");
5415
- const durationMs = Number.isFinite(seconds) && seconds >= 0 ? Math.round(seconds * 1e3) : 0;
5411
+ const timed = Number.isFinite(seconds) && seconds >= 0;
5412
+ const durationMs = timed ? Math.round(seconds * 1e3) : 0;
5413
+ const stamp = suite?.attrs["timestamp"];
5414
+ const dated = stamp === void 0 || stamp === "" ? void 0 : new Date(stamp);
5415
+ let started;
5416
+ if (dated !== void 0 && !Number.isNaN(dated.getTime())) started = dated;
5417
+ else if (timed) started = new Date(now.getTime() - durationMs);
5418
+ else return void 0;
5419
+ const finished = new Date(started.getTime() + durationMs);
5420
+ if (Number.isNaN(started.getTime()) || Number.isNaN(finished.getTime())) return void 0;
5416
5421
  return {
5417
5422
  startedAt: started.toISOString(),
5418
- finishedAt: new Date(started.getTime() + durationMs).toISOString(),
5423
+ finishedAt: finished.toISOString(),
5419
5424
  durationMs,
5420
5425
  // The base format has no reruns. Surefire's `<rerunFailure>` does, and reading it is work for
5421
5426
  // the day somebody asks; reporting 0 says «first attempt», which is true of every case here.
5422
5427
  retry: 0
5423
5428
  };
5424
5429
  }
5425
- function readJUnit(source, file, missing = [], notes = []) {
5430
+ function readJUnit(source, file, missing = [], notes = [], now = /* @__PURE__ */ new Date()) {
5426
5431
  const doc = parseXml(source, file);
5427
5432
  const { lexical, real: folder } = reportFolders(file);
5428
5433
  const found = [];
@@ -5436,7 +5441,7 @@ function readJUnit(source, file, missing = [], notes = []) {
5436
5441
  const stated = TOKEN2.exec(playwright?.titles.at(-1) ?? name)?.[1];
5437
5442
  const specRef = specRefOf(test, suite, classname);
5438
5443
  const errorContext = errorTextOf(detail);
5439
- const execution = executionOf(test, suite);
5444
+ const execution = executionOf(test, suite, now);
5440
5445
  const title = titleOf(classname, name);
5441
5446
  const rkey = resultKey(key.value, 0);
5442
5447
  const files = filesOfTest(test, folder, title, missing, { lexical, resultKey: rkey, notes });
@@ -5486,10 +5491,10 @@ function attemptOf(result, file, ctx) {
5486
5491
  ...typeof at?.file === "string" && typeof at.line === "number" ? { location: { file: at.file, line: at.line, ...typeof at.column === "number" ? { column: at.column } : {} } } : {}
5487
5492
  })),
5488
5493
  steps: steps(result.steps),
5489
- attachments: (result.attachments ?? []).map(({ name, contentType, path: path18, body }) => ({
5494
+ attachments: (result.attachments ?? []).map(({ name, contentType, path: path19, body }) => ({
5490
5495
  name: name ?? "",
5491
5496
  ...contentType !== void 0 ? { contentType } : {},
5492
- ...path18 !== void 0 ? { path: path18 } : {},
5497
+ ...path19 !== void 0 ? { path: path19 } : {},
5493
5498
  // Decoded only where the platform would take it (plune-ai/plune#928): a report of screenshots kept as
5494
5499
  // bodies is megabytes of base64 that nothing here will read.
5495
5500
  ...typeof body === "string" && isTextType(contentType) ? { body: Buffer.from(body, "base64") } : {}
@@ -5628,12 +5633,40 @@ function detectFormat(source, file) {
5628
5633
  );
5629
5634
  }
5630
5635
  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}.`);
5636
+ if (format === "playwright-json") {
5637
+ throw new UnknownFormatError(dir, "this is a folder, and only allure-results or JUnit XML files are read from one, not playwright-json.");
5633
5638
  }
5634
5639
  const missing = [];
5635
5640
  const notes = [];
5636
- return { format: "allure", results: readAllure(dir, missing, notes), missing, notes };
5641
+ if (format === "allure" || format === void 0 && namesIn(dir, RESULT).length > 0) {
5642
+ return { format: "allure", results: readAllure(dir, missing, notes), missing, notes };
5643
+ }
5644
+ const results = [];
5645
+ const skipped = [];
5646
+ let reports = 0;
5647
+ for (const name of namesIn(dir, /\.xml$/)) {
5648
+ const file = path13.join(dir, name);
5649
+ let source;
5650
+ try {
5651
+ source = fs12.readFileSync(file, "utf8");
5652
+ } catch {
5653
+ throw new RunCommandError(`Cannot read ${printable(file)}.`);
5654
+ }
5655
+ if (!looksLikeJUnit(source)) {
5656
+ skipped.push(name);
5657
+ notes.push(`plune: ${printable(file)} is not a JUnit report \u2014 skipped.`);
5658
+ continue;
5659
+ }
5660
+ reports += 1;
5661
+ for (const r of readJUnit(source, file, missing, notes)) results.push(r);
5662
+ }
5663
+ if (reports === 0) {
5664
+ const names = skipped.slice(0, 3).map(printable).join(", ") + (skipped.length > 3 ? ` and ${skipped.length - 3} more` : "");
5665
+ throw new AllureReportError(
5666
+ `${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.`
5667
+ );
5668
+ }
5669
+ return { format: "junit", results, missing, notes };
5637
5670
  }
5638
5671
  function readReport(source, file, format) {
5639
5672
  const chosen2 = format ?? detectFormat(source, file);
@@ -5643,11 +5676,15 @@ function readReport(source, file, format) {
5643
5676
  if (chosen2 === "junit") return { format: chosen2, results: readJUnit(source, file, missing, notes), missing, notes };
5644
5677
  return { format: chosen2, ...readPlaywrightJson(source, file), missing, notes };
5645
5678
  }
5646
- var IMPORT_FORMATS, UnknownFormatError;
5679
+ var fs12, path13, IMPORT_FORMATS, UnknownFormatError;
5647
5680
  var init_importers = __esm({
5648
5681
  "src/importers/index.ts"() {
5649
5682
  "use strict";
5650
5683
  init_cjs_shims();
5684
+ fs12 = __toESM(require("fs"), 1);
5685
+ path13 = __toESM(require("path"), 1);
5686
+ init_run_lifecycle();
5687
+ init_failure_detail();
5651
5688
  init_allure();
5652
5689
  init_junit();
5653
5690
  init_playwright_json();
@@ -5694,9 +5731,9 @@ async function handleRunImport(options) {
5694
5731
  } else {
5695
5732
  let source;
5696
5733
  try {
5697
- source = fs12.readFileSync(options.file, "utf8");
5734
+ source = fs13.readFileSync(options.file, "utf8");
5698
5735
  } catch {
5699
- throw new RunCommandError(`Cannot read ${options.file}.`);
5736
+ throw new RunCommandError(`Cannot read ${printable(options.file)}.`);
5700
5737
  }
5701
5738
  read = readReport(source, options.file, options.format);
5702
5739
  }
@@ -5847,12 +5884,12 @@ function describe(result, apiUrl) {
5847
5884
  if (result.runId !== null) lines.push(runUrl2(apiUrl, result.runId));
5848
5885
  return lines;
5849
5886
  }
5850
- var fs12, ImportTimeoutError, NEXT;
5887
+ var fs13, ImportTimeoutError, NEXT;
5851
5888
  var init_run_import = __esm({
5852
5889
  "src/cli/commands/run-import.ts"() {
5853
5890
  "use strict";
5854
5891
  init_cjs_shims();
5855
- fs12 = __toESM(require("fs"), 1);
5892
+ fs13 = __toESM(require("fs"), 1);
5856
5893
  init_session();
5857
5894
  init_failure_detail();
5858
5895
  init_importers();
@@ -5884,10 +5921,10 @@ function isRunResult(v) {
5884
5921
  }
5885
5922
  function handleReport(opts = {}) {
5886
5923
  const cwd = opts.cwd ?? process.cwd();
5887
- const file = path13.join(cwd, ".plune", "last-run.json");
5924
+ const file = path14.join(cwd, ".plune", "last-run.json");
5888
5925
  let raw;
5889
5926
  try {
5890
- raw = fs13.readFileSync(file, "utf8");
5927
+ raw = fs14.readFileSync(file, "utf8");
5891
5928
  } catch {
5892
5929
  throw new ReportNotFoundError('No saved run found. Run "plune run" first.');
5893
5930
  }
@@ -5902,13 +5939,13 @@ function handleReport(opts = {}) {
5902
5939
  }
5903
5940
  return parsed;
5904
5941
  }
5905
- var fs13, path13, ReportNotFoundError;
5942
+ var fs14, path14, ReportNotFoundError;
5906
5943
  var init_report = __esm({
5907
5944
  "src/cli/commands/report.ts"() {
5908
5945
  "use strict";
5909
5946
  init_cjs_shims();
5910
- fs13 = __toESM(require("fs"), 1);
5911
- path13 = __toESM(require("path"), 1);
5947
+ fs14 = __toESM(require("fs"), 1);
5948
+ path14 = __toESM(require("path"), 1);
5912
5949
  ReportNotFoundError = class extends Error {
5913
5950
  code = "NO_SAVED_RUN";
5914
5951
  constructor(message) {
@@ -5978,7 +6015,7 @@ function isRunResult2(v) {
5978
6015
  function loadRunResult(file, label) {
5979
6016
  let raw;
5980
6017
  try {
5981
- raw = fs14.readFileSync(file, "utf8");
6018
+ raw = fs15.readFileSync(file, "utf8");
5982
6019
  } catch {
5983
6020
  throw new DiffInputError(`Cannot read ${label} run file: ${file}`);
5984
6021
  }
@@ -5998,12 +6035,12 @@ function handleDiff(opts) {
5998
6035
  const current = loadRunResult(opts.currentPath, "current");
5999
6036
  return diffRuns(baseline, current);
6000
6037
  }
6001
- var fs14, DiffInputError;
6038
+ var fs15, DiffInputError;
6002
6039
  var init_diff2 = __esm({
6003
6040
  "src/cli/commands/diff.ts"() {
6004
6041
  "use strict";
6005
6042
  init_cjs_shims();
6006
- fs14 = __toESM(require("fs"), 1);
6043
+ fs15 = __toESM(require("fs"), 1);
6007
6044
  init_diff();
6008
6045
  DiffInputError = class extends Error {
6009
6046
  code = "DIFF_INPUT";
@@ -6128,8 +6165,8 @@ async function runInitWizard(cwd) {
6128
6165
  process.exit(1);
6129
6166
  return;
6130
6167
  }
6131
- const outputPath = path14.join(cwd, "plune.yaml");
6132
- if (fs15.existsSync(outputPath)) {
6168
+ const outputPath = path15.join(cwd, "plune.yaml");
6169
+ if (fs16.existsSync(outputPath)) {
6133
6170
  const overwrite = await clack.confirm({
6134
6171
  message: "plune.yaml already exists. Overwrite?"
6135
6172
  });
@@ -6168,18 +6205,18 @@ async function runInitWizard(cwd) {
6168
6205
  `;
6169
6206
  const spin = clack.spinner();
6170
6207
  spin.start("Writing plune.yaml\u2026");
6171
- fs15.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
6208
+ fs16.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
6172
6209
  spin.stop("plune.yaml created!");
6173
6210
  clack.outro("plune.yaml is ready.");
6174
6211
  }
6175
- var clack, fs15, path14, import_yaml2;
6212
+ var clack, fs16, path15, import_yaml2;
6176
6213
  var init_wizard = __esm({
6177
6214
  "src/config/init/wizard.ts"() {
6178
6215
  "use strict";
6179
6216
  init_cjs_shims();
6180
6217
  clack = __toESM(require("@clack/prompts"), 1);
6181
- fs15 = __toESM(require("fs"), 1);
6182
- path14 = __toESM(require("path"), 1);
6218
+ fs16 = __toESM(require("fs"), 1);
6219
+ path15 = __toESM(require("path"), 1);
6183
6220
  import_yaml2 = require("yaml");
6184
6221
  init_errors();
6185
6222
  }
@@ -6187,21 +6224,21 @@ var init_wizard = __esm({
6187
6224
 
6188
6225
  // src/cli/templates/index.ts
6189
6226
  function writeTemplateFile(cwd, rel, content, force) {
6190
- const target = path15.join(cwd, rel);
6191
- if (!force && fs16.existsSync(target)) {
6227
+ const target = path16.join(cwd, rel);
6228
+ if (!force && fs17.existsSync(target)) {
6192
6229
  return "skipped";
6193
6230
  }
6194
- fs16.mkdirSync(path15.dirname(target), { recursive: true });
6195
- fs16.writeFileSync(target, content, "utf8");
6231
+ fs17.mkdirSync(path16.dirname(target), { recursive: true });
6232
+ fs17.writeFileSync(target, content, "utf8");
6196
6233
  return "written";
6197
6234
  }
6198
- var fs16, path15, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
6235
+ var fs17, path16, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
6199
6236
  var init_templates = __esm({
6200
6237
  "src/cli/templates/index.ts"() {
6201
6238
  "use strict";
6202
6239
  init_cjs_shims();
6203
- fs16 = __toESM(require("fs"), 1);
6204
- path15 = __toESM(require("path"), 1);
6240
+ fs17 = __toESM(require("fs"), 1);
6241
+ path16 = __toESM(require("path"), 1);
6205
6242
  ENV_EXAMPLE_TPL = `# Copy this file to .env and replace the placeholders with your real keys.
6206
6243
  # .env is gitignored; .env.example is safe to commit.
6207
6244
  # Plune reads whichever key matches your configured provider.
@@ -6381,14 +6418,14 @@ var init_logout = __esm({
6381
6418
 
6382
6419
  // src/cli/commands/cairn-artifact.ts
6383
6420
  async function findLatestCairnRun(runsDir = "runs") {
6384
- const entries = await fs17.readdir(runsDir, { withFileTypes: true }).catch(() => null);
6421
+ const entries = await fs18.readdir(runsDir, { withFileTypes: true }).catch(() => null);
6385
6422
  if (entries === null) throw new NoCairnRunsError(runsDir);
6386
6423
  let newest;
6387
6424
  for (const entry of entries) {
6388
6425
  if (!entry.isDirectory()) continue;
6389
- const dir = path16.join(runsDir, entry.name);
6426
+ const dir = path17.join(runsDir, entry.name);
6390
6427
  try {
6391
- const report = await fs17.lstat(path16.join(dir, "report.json"));
6428
+ const report = await fs18.lstat(path17.join(dir, "report.json"));
6392
6429
  if (!report.isFile()) continue;
6393
6430
  if (newest === void 0 || report.mtimeMs > newest.at) {
6394
6431
  newest = { dir, at: report.mtimeMs };
@@ -6401,13 +6438,13 @@ async function findLatestCairnRun(runsDir = "runs") {
6401
6438
  }
6402
6439
  async function readJson(file) {
6403
6440
  try {
6404
- return JSON.parse(await fs17.readFile(file, "utf8"));
6441
+ return JSON.parse(await fs18.readFile(file, "utf8"));
6405
6442
  } catch {
6406
6443
  return void 0;
6407
6444
  }
6408
6445
  }
6409
6446
  async function defaultReadEvidence(dir) {
6410
- const raw = await readJson(path16.join(dir, "api-evidence.json"));
6447
+ const raw = await readJson(path17.join(dir, "api-evidence.json"));
6411
6448
  if (!Array.isArray(raw)) return void 0;
6412
6449
  return raw.map((r) => {
6413
6450
  const rec = asRecord(r);
@@ -6450,7 +6487,7 @@ function casesFromApi(report, verdicts) {
6450
6487
  });
6451
6488
  }
6452
6489
  async function readCairnRun(dir, deps = {}) {
6453
- const raw = await readJson(path16.join(dir, "report.json"));
6490
+ const raw = await readJson(path17.join(dir, "report.json"));
6454
6491
  if (raw === void 0) throw new CairnRunNotFoundError(dir);
6455
6492
  const report = asRecord(raw);
6456
6493
  if (report["schemaVersion"] !== SUPPORTED_ARTIFACT_VERSION) {
@@ -6480,20 +6517,20 @@ async function readCairnRun(dir, deps = {}) {
6480
6517
  return {
6481
6518
  schemaVersion: INGEST_SCHEMA_VERSION,
6482
6519
  run: {
6483
- runId: str(report["runId"], path16.basename(dir)),
6520
+ runId: str(report["runId"], path17.basename(dir)),
6484
6521
  mode,
6485
6522
  target: str(report["url"], "unknown")
6486
6523
  },
6487
6524
  cases
6488
6525
  };
6489
6526
  }
6490
- var fs17, path16, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
6527
+ var fs18, path17, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
6491
6528
  var init_cairn_artifact = __esm({
6492
6529
  "src/cli/commands/cairn-artifact.ts"() {
6493
6530
  "use strict";
6494
6531
  init_cjs_shims();
6495
- fs17 = __toESM(require("fs/promises"), 1);
6496
- path16 = __toESM(require("path"), 1);
6532
+ fs18 = __toESM(require("fs/promises"), 1);
6533
+ path17 = __toESM(require("path"), 1);
6497
6534
  SUPPORTED_ARTIFACT_VERSION = 1;
6498
6535
  INGEST_SCHEMA_VERSION = 1;
6499
6536
  NoCairnRunsError = class extends Error {
@@ -6565,7 +6602,7 @@ async function handleIngest(deps) {
6565
6602
  const doFetch = deps.fetchImpl ?? fetch;
6566
6603
  const endpoint = `${apiUrl}/v1/ingest/cairn`;
6567
6604
  const job = new JobReporter(doFetch, apiUrl, token, "ingest");
6568
- await job.open(path17.basename(path17.resolve(dir)), payload.cases.length);
6605
+ await job.open(path18.basename(path18.resolve(dir)), payload.cases.length);
6569
6606
  try {
6570
6607
  const result = await upload();
6571
6608
  await job.uploaded({ subjectId: result.runId || void 0 });
@@ -6624,12 +6661,12 @@ function formatIngestResult(r) {
6624
6661
  return `${lines.join("\n")}
6625
6662
  `;
6626
6663
  }
6627
- var path17, IngestNotLoggedInError, IngestTokenRejectedError, IngestNetworkError, IngestHttpError;
6664
+ var path18, IngestNotLoggedInError, IngestTokenRejectedError, IngestNetworkError, IngestHttpError;
6628
6665
  var init_ingest = __esm({
6629
6666
  "src/cli/commands/ingest.ts"() {
6630
6667
  "use strict";
6631
6668
  init_cjs_shims();
6632
- path17 = __toESM(require("path"), 1);
6669
+ path18 = __toESM(require("path"), 1);
6633
6670
  init_api_url();
6634
6671
  init_credentials();
6635
6672
  init_cairn_artifact();
@@ -7577,7 +7614,13 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7577
7614
  await failRunCommand(err, verboseOf(command));
7578
7615
  }
7579
7616
  });
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(
7617
+ runCommand.command("import").argument(
7618
+ "<file>",
7619
+ "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"
7620
+ ).description("Turn a report another runner wrote into a run in Plune \u2014 no provider key needed").option(
7621
+ "--format <fmt>",
7622
+ "junit | playwright-json | allure; detected when omitted (a folder with a *-result.json is allure, any other folder is its *.xml files as junit)"
7623
+ ).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
7624
  "--fail-on-missing-attachments",
7582
7625
  "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
7626
  false
@@ -7707,10 +7750,10 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7707
7750
  const { handleLogin: handleLogin2, reportLoginFailure: reportLoginFailure2 } = await Promise.resolve().then(() => (init_login(), login_exports));
7708
7751
  try {
7709
7752
  const token = options.token ?? await readTokenFromStdin();
7710
- const { path: path18, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
7753
+ const { path: path19, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
7711
7754
  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).
7755
+ verified ? `Logged in. Token checked against the API and saved to ${path19}
7756
+ ` : `Token saved to ${path19} WITHOUT being checked (--skip-verify).
7714
7757
  `
7715
7758
  );
7716
7759
  } catch (err) {
@@ -7727,9 +7770,9 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7727
7770
  const verbose = command.optsWithGlobals().verbose === true;
7728
7771
  const { handleLogout: handleLogout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
7729
7772
  try {
7730
- const { removed, path: path18 } = handleLogout2();
7773
+ const { removed, path: path19 } = handleLogout2();
7731
7774
  process.stdout.write(
7732
- removed ? `Logged out. Removed ${path18}
7775
+ removed ? `Logged out. Removed ${path19}
7733
7776
  ` : "Not logged in \u2014 nothing to remove.\n"
7734
7777
  );
7735
7778
  } 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.1",
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",