@plune-ai/cli 0.9.0 → 0.9.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.
package/README.md CHANGED
@@ -103,7 +103,7 @@ provider API key is read from the environment based on `provider.type`:
103
103
  | `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` → Settings → API tokens. Flags: `--token <token>` (omit to paste it or pipe it via stdin), `--skip-verify` (save without checking, for offline setup). |
104
104
  | `plune logout` | Remove the saved token. |
105
105
  | `plune sync` | Upload the latest local run to the platform. Flags: `--file <path>` to send a specific run JSON. |
106
- | `plune run import <file>` | Turn a **JUnit XML** or **Playwright JSON** report into a run in Plune. Needs no provider key — nothing is generated. Flags: `--format junit\|playwright-json` (detected from the file when omitted), `--key <externalKey>` to land several reports in one run, `--create` to offer unmatched tests to the review queue. |
106
+ | `plune run import <file>` | Turn a **JUnit XML** or **Playwright JSON** report into a run in Plune. Needs no provider key — nothing is generated. Flags: `--format junit\|playwright-json` (detected from the file when omitted), `--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. |
107
107
  | `plune ingest [dir]` | Record a [Cairn](https://github.com/plune-ai/cairn) run in Plune. Omit `[dir]` for the newest run under `./runs`, or name the directory holding `report.json`. Generated cases arrive as **review proposals** — nothing is created until a person approves it. |
108
108
 
109
109
  Global flags: `-c, --config <path>` · `-v, --verbose` · `--no-color`.
@@ -135,14 +135,30 @@ which reports as the run happens and needs no second step:
135
135
  reporter: [['list'], ['@plune-ai/playwright']],
136
136
  ```
137
137
 
138
- Sharded CI? Give every job the same `--key` (or `PLUNE_RUN`), and close the run with
139
- `plune run finish <id>` once they are all done.
138
+ ### Several jobs, one run
139
+
140
+ Two suites, or a sharded matrix, report into a single run when every job shares a key **and** knows
141
+ it is not the last one:
142
+
143
+ ```bash
144
+ PLUNE_SHARED_RUN=1 plune run import ./junit.xml --key "$GITHUB_RUN_ID"
145
+ PLUNE_SHARED_RUN=1 plune run import ./app/junit.xml --key "$GITHUB_RUN_ID"
146
+ plune run finish "$RUN_ID" # once, when they are all done
147
+ ```
148
+
149
+ `PLUNE_SHARED_RUN=1` is what stops a job closing a run its siblings are still reporting into; the
150
+ key alone says where the results go, not who ends the run. Leave it unset in the last job and that
151
+ job closes the run instead of the explicit `finish`.
152
+
153
+ `plune run exec` sets it for you — anything reporting inside it, this command included, joins
154
+ without closing.
140
155
 
141
156
  ## Optional: keep a history
142
157
 
