@plune-ai/cli 0.8.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,17 +103,62 @@ 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 (with `PLUNE_SHARED_RUN=1` — see below), `--create` to offer unmatched tests to the review queue. |
106
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. |
107
108
 
108
109
  Global flags: `-c, --config <path>` · `-v, --verbose` · `--no-color`.
109
110
 
110
111
  **Exit codes:** `0` everything passed · `1` an assertion failed · `2` configuration or execution error.
111
112
 
113
+ ## Already running tests? Bring the results in
114
+
115
+ Everything above generates checks, which is why it needs a provider key. If you already have a
116
+ suite, there is nothing to generate — the results exist and only have to arrive, and that route
117
+ costs nothing beyond a Plune token.
118
+
119
+ ```bash
120
+ plune login
121
+ plune run import ./junit.xml # Jest, Vitest, pytest, PHPUnit, Surefire, Cypress, …
122
+ plune run import ./playwright.json # or Playwright's own JSON report
123
+ ```
124
+
125
+ The format is read from the file, not from its name. Statuses go over as the report wrote them and
126
+ Plune maps them per project, so `error` can mean something different to your team than to ours.
127
+ A test Plune has no case for is counted, and `--create` offers it to the review queue instead —
128
+ nothing becomes a test case until a person approves it.
129
+
130
+ For a Playwright suite there is also [`@plune-ai/playwright`](https://www.npmjs.com/package/@plune-ai/playwright),
131
+ which reports as the run happens and needs no second step:
132
+
133
+ ```js
134
+ // playwright.config.ts
135
+ reporter: [['list'], ['@plune-ai/playwright']],
136
+ ```
137
+
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.
155
+
112
156
  ## Optional: keep a history
113
157
 
114
- Everything above works with no account, no network, and no token — that does not change. If you
115
- also want run history, trends, and a shared dashboard, the three cloud commands push your local
116
- 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):
117
162
 
118
163
  ```bash
119
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`);
@@ -2749,6 +2764,513 @@ var init_run_lifecycle = __esm({
2749
2764
  }
2750
2765
  });
2751
2766
 
