@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 +48 -3
- package/dist/cli.cjs +578 -26
- package/dist/reporter-core.cjs +18 -3
- package/dist/reporter-core.d.cts +8 -0
- package/dist/reporter-core.d.ts +8 -0
- package/dist/reporter-core.js +18 -3
- package/package.json +1 -1
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.
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 &&
|
|
3595
|
+
if (!force && fs12.existsSync(target)) {
|
|
3074
3596
|
return "skipped";
|
|
3075
3597
|
}
|
|
3076
|
-
|
|
3077
|
-
|
|
3598
|
+
fs12.mkdirSync(path9.dirname(target), { recursive: true });
|
|
3599
|
+
fs12.writeFileSync(target, content, "utf8");
|
|
3078
3600
|
return "written";
|
|
3079
3601
|
}
|
|
3080
|
-
var
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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([
|
package/dist/reporter-core.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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`);
|
package/dist/reporter-core.d.cts
CHANGED
|
@@ -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
|
}
|
package/dist/reporter-core.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/reporter-core.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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