143
- Everything above works with no account, no network, and no token — that does not change. If you
144
- also want run history, trends, and a shared dashboard, the three cloud commands push your local
145
- runs to the [Plune platform](https://plune.ai/platform):
158
+ Everything above works with no account, no network, and no token — that does not change. `run`,
159
+ `report`, `diff` and `init` never open a socket, and that is the half this promise is about. If you
160
+ also want run history, trends, and a shared dashboard, the commands below push your local runs to
161
+ the [Plune platform](https://plune.ai/platform):
146
162
 
147
163
  ```bash
148
164
  plune login # paste the API token from your platform settings page
package/dist/cli.cjs CHANGED
@@ -2373,6 +2373,7 @@ async function startRun(passed, expected) {
2373
2373
  rejected: 0,
2374
2374
  unresolved: 0,
2375
2375
  offered: 0,
2376
+ unoffered: 0,
2376
2377
  deferred: 0
2377
2378
  };
2378
2379
  const resolved = /* @__PURE__ */ new Map();
@@ -2465,13 +2466,17 @@ async function startRun(passed, expected) {
2465
2466
  }
2466
2467
  async function offer() {
2467
2468
  if (discoveries.size === 0 || offline || runId === null) return;
2468
- for (const batch of chunk([...discoveries.values()], DISCOVER_MAX)) {
2469
+ const batches = chunk([...discoveries.values()], DISCOVER_MAX);
2470
+ for (const [index, batch] of batches.entries()) {
2469
2471
  const out = await client.post("/v1/review-items/discovered", {
2470
2472
  discovered: batch,
2471
2473
  runId
2472
2474
  });
2473
2475
  if (!out.ok) {
2474
- log(`plune: could not offer ${batch.length} unknown tests for review (${out.detail || out.kind}).`);
2476
+ stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
2477
+ log(
2478
+ `plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.detail || out.kind}).`
2479
+ );
2475
2480
  return;
2476
2481
  }
2477
2482
  stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
@@ -2501,7 +2506,10 @@ async function startRun(passed, expected) {
2501
2506
  }
2502
2507
  }
2503
2508
  if (unanswered.length > 0) defer(unanswered);
2504
- if (submissions.length === 0) return;
2509
+ if (submissions.length === 0) {
2510
+ progress();
2511
+ return;
2512
+ }
2505
2513
  const out = await client.post(
2506
2514
  `/v1/runs/${runId}/results`,
2507
2515
  { results: submissions }
@@ -2511,6 +2519,7 @@ async function startRun(passed, expected) {
2511
2519
  stats.duplicate += out.body.counts.duplicate ?? 0;
2512
2520
  stats.conflict += out.body.counts.conflict ?? 0;
2513
2521
  stats.rejected += out.body.counts.rejected ?? 0;
2522
+ progress();
2514
2523
  return;
2515
2524
  }
2516
2525
  if (out.kind === "auth") {
@@ -2523,6 +2532,12 @@ async function startRun(passed, expected) {
2523
2532
  }
2524
2533
  defer(submissions);
2525
2534
  }
2535
+ function progress() {
2536
+ const total = expected?.length ?? 0;
2537
+ if (total <= batchSize) return;
2538
+ const done2 = stats.accepted + stats.duplicate + stats.conflict + stats.rejected + stats.unresolved;
2539
+ log(`plune: ${Math.min(done2, total)} of ${total} results sent`);
2540
+ }
2526
2541
  function summarise() {
2527
2542
  const parts = [`${stats.accepted} accepted`];
2528
2543
  if (stats.duplicate > 0) parts.push(`${stats.duplicate} already reported`);
@@ -3180,7 +3195,8 @@ async function handleRunImport(options) {
3180
3195
  results.map((r) => r.keys)
3181
3196
  );
3182
3197
  for (const result2 of results) await session.add(result2);
3183
- await session.finish();
3198
+ if (env.keepOpen) await session.leaveOpen();
3199
+ else await session.finish();
3184
3200
  const stats = session.stats;
3185
3201
  const result = {
3186
3202
  runId: session.runId,
@@ -3192,6 +3208,7 @@ async function handleRunImport(options) {
3192
3208
  rejected: stats.rejected,
3193
3209
  unresolved: stats.unresolved,
3194
3210
  offered: stats.offered,
3211
+ unoffered: stats.unoffered,
3195
3212
  deferred: stats.deferred,
3196
3213
  unlocatable: results.filter((r) => r.specRef === void 0).length,
3197
3214
  offering: options.create === true
@@ -3205,7 +3222,11 @@ function runUrl2(apiUrl, id) {
3205
3222
  }
3206
3223
  function describe(result, apiUrl) {
3207
3224
  const lines = [
3208
- `Imported ${result.parsed} result(s) from a ${result.format} report: ${result.accepted} accepted, ${result.duplicate} already there, ${result.unresolved} unmatched.`
3225
+ // `Read`, not `Imported`: the verb describes the FILE, which was read whatever happened next.
3226
+ // «Imported 555 result(s): 0 accepted, 0 already there, 0 unmatched» was a sentence whose two
3227
+ // halves disagreed, and readers stop at the verb — a run that reached nobody read as one that
3228
+ // worked (#622).
3229
+ `Read ${result.parsed} result(s) from a ${result.format} report: ${result.accepted} accepted, ${result.duplicate} already there, ${result.unresolved} unmatched.`
3209
3230
  ];
3210
3231
  if (result.conflict > 0 || result.rejected > 0) {
3211
3232
  lines.push(`${result.conflict} conflicted and ${result.rejected} were refused.`);
@@ -3219,9 +3240,14 @@ function describe(result, apiUrl) {
3219
3240
  lines.push(`${result.offered} unknown test(s) offered to the review queue.`);
3220
3241
  } else if (result.unresolved > 0 && !result.offering) {
3221
3242
  lines.push("Nothing was offered to the review queue \u2014 pass --create to propose the unmatched tests.");
3222
- } else if (result.unresolved > 0) {
3243
+ } else if (result.unresolved > 0 && result.unoffered === 0) {
3223
3244
  lines.push("Nothing new to offer \u2014 the unmatched tests are already in the review queue.");
3224
3245
  }
3246
+ if (result.unoffered > 0) {
3247
+ lines.push(
3248
+ `${result.unoffered} more could not be offered \u2014 the review queue is full. Approve or reject what is waiting, then import this report again to offer the rest.`
3249
+ );
3250
+ }
3225
3251
  if (result.unlocatable > 0) {
3226
3252
  lines.push(
3227
3253
  `${result.unlocatable} test(s) carry no file or class in the report, so they cannot be offered for review \u2014 a reviewer would have nowhere to look.`
@@ -239,6 +239,7 @@ async function startRun(passed, expected) {
239
239
  rejected: 0,
240
240
  unresolved: 0,
241
241
  offered: 0,
242
+ unoffered: 0,
242
243
  deferred: 0
243
244
  };
244
245
  const resolved = /* @__PURE__ */ new Map();
@@ -331,13 +332,17 @@ async function startRun(passed, expected) {
331
332
  }
332
333
  async function offer() {
333
334
  if (discoveries.size === 0 || offline || runId === null) return;
334
- for (const batch of chunk([...discoveries.values()], DISCOVER_MAX)) {
335
+ const batches = chunk([...discoveries.values()], DISCOVER_MAX);
336
+ for (const [index, batch] of batches.entries()) {
335
337
  const out = await client.post("/v1/review-items/discovered", {
336
338
  discovered: batch,
337
339
  runId
338
340
  });
339
341
  if (!out.ok) {
340
- log(`plune: could not offer ${batch.length} unknown tests for review (${out.detail || out.kind}).`);
342
+ stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
343
+ log(
344
+ `plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.detail || out.kind}).`
345
+ );
341
346
  return;
342
347
  }
343
348
  stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
@@ -367,7 +372,10 @@ async function startRun(passed, expected) {
367
372
  }
368
373
  }
369
374
  if (unanswered.length > 0) defer(unanswered);
370
- if (submissions.length === 0) return;
375
+ if (submissions.length === 0) {
376
+ progress();
377
+ return;
378
+ }
371
379
  const out = await client.post(
372
380
  `/v1/runs/${runId}/results`,
373
381
  { results: submissions }
@@ -377,6 +385,7 @@ async function startRun(passed, expected) {
377
385
  stats.duplicate += out.body.counts.duplicate ?? 0;
378
386
  stats.conflict += out.body.counts.conflict ?? 0;
379
387
  stats.rejected += out.body.counts.rejected ?? 0;
388
+ progress();
380
389
  return;
381
390
  }
382
391
  if (out.kind === "auth") {
@@ -389,6 +398,12 @@ async function startRun(passed, expected) {
389
398
  }
390
399
  defer(submissions);
391
400
  }
401
+ function progress() {
402
+ const total = expected?.length ?? 0;
403
+ if (total <= batchSize) return;
404
+ const done2 = stats.accepted + stats.duplicate + stats.conflict + stats.rejected + stats.unresolved;
405
+ log(`plune: ${Math.min(done2, total)} of ${total} results sent`);
406
+ }
392
407
  function summarise() {
393
408
  const parts = [`${stats.accepted} accepted`];
394
409
  if (stats.duplicate > 0) parts.push(`${stats.duplicate} already reported`);
@@ -139,6 +139,14 @@ interface RunStats extends SubmitCounts {
139
139
  /** Unresolved tests offered to the review queue under `PLUNE_CREATE=1` (D14). Counted separately
140
140
  * from `unresolved` because they are the same tests seen twice, not a second population. */
141
141
  offered: number;
142
+ /**
143
+ * Unmatched tests that were NOT offered, because the queue would not take them (#627).
144
+ *
145
+ * Separate from `offered` rather than a shortfall computed against `unresolved`: the reason a
146
+ * test went unoffered is the caller's next action, and a subtraction cannot carry a reason. A
147
+ * queue at capacity means "empty it and import again"; nothing else here means that.
148
+ */
149
+ unoffered: number;
142
150
  /** Results written to the fallback file instead of the platform. */
143
151
  deferred: number;
144
152
  }
@@ -139,6 +139,14 @@ interface RunStats extends SubmitCounts {
139
139
  /** Unresolved tests offered to the review queue under `PLUNE_CREATE=1` (D14). Counted separately
140
140
  * from `unresolved` because they are the same tests seen twice, not a second population. */
141
141
  offered: number;
142
+ /**
143
+ * Unmatched tests that were NOT offered, because the queue would not take them (#627).
144
+ *
145
+ * Separate from `offered` rather than a shortfall computed against `unresolved`: the reason a
146
+ * test went unoffered is the caller's next action, and a subtraction cannot carry a reason. A
147
+ * queue at capacity means "empty it and import again"; nothing else here means that.
148
+ */
149
+ unoffered: number;
142
150
  /** Results written to the fallback file instead of the platform. */
143
151
  deferred: number;
144
152
  }
@@ -196,6 +196,7 @@ async function startRun(passed, expected) {
196
196
  rejected: 0,
197
197
  unresolved: 0,
198
198
  offered: 0,
199
+ unoffered: 0,
199
200
  deferred: 0
200
201
  };
201
202
  const resolved = /* @__PURE__ */ new Map();
@@ -288,13 +289,17 @@ async function startRun(passed, expected) {
288
289
  }
289
290
  async function offer() {
290
291
  if (discoveries.size === 0 || offline || runId === null) return;
291
- for (const batch of chunk([...discoveries.values()], DISCOVER_MAX)) {
292
+ const batches = chunk([...discoveries.values()], DISCOVER_MAX);
293
+ for (const [index, batch] of batches.entries()) {
292
294
  const out = await client.post("/v1/review-items/discovered", {
293
295
  discovered: batch,
294
296
  runId
295
297
  });
296
298
  if (!out.ok) {
297
- log(`plune: could not offer ${batch.length} unknown tests for review (${out.detail || out.kind}).`);
299
+ stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
300
+ log(
301
+ `plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.detail || out.kind}).`
302
+ );
298
303
  return;
299
304
  }
300
305
  stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
@@ -324,7 +329,10 @@ async function startRun(passed, expected) {
324
329
  }
325
330
  }
326
331
  if (unanswered.length > 0) defer(unanswered);
327
- if (submissions.length === 0) return;
332
+ if (submissions.length === 0) {
333
+ progress();
334
+ return;
335
+ }
328
336
  const out = await client.post(
329
337
  `/v1/runs/${runId}/results`,
330
338
  { results: submissions }
@@ -334,6 +342,7 @@ async function startRun(passed, expected) {
334
342
  stats.duplicate += out.body.counts.duplicate ?? 0;
335
343
  stats.conflict += out.body.counts.conflict ?? 0;
336
344
  stats.rejected += out.body.counts.rejected ?? 0;
345
+ progress();
337
346
  return;
338
347
  }
339
348
  if (out.kind === "auth") {
@@ -346,6 +355,12 @@ async function startRun(passed, expected) {
346
355
  }
347
356
  defer(submissions);
348
357
  }
358
+ function progress() {
359
+ const total = expected?.length ?? 0;
360
+ if (total <= batchSize) return;
361
+ const done2 = stats.accepted + stats.duplicate + stats.conflict + stats.rejected + stats.unresolved;
362
+ log(`plune: ${Math.min(done2, total)} of ${total} results sent`);
363
+ }
349
364
  function summarise() {
350
365
  const parts = [`${stats.accepted} accepted`];
351
366
  if (stats.duplicate > 0) parts.push(`${stats.duplicate} already reported`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.9.0",
3
+ "version": "0.9.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",