2767
+ // src/reporter-core/result-key.ts
2768
+ function resultKey(testId, retry) {
2769
+ const plain = `${testId}#${retry}`;
2770
+ return plain.length <= RESULT_KEY_MAX ? plain : `${sha256(testId).slice(0, HASH_CHARS)}#${retry}`;
2771
+ }
2772
+ var RESULT_KEY_MAX, HASH_CHARS;
2773
+ var init_result_key = __esm({
2774
+ "src/reporter-core/result-key.ts"() {
2775
+ "use strict";
2776
+ init_cjs_shims();
2777
+ init_hash();
2778
+ RESULT_KEY_MAX = 200;
2779
+ HASH_CHARS = 32;
2780
+ }
2781
+ });
2782
+
2783
+ // src/importers/xml.ts
2784
+ function decode(raw) {
2785
+ if (!raw.includes("&")) return raw;
2786
+ return raw.replace(/&(#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);/g, (whole, body) => {
2787
+ if (body.startsWith("#")) {
2788
+ const hex = body[1] === "x" || body[1] === "X";
2789
+ const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
2790
+ try {
2791
+ return Number.isNaN(code) ? whole : String.fromCodePoint(code);
2792
+ } catch {
2793
+ return whole;
2794
+ }
2795
+ }
2796
+ return NAMED[body] ?? whole;
2797
+ });
2798
+ }
2799
+ function parseXml(source, file) {
2800
+ const root = { name: "#document", attrs: {}, children: [], text: "", line: 1 };
2801
+ const stack = [root];
2802
+ let i = 0;
2803
+ let line = 1;
2804
+ const advance = (to) => {
2805
+ for (let j = i; j < to; j++) if (source[j] === "\n") line += 1;
2806
+ i = to;
2807
+ };
2808
+ const top = () => stack[stack.length - 1];
2809
+ while (i < source.length) {
2810
+ const lt = source.indexOf("<", i);
2811
+ if (lt === -1) break;
2812
+ if (lt > i) {
2813
+ const between = source.slice(i, lt);
2814
+ if (between.trim() !== "") top().text += decode(between);
2815
+ advance(lt);
2816
+ }
2817
+ if (source.startsWith("<!--", i)) {
2818
+ const end = source.indexOf("-->", i + 4);
2819
+ if (end === -1) throw new XmlParseError(file, line, "a comment is never closed");
2820
+ advance(end + 3);
2821
+ continue;
2822
+ }
2823
+ if (source.startsWith("<![CDATA[", i)) {
2824
+ const end = source.indexOf("]]>", i + 9);
2825
+ if (end === -1) throw new XmlParseError(file, line, "a CDATA section is never closed");
2826
+ top().text += source.slice(i + 9, end);
2827
+ advance(end + 3);
2828
+ continue;
2829
+ }
2830
+ if (source.startsWith("<?", i)) {
2831
+ const end = source.indexOf("?>", i + 2);
2832
+ if (end === -1) throw new XmlParseError(file, line, "a processing instruction is never closed");
2833
+ advance(end + 2);
2834
+ continue;
2835
+ }
2836
+ if (source.startsWith("<!", i)) {
2837
+ const end = source.indexOf(">", i + 2);
2838
+ if (end === -1) throw new XmlParseError(file, line, "a declaration is never closed");
2839
+ advance(end + 1);
2840
+ continue;
2841
+ }
2842
+ if (source.startsWith("</", i)) {
2843
+ const end = source.indexOf(">", i + 2);
2844
+ if (end === -1) throw new XmlParseError(file, line, "a closing tag is never closed");
2845
+ const name2 = source.slice(i + 2, end).trim();
2846
+ const open = top();
2847
+ if (open === root) throw new XmlParseError(file, line, `</${name2}> closes nothing`);
2848
+ if (open.name !== name2) {
2849
+ throw new XmlParseError(file, line, `</${name2}> closes <${open.name}>, opened on line ${open.line}`);
2850
+ }
2851
+ stack.pop();
2852
+ advance(end + 1);
2853
+ continue;
2854
+ }
2855
+ const startLine = line;
2856
+ let j = i + 1;
2857
+ const nameStart = j;
2858
+ while (j < source.length && !NAME_END.test(source[j])) j += 1;
2859
+ const name = source.slice(nameStart, j);
2860
+ if (name === "") throw new XmlParseError(file, startLine, "a tag with no name");
2861
+ const el = { name, attrs: {}, children: [], text: "", line: startLine };
2862
+ let selfClosing = false;
2863
+ for (; ; ) {
2864
+ while (j < source.length && SPACE.test(source[j])) j += 1;
2865
+ if (j >= source.length) throw new XmlParseError(file, startLine, `<${name}> is never closed`);
2866
+ if (source[j] === "/" && source[j + 1] === ">") {
2867
+ selfClosing = true;
2868
+ j += 2;
2869
+ break;
2870
+ }
2871
+ if (source[j] === ">") {
2872
+ j += 1;
2873
+ break;
2874
+ }
2875
+ const attrStart = j;
2876
+ while (j < source.length && !ATTR_NAME_END.test(source[j])) j += 1;
2877
+ const attr = source.slice(attrStart, j);
2878
+ if (attr === "") throw new XmlParseError(file, startLine, `<${name}> has an attribute with no name`);
2879
+ while (j < source.length && SPACE.test(source[j])) j += 1;
2880
+ if (source[j] !== "=") {
2881
+ throw new XmlParseError(file, startLine, `<${name}> attribute "${attr}" has no value`);
2882
+ }
2883
+ j += 1;
2884
+ while (j < source.length && SPACE.test(source[j])) j += 1;
2885
+ const quote = source[j];
2886
+ if (quote !== '"' && quote !== "'") {
2887
+ throw new XmlParseError(file, startLine, `<${name}> attribute "${attr}" is not quoted`);
2888
+ }
2889
+ const close = source.indexOf(quote, j + 1);
2890
+ if (close === -1) throw new XmlParseError(file, startLine, `<${name}> attribute "${attr}" is never closed`);
2891
+ el.attrs[attr] = decode(source.slice(j + 1, close));
2892
+ j = close + 1;
2893
+ }
2894
+ top().children.push(el);
2895
+ if (!selfClosing) stack.push(el);
2896
+ advance(j);
2897
+ }
2898
+ if (stack.length > 1) {
2899
+ const open = top();
2900
+ throw new XmlParseError(file, open.line, `<${open.name}> is never closed`);
2901
+ }
2902
+ if (root.children.length === 0) throw new XmlParseError(file, 1, "no elements \u2014 is this an XML report?");
2903
+ return root;
2904
+ }
2905
+ var XmlParseError, NAMED, NAME_END, ATTR_NAME_END, SPACE;
2906
+ var init_xml = __esm({
2907
+ "src/importers/xml.ts"() {
2908
+ "use strict";
2909
+ init_cjs_shims();
2910
+ XmlParseError = class extends Error {
2911
+ constructor(file, line, detail) {
2912
+ super(`${file}:${line} \u2014 ${detail}`);
2913
+ this.file = file;
2914
+ this.line = line;
2915
+ this.name = "XmlParseError";
2916
+ }
2917
+ file;
2918
+ line;
2919
+ };
2920
+ NAMED = {
2921
+ amp: "&",
2922
+ lt: "<",
2923
+ gt: ">",
2924
+ quot: '"',
2925
+ apos: "'"
2926
+ };
2927
+ NAME_END = /[\s/>]/;
2928
+ ATTR_NAME_END = /[\s=/>]/;
2929
+ SPACE = /\s/;
2930
+ }
2931
+ });
2932
+
2933
+ // src/importers/junit.ts
2934
+ function locate(el, suite, out) {
2935
+ for (const child of el.children) {
2936
+ if (child.name === "testcase") out.push({ test: child, suite });
2937
+ else locate(child, child.name === "testsuite" ? child : suite, out);
2938
+ }
2939
+ }
2940
+ function outcomeOf(test) {
2941
+ for (const name of ["error", "failure", "skipped"]) {
2942
+ const found = test.children.find((c) => c.name === name);
2943
+ if (found !== void 0) return { status: name, detail: found };
2944
+ }
2945
+ return { status: "pass", detail: void 0 };
2946
+ }
2947
+ function errorTextOf(detail) {
2948
+ if (detail === void 0) return "";
2949
+ const head = [detail.attrs["message"], detail.attrs["type"]].filter((s) => s !== void 0 && s !== "");
2950
+ return [...head, detail.text.trim()].filter((s) => s !== "").join("\n\n");
2951
+ }
2952
+ function keyOf(classname, name) {
2953
+ return { kind: "path-title", value: classname === "" ? name : `${classname}#${name}` };
2954
+ }
2955
+ function titleOf(classname, name) {
2956
+ if (classname === "") return name;
2957
+ if (classname === name || classname.endsWith(` ${name}`) || classname.endsWith(`\u203A${name}`)) return classname;
2958
+ return `${classname} \u203A ${name}`;
2959
+ }
2960
+ function specRefOf(test, suite, classname) {
2961
+ const file = test.attrs["file"] ?? suite?.attrs["file"] ?? "";
2962
+ if (file !== "") {
2963
+ const line = test.attrs["line"];
2964
+ return line !== void 0 && line !== "" ? `${file}:${line}` : file;
2965
+ }
2966
+ return classname === "" ? void 0 : classname;
2967
+ }
2968
+ function executionOf(test, suite) {
2969
+ const stamp = suite?.attrs["timestamp"];
2970
+ if (stamp === void 0 || stamp === "") return void 0;
2971
+ const started = new Date(stamp);
2972
+ if (Number.isNaN(started.getTime())) return void 0;
2973
+ const seconds = Number.parseFloat(test.attrs["time"] ?? "");
2974
+ const durationMs = Number.isFinite(seconds) && seconds >= 0 ? Math.round(seconds * 1e3) : 0;
2975
+ return {
2976
+ startedAt: started.toISOString(),
2977
+ finishedAt: new Date(started.getTime() + durationMs).toISOString(),
2978
+ durationMs,
2979
+ // The base format has no reruns. Surefire's `<rerunFailure>` does, and reading it is work for
2980
+ // the day somebody asks; reporting 0 says «first attempt», which is true of every case here.
2981
+ retry: 0
2982
+ };
2983
+ }
2984
+ function readJUnit(source, file) {
2985
+ const doc = parseXml(source, file);
2986
+ const found = [];
2987
+ locate(doc, void 0, found);
2988
+ return found.map(({ test, suite }) => {
2989
+ const name = test.attrs["name"] ?? "";
2990
+ const classname = test.attrs["classname"] ?? test.attrs["class"] ?? "";
2991
+ const { status, detail } = outcomeOf(test);
2992
+ const key = keyOf(classname, name);
2993
+ const specRef = specRefOf(test, suite, classname);
2994
+ const errorContext = errorTextOf(detail);
2995
+ const execution = executionOf(test, suite);
2996
+ return {
2997
+ resultKey: resultKey(key.value, 0),
2998
+ keys: [key],
2999
+ source: SOURCE,
3000
+ rawStatus: status,
3001
+ title: titleOf(classname, name),
3002
+ ...specRef !== void 0 ? { specRef } : {},
3003
+ ...execution !== void 0 ? { execution } : {},
3004
+ ...errorContext !== "" ? { errorContext } : {}
3005
+ };
3006
+ });
3007
+ }
3008
+ function looksLikeJUnit(source) {
3009
+ return /<\s*testsuites?\b/.test(source.slice(0, 4096));
3010
+ }
3011
+ var SOURCE;
3012
+ var init_junit = __esm({
3013
+ "src/importers/junit.ts"() {
3014
+ "use strict";
3015
+ init_cjs_shims();
3016
+ init_result_key();
3017
+ init_xml();
3018
+ SOURCE = "junit";
3019
+ }
3020
+ });
3021
+
3022
+ // src/importers/playwright-json.ts
3023
+ function statedIdOf(test, title) {
3024
+ const annotation = test.annotations?.find((a) => a.type === "PluneId")?.description;
3025
+ if (annotation !== void 0 && annotation !== "") return annotation;
3026
+ return TOKEN.exec(title)?.[1];
3027
+ }
3028
+ function errorTextOf2(result) {
3029
+ return (result.errors ?? []).map((e) => e.stack ?? e.message ?? e.value ?? "").filter((text2) => text2 !== "").join("\n\n");
3030
+ }
3031
+ function pendingFrom(spec, test, result, file, titles) {
3032
+ const pathTitle = [file, ...titles].join("#");
3033
+ const seed = test.id ?? pathTitle;
3034
+ const stated = statedIdOf(test, spec.title ?? "");
3035
+ const expected = test.expectedStatus;
3036
+ const errorContext = errorTextOf2(result);
3037
+ const keys = [
3038
+ ...test.id !== void 0 ? [{ kind: "playwright-id", value: test.id }] : [],
3039
+ { kind: "path-title", value: pathTitle }
3040
+ ];
3041
+ const started = result.startTime !== void 0 ? new Date(result.startTime) : void 0;
3042
+ const durationMs = typeof result.duration === "number" && result.duration >= 0 ? result.duration : 0;
3043
+ const retry = typeof result.retry === "number" && result.retry >= 0 ? result.retry : 0;
3044
+ return {
3045
+ resultKey: resultKey(seed, retry),
3046
+ ...stated !== void 0 ? { testCaseId: stated } : {},
3047
+ keys,
3048
+ title: titles.join(" \u203A "),
3049
+ specRef: spec.line !== void 0 ? `${file}:${spec.line}` : file,
3050
+ source: SOURCE2,
3051
+ // Untouched, as everywhere: `timedOut` means what the project's map says it means, not what
3052
+ // this file decided.
3053
+ rawStatus: result.status ?? "unknown",
3054
+ ...expected === "failed" || expected === "skipped" ? { expectedStatus: expected } : {},
3055
+ ...started !== void 0 && !Number.isNaN(started.getTime()) ? {
3056
+ execution: {
3057
+ startedAt: started.toISOString(),
3058
+ finishedAt: new Date(started.getTime() + durationMs).toISOString(),
3059
+ durationMs,
3060
+ retry,
3061
+ ...result.workerIndex !== void 0 ? { worker: String(result.workerIndex) } : {}
3062
+ }
3063
+ } : {},
3064
+ ...errorContext !== "" ? { errorContext } : {}
3065
+ };
3066
+ }
3067
+ function walk(suite, describes, depth, out) {
3068
+ const here = depth === 0 ? [] : [...describes, suite.title ?? ""];
3069
+ for (const spec of suite.specs ?? []) {
3070
+ const file = spec.file ?? suite.file ?? suite.title ?? "";
3071
+ const titles = [...here, spec.title ?? ""];
3072
+ for (const test of spec.tests ?? []) {
3073
+ for (const result of test.results ?? []) out.push(pendingFrom(spec, test, result, file, titles));
3074
+ }
3075
+ }
3076
+ for (const child of suite.suites ?? []) walk(child, here, depth + 1, out);
3077
+ }
3078
+ function readPlaywrightJson(source, file) {
3079
+ let parsed;
3080
+ try {
3081
+ parsed = JSON.parse(source);
3082
+ } catch (err) {
3083
+ throw new JsonReportError(file, `not JSON \u2014 ${err instanceof Error ? err.message : String(err)}`);
3084
+ }
3085
+ if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.suites)) {
3086
+ throw new JsonReportError(file, 'no "suites" array \u2014 is this a Playwright JSON report?');
3087
+ }
3088
+ const out = [];
3089
+ for (const suite of parsed.suites) walk(suite, [], 0, out);
3090
+ return out;
3091
+ }
3092
+ function looksLikePlaywrightJson(source) {
3093
+ const head = source.slice(0, 4096);
3094
+ return head.trimStart().startsWith("{") && head.includes('"suites"');
3095
+ }
3096
+ var SOURCE2, TOKEN, JsonReportError;
3097
+ var init_playwright_json = __esm({
3098
+ "src/importers/playwright-json.ts"() {
3099
+ "use strict";
3100
+ init_cjs_shims();
3101
+ init_result_key();
3102
+ SOURCE2 = "playwright";
3103
+ TOKEN = /@P([A-Za-z0-9_-]+)/;
3104
+ JsonReportError = class extends Error {
3105
+ constructor(file, detail) {
3106
+ super(`${file} \u2014 ${detail}`);
3107
+ this.file = file;
3108
+ this.name = "JsonReportError";
3109
+ }
3110
+ file;
3111
+ };
3112
+ }
3113
+ });
3114
+
3115
+ // src/importers/index.ts
3116
+ var importers_exports = {};
3117
+ __export(importers_exports, {
3118
+ IMPORT_FORMATS: () => IMPORT_FORMATS,
3119
+ JsonReportError: () => JsonReportError,
3120
+ UnknownFormatError: () => UnknownFormatError,
3121
+ XmlParseError: () => XmlParseError,
3122
+ detectFormat: () => detectFormat,
3123
+ readReport: () => readReport
3124
+ });
3125
+ function detectFormat(source, file) {
3126
+ if (looksLikeJUnit(source)) return "junit";
3127
+ if (looksLikePlaywrightJson(source)) return "playwright-json";
3128
+ throw new UnknownFormatError(
3129
+ file,
3130
+ 'no <testsuite> and no Playwright "suites" \u2014 this is not a report we can read.'
3131
+ );
3132
+ }
3133
+ function readReport(source, file, format) {
3134
+ const chosen = format ?? detectFormat(source, file);
3135
+ return {
3136
+ format: chosen,
3137
+ results: chosen === "junit" ? readJUnit(source, file) : readPlaywrightJson(source, file)
3138
+ };
3139
+ }
3140
+ var IMPORT_FORMATS, UnknownFormatError;
3141
+ var init_importers = __esm({
3142
+ "src/importers/index.ts"() {
3143
+ "use strict";
3144
+ init_cjs_shims();
3145
+ init_junit();
3146
+ init_playwright_json();
3147
+ init_xml();
3148
+ init_playwright_json();
3149
+ IMPORT_FORMATS = ["junit", "playwright-json"];
3150
+ UnknownFormatError = class extends Error {
3151
+ constructor(file, detail) {
3152
+ super(`${file} \u2014 ${detail} Say which with --format ${IMPORT_FORMATS.join(" | ")}.`);
3153
+ this.name = "UnknownFormatError";
3154
+ }
3155
+ };
3156
+ }
3157
+ });
3158
+
3159
+ // src/cli/commands/run-import.ts
3160
+ var run_import_exports = {};
3161
+ __export(run_import_exports, {
3162
+ handleRunImport: () => handleRunImport
3163
+ });
3164
+ async function handleRunImport(options) {
3165
+ const write = options.write ?? ((line) => void process.stdout.write(`${line}
3166
+ `));
3167
+ let source;
3168
+ try {
3169
+ source = fs8.readFileSync(options.file, "utf8");
3170
+ } catch {
3171
+ throw new RunCommandError(`Cannot read ${options.file}.`);
3172
+ }
3173
+ const { format, results } = readReport(source, options.file, options.format);
3174
+ if (results.length === 0) {
3175
+ throw new RunCommandError(`No test cases in ${options.file} \u2014 it parsed, but there is nothing in it.`);
3176
+ }
3177
+ const env = readEnv();
3178
+ const apiUrl = resolveApiUrl(options.apiUrl ?? env.config.apiUrl);
3179
+ const token = options.token ?? env.config.token ?? loadToken() ?? "";
3180
+ if (token === "") throw new NoTokenError();
3181
+ const session = await startRun(
3182
+ {
3183
+ apiUrl,
3184
+ token,
3185
+ ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
3186
+ ...options.key !== void 0 ? { externalKey: options.key } : {},
3187
+ ...options.create === true ? { offerDiscovered: true } : {},
3188
+ kind: "automated",
3189
+ meta: { runner: format },
3190
+ log: write
3191
+ },
3192
+ // The file is the complete list of what ran, so the platform can resolve every test in one
3193
+ // request and derive what never ran from the same list (AC-09/AC-10) — the lazy path would be
3194
+ // a lookup per result.
3195
+ results.map((r) => r.keys)
3196
+ );
3197
+ for (const result2 of results) await session.add(result2);
3198
+ if (env.keepOpen) await session.leaveOpen();
3199
+ else await session.finish();
3200
+ const stats = session.stats;
3201
+ const result = {
3202
+ runId: session.runId,
3203
+ format,
3204
+ parsed: results.length,
3205
+ accepted: stats.accepted,
3206
+ duplicate: stats.duplicate,
3207
+ conflict: stats.conflict,
3208
+ rejected: stats.rejected,
3209
+ unresolved: stats.unresolved,
3210
+ offered: stats.offered,
3211
+ unoffered: stats.unoffered,
3212
+ deferred: stats.deferred,
3213
+ unlocatable: results.filter((r) => r.specRef === void 0).length,
3214
+ offering: options.create === true
3215
+ };
3216
+ for (const line of describe(result, apiUrl)) write(line);
3217
+ return result;
3218
+ }
3219
+ function runUrl2(apiUrl, id) {
3220
+ const web = dashboardUrl(apiUrl);
3221
+ return web === void 0 ? `${apiUrl}/v1/runs/${id}` : `${web}/runs/${id}`;
3222
+ }
3223
+ function describe(result, apiUrl) {
3224
+ const lines = [
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.`
3230
+ ];
3231
+ if (result.conflict > 0 || result.rejected > 0) {
3232
+ lines.push(`${result.conflict} conflicted and ${result.rejected} were refused.`);
3233
+ }
3234
+ if (result.deferred > 0) {
3235
+ lines.push(
3236
+ `${result.deferred} could not be sent and are in the fallback file \u2014 "plune run report" retries them.`
3237
+ );
3238
+ }
3239
+ if (result.offered > 0) {
3240
+ lines.push(`${result.offered} unknown test(s) offered to the review queue.`);
3241
+ } else if (result.unresolved > 0 && !result.offering) {
3242
+ lines.push("Nothing was offered to the review queue \u2014 pass --create to propose the unmatched tests.");
3243
+ } else if (result.unresolved > 0 && result.unoffered === 0) {
3244
+ lines.push("Nothing new to offer \u2014 the unmatched tests are already in the review queue.");
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
+ }
3251
+ if (result.unlocatable > 0) {
3252
+ lines.push(
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.`
3254
+ );
3255
+ }
3256
+ if (result.runId !== null) lines.push(runUrl2(apiUrl, result.runId));
3257
+ return lines;
3258
+ }
3259
+ var fs8;
3260
+ var init_run_import = __esm({
3261
+ "src/cli/commands/run-import.ts"() {
3262
+ "use strict";
3263
+ init_cjs_shims();
3264
+ fs8 = __toESM(require("fs"), 1);
3265
+ init_session();
3266
+ init_importers();
3267
+ init_run_lifecycle();
3268
+ init_api_url();
3269
+ init_credentials();
3270
+ init_env2();
3271
+ }
3272
+ });
3273
+
2752
3274
  // src/cli/commands/report.ts
2753
3275
  var report_exports = {};
2754
3276
  __export(report_exports, {
@@ -2765,7 +3287,7 @@ function handleReport(opts = {}) {
2765
3287
  const file = path7.join(cwd, ".plune", "last-run.json");
2766
3288
  let raw;
2767
3289
  try {
2768
- raw = fs8.readFileSync(file, "utf8");
3290
+ raw = fs9.readFileSync(file, "utf8");
2769
3291
  } catch {
2770
3292
  throw new ReportNotFoundError('No saved run found. Run "plune run" first.');
2771
3293
  }
@@ -2780,12 +3302,12 @@ function handleReport(opts = {}) {
2780
3302
  }
2781
3303
  return parsed;
2782
3304
  }
2783
- var fs8, path7, ReportNotFoundError;
3305
+ var fs9, path7, ReportNotFoundError;
2784
3306
  var init_report = __esm({
2785
3307
  "src/cli/commands/report.ts"() {
2786
3308
  "use strict";
2787
3309
  init_cjs_shims();
2788
- fs8 = __toESM(require("fs"), 1);
3310
+ fs9 = __toESM(require("fs"), 1);
2789
3311
  path7 = __toESM(require("path"), 1);
2790
3312
  ReportNotFoundError = class extends Error {
2791
3313
  code = "NO_SAVED_RUN";
@@ -2860,7 +3382,7 @@ function isRunResult2(v) {
2860
3382
  function loadRunResult(file, label) {
2861
3383
  let raw;
2862
3384
  try {
2863
- raw = fs9.readFileSync(file, "utf8");
3385
+ raw = fs10.readFileSync(file, "utf8");
2864
3386
  } catch {
2865
3387
  throw new DiffInputError(`Cannot read ${label} run file: ${file}`);
2866
3388
  }
@@ -2880,12 +3402,12 @@ function handleDiff(opts) {
2880
3402
  const current = loadRunResult(opts.currentPath, "current");
2881
3403
  return diffRuns(baseline, current);
2882
3404
  }
2883
- var fs9, DiffInputError;
3405
+ var fs10, DiffInputError;
2884
3406
  var init_diff2 = __esm({
2885
3407
  "src/cli/commands/diff.ts"() {
2886
3408
  "use strict";
2887
3409
  init_cjs_shims();
2888
- fs9 = __toESM(require("fs"), 1);
3410
+ fs10 = __toESM(require("fs"), 1);
2889
3411
  init_diff();
2890
3412
  DiffInputError = class extends Error {
2891
3413
  code = "DIFF_INPUT";
@@ -3011,7 +3533,7 @@ async function runInitWizard(cwd) {
3011
3533
  return;
3012
3534
  }
3013
3535
  const outputPath = path8.join(cwd, "plune.yaml");
3014
- if (fs10.existsSync(outputPath)) {
3536
+ if (fs11.existsSync(outputPath)) {
3015
3537
  const overwrite = await clack.confirm({
3016
3538
  message: "plune.yaml already exists. Overwrite?"
3017
3539
  });
@@ -3050,17 +3572,17 @@ async function runInitWizard(cwd) {
3050
3572
  `;
3051
3573
  const spin = clack.spinner();
3052
3574
  spin.start("Writing plune.yaml\u2026");
3053
- fs10.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
3575
+ fs11.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
3054
3576
  spin.stop("plune.yaml created!");
3055
3577
  clack.outro("plune.yaml is ready.");
3056
3578
  }
3057
- var clack, fs10, path8, import_yaml2;
3579
+ var clack, fs11, path8, import_yaml2;
3058
3580
  var init_wizard = __esm({
3059
3581
  "src/config/init/wizard.ts"() {
3060
3582
  "use strict";
3061
3583
  init_cjs_shims();
3062
3584
  clack = __toESM(require("@clack/prompts"), 1);
3063
- fs10 = __toESM(require("fs"), 1);
3585
+ fs11 = __toESM(require("fs"), 1);
3064
3586
  path8 = __toESM(require("path"), 1);
3065
3587
  import_yaml2 = require("yaml");
3066
3588
  init_errors();
@@ -3070,19 +3592,19 @@ var init_wizard = __esm({
3070
3592
  // src/cli/templates/index.ts
3071
3593
  function writeTemplateFile(cwd, rel, content, force) {
3072
3594
  const target = path9.join(cwd, rel);
3073
- if (!force && fs11.existsSync(target)) {
3595
+ if (!force && fs12.existsSync(target)) {
3074
3596
  return "skipped";
3075
3597
  }
3076
- fs11.mkdirSync(path9.dirname(target), { recursive: true });
3077
- fs11.writeFileSync(target, content, "utf8");
3598
+ fs12.mkdirSync(path9.dirname(target), { recursive: true });
3599
+ fs12.writeFileSync(target, content, "utf8");
3078
3600
  return "written";
3079
3601
  }
3080
- var fs11, path9, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
3602
+ var fs12, path9, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
3081
3603
  var init_templates = __esm({
3082
3604
  "src/cli/templates/index.ts"() {
3083
3605
  "use strict";
3084
3606
  init_cjs_shims();
3085
- fs11 = __toESM(require("fs"), 1);
3607
+ fs12 = __toESM(require("fs"), 1);
3086
3608
  path9 = __toESM(require("path"), 1);
3087
3609
  ENV_EXAMPLE_TPL = `# Copy this file to .env and replace the placeholders with your real keys.
3088
3610
  # .env is gitignored; .env.example is safe to commit.
@@ -3342,7 +3864,7 @@ function readRun(cwd, file) {
3342
3864
  const target = file !== void 0 ? path10.resolve(cwd, file) : path10.join(cwd, ".plune", "last-run.json");
3343
3865
  let raw;
3344
3866
  try {
3345
- raw = fs12.readFileSync(target, "utf8");
3867
+ raw = fs13.readFileSync(target, "utf8");
3346
3868
  } catch {
3347
3869
  throw new SyncFileError(
3348
3870
  file !== void 0 ? `Run file not found: ${target}` : 'No run to sync. Run "plune run" first (or pass --file <path>).'
@@ -3406,12 +3928,12 @@ function reportSyncFailure(err, write) {
3406
3928
  }
3407
3929
  return null;
3408
3930
  }
3409
- var fs12, path10, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
3931
+ var fs13, path10, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
3410
3932
  var init_sync = __esm({
3411
3933
  "src/cli/commands/sync.ts"() {
3412
3934
  "use strict";
3413
3935
  init_cjs_shims();
3414
- fs12 = __toESM(require("fs"), 1);
3936
+ fs13 = __toESM(require("fs"), 1);
3415
3937
  path10 = __toESM(require("path"), 1);
3416
3938
  init_run_result_schema();
3417
3939
  init_api_url();
@@ -3457,14 +3979,14 @@ var init_sync = __esm({
3457
3979
 
3458
3980
  // src/cli/commands/cairn-artifact.ts
3459
3981
  async function findLatestCairnRun(runsDir = "runs") {
3460
- const entries = await fs13.readdir(runsDir, { withFileTypes: true }).catch(() => null);
3982
+ const entries = await fs14.readdir(runsDir, { withFileTypes: true }).catch(() => null);
3461
3983
  if (entries === null) throw new NoCairnRunsError(runsDir);
3462
3984
  let newest;
3463
3985
  for (const entry of entries) {
3464
3986
  if (!entry.isDirectory()) continue;
3465
3987
  const dir = path11.join(runsDir, entry.name);
3466
3988
  try {
3467
- const report = await fs13.lstat(path11.join(dir, "report.json"));
3989
+ const report = await fs14.lstat(path11.join(dir, "report.json"));
3468
3990
  if (!report.isFile()) continue;
3469
3991
  if (newest === void 0 || report.mtimeMs > newest.at) {
3470
3992
  newest = { dir, at: report.mtimeMs };
@@ -3477,7 +3999,7 @@ async function findLatestCairnRun(runsDir = "runs") {
3477
3999
  }
3478
4000
  async function readJson(file) {
3479
4001
  try {
3480
- return JSON.parse(await fs13.readFile(file, "utf8"));
4002
+ return JSON.parse(await fs14.readFile(file, "utf8"));
3481
4003
  } catch {
3482
4004
  return void 0;
3483
4005
  }
@@ -3562,12 +4084,12 @@ async function readCairnRun(dir, deps = {}) {
3562
4084
  cases
3563
4085
  };
3564
4086
  }
3565
- var fs13, path11, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
4087
+ var fs14, path11, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
3566
4088
  var init_cairn_artifact = __esm({
3567
4089
  "src/cli/commands/cairn-artifact.ts"() {
3568
4090
  "use strict";
3569
4091
  init_cjs_shims();
3570
- fs13 = __toESM(require("fs/promises"), 1);
4092
+ fs14 = __toESM(require("fs/promises"), 1);
3571
4093
  path11 = __toESM(require("path"), 1);
3572
4094
  SUPPORTED_ARTIFACT_VERSION = 1;
3573
4095
  INGEST_SCHEMA_VERSION = 1;
@@ -3914,6 +4436,36 @@ function createProgram() {
3914
4436
  await failRunCommand(err, verboseOf(command));
3915
4437
  }
3916
4438
  });
4439
+ runCommand.command("import").argument("<file>", "A JUnit XML or Playwright JSON report").description("Turn a report another runner wrote into a run in Plune \u2014 no provider key needed").option("--format <fmt>", "junit | playwright-json; detected from the file when omitted").option("--key <externalKey>", "The key several jobs share so their reports land in one run").option("--create", "Offer tests nothing matched to the review queue", false).action(
4440
+ async (file, options, command) => {
4441
+ const [{ handleRunImport: handleRunImport2 }, { IMPORT_FORMATS: IMPORT_FORMATS2, UnknownFormatError: UnknownFormatError2, XmlParseError: XmlParseError2, JsonReportError: JsonReportError2 }] = await Promise.all([
4442
+ Promise.resolve().then(() => (init_run_import(), run_import_exports)),
4443
+ Promise.resolve().then(() => (init_importers(), importers_exports))
4444
+ ]);
4445
+ const format = options.format;
4446
+ if (format !== void 0 && !IMPORT_FORMATS2.includes(format)) {
4447
+ process.stderr.write(`Unknown --format "${format}". Use ${IMPORT_FORMATS2.join(" or ")}.
4448
+ `);
4449
+ process.exit(2);
4450
+ return;
4451
+ }
4452
+ try {
4453
+ await handleRunImport2({
4454
+ file,
4455
+ ...format !== void 0 ? { format } : {},
4456
+ ...options.key !== void 0 ? { key: options.key } : {},
4457
+ ...options.create ? { create: true } : {}
4458
+ });
4459
+ } catch (err) {
4460
+ if (err instanceof UnknownFormatError2 || err instanceof XmlParseError2 || err instanceof JsonReportError2) {
4461
+ process.stderr.write(err.message + "\n");
4462
+ process.exit(2);
4463
+ return;
4464
+ }
4465
+ await failRunCommand(err, verboseOf(command));
4466
+ }
4467
+ }
4468
+ );
3917
4469
  program.command("report").description("Render the most recent run in a chosen format").option("--format <fmt>", "console | json | markdown", "console").option("-o, --output <path>", "Write the report to a file instead of stdout").action(async (options, command) => {
3918
4470
  const globals = command.optsWithGlobals();
3919
4471
  const [{ loadEnv: loadEnv2 }, { handleReport: handleReport2, ReportNotFoundError: ReportNotFoundError2 }, { renderReport: renderReport2 }] = await Promise.all([
@@ -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.8.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",