@plune-ai/cli 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/cli.cjs +549 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -103,12 +103,41 @@ provider API key is read from the environment based on `provider.type`:
|
|
|
103
103
|
| `plune login` | Save a [Plune platform](https://plune.ai/platform) API token so `sync` and `ingest` can reach it. The token is **checked against the API before it is saved**, so a wrong one fails here rather than two commands later. Get one at `https://beta.plune.ai` → Settings → API tokens. Flags: `--token <token>` (omit to paste it or pipe it via stdin), `--skip-verify` (save without checking, for offline setup). |
|
|
104
104
|
| `plune logout` | Remove the saved token. |
|
|
105
105
|
| `plune sync` | Upload the latest local run to the platform. Flags: `--file <path>` to send a specific run JSON. |
|
|
106
|
+
| `plune run import <file>` | Turn a **JUnit XML** or **Playwright JSON** report into a run in Plune. Needs no provider key — nothing is generated. Flags: `--format junit\|playwright-json` (detected from the file when omitted), `--key <externalKey>` to land several reports in one run, `--create` to offer unmatched tests to the review queue. |
|
|
106
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
|
+
Sharded CI? Give every job the same `--key` (or `PLUNE_RUN`), and close the run with
|
|
139
|
+
`plune run finish <id>` once they are all done.
|
|
140
|
+
|
|
112
141
|
## Optional: keep a history
|
|
113
142
|
|
|
114
143
|
Everything above works with no account, no network, and no token — that does not change. If you
|
package/dist/cli.cjs
CHANGED
|
@@ -2749,6 +2749,502 @@ var init_run_lifecycle = __esm({
|
|
|
2749
2749
|
}
|
|
2750
2750
|
});
|
|
2751
2751
|
|
|
2752
|
+
// src/reporter-core/result-key.ts
|
|
2753
|
+
function resultKey(testId, retry) {
|
|
2754
|
+
const plain = `${testId}#${retry}`;
|
|
2755
|
+
return plain.length <= RESULT_KEY_MAX ? plain : `${sha256(testId).slice(0, HASH_CHARS)}#${retry}`;
|
|
2756
|
+
}
|
|
2757
|
+
var RESULT_KEY_MAX, HASH_CHARS;
|
|
2758
|
+
var init_result_key = __esm({
|
|
2759
|
+
"src/reporter-core/result-key.ts"() {
|
|
2760
|
+
"use strict";
|
|
2761
|
+
init_cjs_shims();
|
|
2762
|
+
init_hash();
|
|
2763
|
+
RESULT_KEY_MAX = 200;
|
|
2764
|
+
HASH_CHARS = 32;
|
|
2765
|
+
}
|
|
2766
|
+
});
|
|
2767
|
+
|
|
2768
|
+
// src/importers/xml.ts
|
|
2769
|
+
function decode(raw) {
|
|
2770
|
+
if (!raw.includes("&")) return raw;
|
|
2771
|
+
return raw.replace(/&(#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);/g, (whole, body) => {
|
|
2772
|
+
if (body.startsWith("#")) {
|
|
2773
|
+
const hex = body[1] === "x" || body[1] === "X";
|
|
2774
|
+
const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
|
|
2775
|
+
try {
|
|
2776
|
+
return Number.isNaN(code) ? whole : String.fromCodePoint(code);
|
|
2777
|
+
} catch {
|
|
2778
|
+
return whole;
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
return NAMED[body] ?? whole;
|
|
2782
|
+
});
|
|
2783
|
+
}
|
|
2784
|
+
function parseXml(source, file) {
|
|
2785
|
+
const root = { name: "#document", attrs: {}, children: [], text: "", line: 1 };
|
|
2786
|
+
const stack = [root];
|
|
2787
|
+
let i = 0;
|
|
2788
|
+
let line = 1;
|
|
2789
|
+
const advance = (to) => {
|
|
2790
|
+
for (let j = i; j < to; j++) if (source[j] === "\n") line += 1;
|
|
2791
|
+
i = to;
|
|
2792
|
+
};
|
|
2793
|
+
const top = () => stack[stack.length - 1];
|
|
2794
|
+
while (i < source.length) {
|
|
2795
|
+
const lt = source.indexOf("<", i);
|
|
2796
|
+
if (lt === -1) break;
|
|
2797
|
+
if (lt > i) {
|
|
2798
|
+
const between = source.slice(i, lt);
|
|
2799
|
+
if (between.trim() !== "") top().text += decode(between);
|
|
2800
|
+
advance(lt);
|
|
2801
|
+
}
|
|
2802
|
+
if (source.startsWith("<!--", i)) {
|
|
2803
|
+
const end = source.indexOf("-->", i + 4);
|
|
2804
|
+
if (end === -1) throw new XmlParseError(file, line, "a comment is never closed");
|
|
2805
|
+
advance(end + 3);
|
|
2806
|
+
continue;
|
|
2807
|
+
}
|
|
2808
|
+
if (source.startsWith("<![CDATA[", i)) {
|
|
2809
|
+
const end = source.indexOf("]]>", i + 9);
|
|
2810
|
+
if (end === -1) throw new XmlParseError(file, line, "a CDATA section is never closed");
|
|
2811
|
+
top().text += source.slice(i + 9, end);
|
|
2812
|
+
advance(end + 3);
|
|
2813
|
+
continue;
|
|
2814
|
+
}
|
|
2815
|
+
if (source.startsWith("<?", i)) {
|
|
2816
|
+
const end = source.indexOf("?>", i + 2);
|
|
2817
|
+
if (end === -1) throw new XmlParseError(file, line, "a processing instruction is never closed");
|
|
2818
|
+
advance(end + 2);
|
|
2819
|
+
continue;
|
|
2820
|
+
}
|
|
2821
|
+
if (source.startsWith("<!", i)) {
|
|
2822
|
+
const end = source.indexOf(">", i + 2);
|
|
2823
|
+
if (end === -1) throw new XmlParseError(file, line, "a declaration is never closed");
|
|
2824
|
+
advance(end + 1);
|
|
2825
|
+
continue;
|
|
2826
|
+
}
|
|
2827
|
+
if (source.startsWith("</", i)) {
|
|
2828
|
+
const end = source.indexOf(">", i + 2);
|
|
2829
|
+
if (end === -1) throw new XmlParseError(file, line, "a closing tag is never closed");
|
|
2830
|
+
const name2 = source.slice(i + 2, end).trim();
|
|
2831
|
+
const open = top();
|
|
2832
|
+
if (open === root) throw new XmlParseError(file, line, `</${name2}> closes nothing`);
|
|
2833
|
+
if (open.name !== name2) {
|
|
2834
|
+
throw new XmlParseError(file, line, `</${name2}> closes <${open.name}>, opened on line ${open.line}`);
|
|
2835
|
+
}
|
|
2836
|
+
stack.pop();
|
|
2837
|
+
advance(end + 1);
|
|
2838
|
+
continue;
|
|
2839
|
+
}
|
|
2840
|
+
const startLine = line;
|
|
2841
|
+
let j = i + 1;
|
|
2842
|
+
const nameStart = j;
|
|
2843
|
+
while (j < source.length && !NAME_END.test(source[j])) j += 1;
|
|
2844
|
+
const name = source.slice(nameStart, j);
|
|
2845
|
+
if (name === "") throw new XmlParseError(file, startLine, "a tag with no name");
|
|
2846
|
+
const el = { name, attrs: {}, children: [], text: "", line: startLine };
|
|
2847
|
+
let selfClosing = false;
|
|
2848
|
+
for (; ; ) {
|
|
2849
|
+
while (j < source.length && SPACE.test(source[j])) j += 1;
|
|
2850
|
+
if (j >= source.length) throw new XmlParseError(file, startLine, `<${name}> is never closed`);
|
|
2851
|
+
if (source[j] === "/" && source[j + 1] === ">") {
|
|
2852
|
+
selfClosing = true;
|
|
2853
|
+
j += 2;
|
|
2854
|
+
break;
|
|
2855
|
+
}
|
|
2856
|
+
if (source[j] === ">") {
|
|
2857
|
+
j += 1;
|
|
2858
|
+
break;
|
|
2859
|
+
}
|
|
2860
|
+
const attrStart = j;
|
|
2861
|
+
while (j < source.length && !ATTR_NAME_END.test(source[j])) j += 1;
|
|
2862
|
+
const attr = source.slice(attrStart, j);
|
|
2863
|
+
if (attr === "") throw new XmlParseError(file, startLine, `<${name}> has an attribute with no name`);
|
|
2864
|
+
while (j < source.length && SPACE.test(source[j])) j += 1;
|
|
2865
|
+
if (source[j] !== "=") {
|
|
2866
|
+
throw new XmlParseError(file, startLine, `<${name}> attribute "${attr}" has no value`);
|
|
2867
|
+
}
|
|
2868
|
+
j += 1;
|
|
2869
|
+
while (j < source.length && SPACE.test(source[j])) j += 1;
|
|
2870
|
+
const quote = source[j];
|
|
2871
|
+
if (quote !== '"' && quote !== "'") {
|
|
2872
|
+
throw new XmlParseError(file, startLine, `<${name}> attribute "${attr}" is not quoted`);
|
|
2873
|
+
}
|
|
2874
|
+
const close = source.indexOf(quote, j + 1);
|
|
2875
|
+
if (close === -1) throw new XmlParseError(file, startLine, `<${name}> attribute "${attr}" is never closed`);
|
|
2876
|
+
el.attrs[attr] = decode(source.slice(j + 1, close));
|
|
2877
|
+
j = close + 1;
|
|
2878
|
+
}
|
|
2879
|
+
top().children.push(el);
|
|
2880
|
+
if (!selfClosing) stack.push(el);
|
|
2881
|
+
advance(j);
|
|
2882
|
+
}
|
|
2883
|
+
if (stack.length > 1) {
|
|
2884
|
+
const open = top();
|
|
2885
|
+
throw new XmlParseError(file, open.line, `<${open.name}> is never closed`);
|
|
2886
|
+
}
|
|
2887
|
+
if (root.children.length === 0) throw new XmlParseError(file, 1, "no elements \u2014 is this an XML report?");
|
|
2888
|
+
return root;
|
|
2889
|
+
}
|
|
2890
|
+
var XmlParseError, NAMED, NAME_END, ATTR_NAME_END, SPACE;
|
|
2891
|
+
var init_xml = __esm({
|
|
2892
|
+
"src/importers/xml.ts"() {
|
|
2893
|
+
"use strict";
|
|
2894
|
+
init_cjs_shims();
|
|
2895
|
+
XmlParseError = class extends Error {
|
|
2896
|
+
constructor(file, line, detail) {
|
|
2897
|
+
super(`${file}:${line} \u2014 ${detail}`);
|
|
2898
|
+
this.file = file;
|
|
2899
|
+
this.line = line;
|
|
2900
|
+
this.name = "XmlParseError";
|
|
2901
|
+
}
|
|
2902
|
+
file;
|
|
2903
|
+
line;
|
|
2904
|
+
};
|
|
2905
|
+
NAMED = {
|
|
2906
|
+
amp: "&",
|
|
2907
|
+
lt: "<",
|
|
2908
|
+
gt: ">",
|
|
2909
|
+
quot: '"',
|
|
2910
|
+
apos: "'"
|
|
2911
|
+
};
|
|
2912
|
+
NAME_END = /[\s/>]/;
|
|
2913
|
+
ATTR_NAME_END = /[\s=/>]/;
|
|
2914
|
+
SPACE = /\s/;
|
|
2915
|
+
}
|
|
2916
|
+
});
|
|
2917
|
+
|
|
2918
|
+
// src/importers/junit.ts
|
|
2919
|
+
function locate(el, suite, out) {
|
|
2920
|
+
for (const child of el.children) {
|
|
2921
|
+
if (child.name === "testcase") out.push({ test: child, suite });
|
|
2922
|
+
else locate(child, child.name === "testsuite" ? child : suite, out);
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
function outcomeOf(test) {
|
|
2926
|
+
for (const name of ["error", "failure", "skipped"]) {
|
|
2927
|
+
const found = test.children.find((c) => c.name === name);
|
|
2928
|
+
if (found !== void 0) return { status: name, detail: found };
|
|
2929
|
+
}
|
|
2930
|
+
return { status: "pass", detail: void 0 };
|
|
2931
|
+
}
|
|
2932
|
+
function errorTextOf(detail) {
|
|
2933
|
+
if (detail === void 0) return "";
|
|
2934
|
+
const head = [detail.attrs["message"], detail.attrs["type"]].filter((s) => s !== void 0 && s !== "");
|
|
2935
|
+
return [...head, detail.text.trim()].filter((s) => s !== "").join("\n\n");
|
|
2936
|
+
}
|
|
2937
|
+
function keyOf(classname, name) {
|
|
2938
|
+
return { kind: "path-title", value: classname === "" ? name : `${classname}#${name}` };
|
|
2939
|
+
}
|
|
2940
|
+
function titleOf(classname, name) {
|
|
2941
|
+
if (classname === "") return name;
|
|
2942
|
+
if (classname === name || classname.endsWith(` ${name}`) || classname.endsWith(`\u203A${name}`)) return classname;
|
|
2943
|
+
return `${classname} \u203A ${name}`;
|
|
2944
|
+
}
|
|
2945
|
+
function specRefOf(test, suite, classname) {
|
|
2946
|
+
const file = test.attrs["file"] ?? suite?.attrs["file"] ?? "";
|
|
2947
|
+
if (file !== "") {
|
|
2948
|
+
const line = test.attrs["line"];
|
|
2949
|
+
return line !== void 0 && line !== "" ? `${file}:${line}` : file;
|
|
2950
|
+
}
|
|
2951
|
+
return classname === "" ? void 0 : classname;
|
|
2952
|
+
}
|
|
2953
|
+
function executionOf(test, suite) {
|
|
2954
|
+
const stamp = suite?.attrs["timestamp"];
|
|
2955
|
+
if (stamp === void 0 || stamp === "") return void 0;
|
|
2956
|
+
const started = new Date(stamp);
|
|
2957
|
+
if (Number.isNaN(started.getTime())) return void 0;
|
|
2958
|
+
const seconds = Number.parseFloat(test.attrs["time"] ?? "");
|
|
2959
|
+
const durationMs = Number.isFinite(seconds) && seconds >= 0 ? Math.round(seconds * 1e3) : 0;
|
|
2960
|
+
return {
|
|
2961
|
+
startedAt: started.toISOString(),
|
|
2962
|
+
finishedAt: new Date(started.getTime() + durationMs).toISOString(),
|
|
2963
|
+
durationMs,
|
|
2964
|
+
// The base format has no reruns. Surefire's `<rerunFailure>` does, and reading it is work for
|
|
2965
|
+
// the day somebody asks; reporting 0 says «first attempt», which is true of every case here.
|
|
2966
|
+
retry: 0
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
function readJUnit(source, file) {
|
|
2970
|
+
const doc = parseXml(source, file);
|
|
2971
|
+
const found = [];
|
|
2972
|
+
locate(doc, void 0, found);
|
|
2973
|
+
return found.map(({ test, suite }) => {
|
|
2974
|
+
const name = test.attrs["name"] ?? "";
|
|
2975
|
+
const classname = test.attrs["classname"] ?? test.attrs["class"] ?? "";
|
|
2976
|
+
const { status, detail } = outcomeOf(test);
|
|
2977
|
+
const key = keyOf(classname, name);
|
|
2978
|
+
const specRef = specRefOf(test, suite, classname);
|
|
2979
|
+
const errorContext = errorTextOf(detail);
|
|
2980
|
+
const execution = executionOf(test, suite);
|
|
2981
|
+
return {
|
|
2982
|
+
resultKey: resultKey(key.value, 0),
|
|
2983
|
+
keys: [key],
|
|
2984
|
+
source: SOURCE,
|
|
2985
|
+
rawStatus: status,
|
|
2986
|
+
title: titleOf(classname, name),
|
|
2987
|
+
...specRef !== void 0 ? { specRef } : {},
|
|
2988
|
+
...execution !== void 0 ? { execution } : {},
|
|
2989
|
+
...errorContext !== "" ? { errorContext } : {}
|
|
2990
|
+
};
|
|
2991
|
+
});
|
|
2992
|
+
}
|
|
2993
|
+
function looksLikeJUnit(source) {
|
|
2994
|
+
return /<\s*testsuites?\b/.test(source.slice(0, 4096));
|
|
2995
|
+
}
|
|
2996
|
+
var SOURCE;
|
|
2997
|
+
var init_junit = __esm({
|
|
2998
|
+
"src/importers/junit.ts"() {
|
|
2999
|
+
"use strict";
|
|
3000
|
+
init_cjs_shims();
|
|
3001
|
+
init_result_key();
|
|
3002
|
+
init_xml();
|
|
3003
|
+
SOURCE = "junit";
|
|
3004
|
+
}
|
|
3005
|
+
});
|
|
3006
|
+
|
|
3007
|
+
// src/importers/playwright-json.ts
|
|
3008
|
+
function statedIdOf(test, title) {
|
|
3009
|
+
const annotation = test.annotations?.find((a) => a.type === "PluneId")?.description;
|
|
3010
|
+
if (annotation !== void 0 && annotation !== "") return annotation;
|
|
3011
|
+
return TOKEN.exec(title)?.[1];
|
|
3012
|
+
}
|
|
3013
|
+
function errorTextOf2(result) {
|
|
3014
|
+
return (result.errors ?? []).map((e) => e.stack ?? e.message ?? e.value ?? "").filter((text2) => text2 !== "").join("\n\n");
|
|
3015
|
+
}
|
|
3016
|
+
function pendingFrom(spec, test, result, file, titles) {
|
|
3017
|
+
const pathTitle = [file, ...titles].join("#");
|
|
3018
|
+
const seed = test.id ?? pathTitle;
|
|
3019
|
+
const stated = statedIdOf(test, spec.title ?? "");
|
|
3020
|
+
const expected = test.expectedStatus;
|
|
3021
|
+
const errorContext = errorTextOf2(result);
|
|
3022
|
+
const keys = [
|
|
3023
|
+
...test.id !== void 0 ? [{ kind: "playwright-id", value: test.id }] : [],
|
|
3024
|
+
{ kind: "path-title", value: pathTitle }
|
|
3025
|
+
];
|
|
3026
|
+
const started = result.startTime !== void 0 ? new Date(result.startTime) : void 0;
|
|
3027
|
+
const durationMs = typeof result.duration === "number" && result.duration >= 0 ? result.duration : 0;
|
|
3028
|
+
const retry = typeof result.retry === "number" && result.retry >= 0 ? result.retry : 0;
|
|
3029
|
+
return {
|
|
3030
|
+
resultKey: resultKey(seed, retry),
|
|
3031
|
+
...stated !== void 0 ? { testCaseId: stated } : {},
|
|
3032
|
+
keys,
|
|
3033
|
+
title: titles.join(" \u203A "),
|
|
3034
|
+
specRef: spec.line !== void 0 ? `${file}:${spec.line}` : file,
|
|
3035
|
+
source: SOURCE2,
|
|
3036
|
+
// Untouched, as everywhere: `timedOut` means what the project's map says it means, not what
|
|
3037
|
+
// this file decided.
|
|
3038
|
+
rawStatus: result.status ?? "unknown",
|
|
3039
|
+
...expected === "failed" || expected === "skipped" ? { expectedStatus: expected } : {},
|
|
3040
|
+
...started !== void 0 && !Number.isNaN(started.getTime()) ? {
|
|
3041
|
+
execution: {
|
|
3042
|
+
startedAt: started.toISOString(),
|
|
3043
|
+
finishedAt: new Date(started.getTime() + durationMs).toISOString(),
|
|
3044
|
+
durationMs,
|
|
3045
|
+
retry,
|
|
3046
|
+
...result.workerIndex !== void 0 ? { worker: String(result.workerIndex) } : {}
|
|
3047
|
+
}
|
|
3048
|
+
} : {},
|
|
3049
|
+
...errorContext !== "" ? { errorContext } : {}
|
|
3050
|
+
};
|
|
3051
|
+
}
|
|
3052
|
+
function walk(suite, describes, depth, out) {
|
|
3053
|
+
const here = depth === 0 ? [] : [...describes, suite.title ?? ""];
|
|
3054
|
+
for (const spec of suite.specs ?? []) {
|
|
3055
|
+
const file = spec.file ?? suite.file ?? suite.title ?? "";
|
|
3056
|
+
const titles = [...here, spec.title ?? ""];
|
|
3057
|
+
for (const test of spec.tests ?? []) {
|
|
3058
|
+
for (const result of test.results ?? []) out.push(pendingFrom(spec, test, result, file, titles));
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
for (const child of suite.suites ?? []) walk(child, here, depth + 1, out);
|
|
3062
|
+
}
|
|
3063
|
+
function readPlaywrightJson(source, file) {
|
|
3064
|
+
let parsed;
|
|
3065
|
+
try {
|
|
3066
|
+
parsed = JSON.parse(source);
|
|
3067
|
+
} catch (err) {
|
|
3068
|
+
throw new JsonReportError(file, `not JSON \u2014 ${err instanceof Error ? err.message : String(err)}`);
|
|
3069
|
+
}
|
|
3070
|
+
if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.suites)) {
|
|
3071
|
+
throw new JsonReportError(file, 'no "suites" array \u2014 is this a Playwright JSON report?');
|
|
3072
|
+
}
|
|
3073
|
+
const out = [];
|
|
3074
|
+
for (const suite of parsed.suites) walk(suite, [], 0, out);
|
|
3075
|
+
return out;
|
|
3076
|
+
}
|
|
3077
|
+
function looksLikePlaywrightJson(source) {
|
|
3078
|
+
const head = source.slice(0, 4096);
|
|
3079
|
+
return head.trimStart().startsWith("{") && head.includes('"suites"');
|
|
3080
|
+
}
|
|
3081
|
+
var SOURCE2, TOKEN, JsonReportError;
|
|
3082
|
+
var init_playwright_json = __esm({
|
|
3083
|
+
"src/importers/playwright-json.ts"() {
|
|
3084
|
+
"use strict";
|
|
3085
|
+
init_cjs_shims();
|
|
3086
|
+
init_result_key();
|
|
3087
|
+
SOURCE2 = "playwright";
|
|
3088
|
+
TOKEN = /@P([A-Za-z0-9_-]+)/;
|
|
3089
|
+
JsonReportError = class extends Error {
|
|
3090
|
+
constructor(file, detail) {
|
|
3091
|
+
super(`${file} \u2014 ${detail}`);
|
|
3092
|
+
this.file = file;
|
|
3093
|
+
this.name = "JsonReportError";
|
|
3094
|
+
}
|
|
3095
|
+
file;
|
|
3096
|
+
};
|
|
3097
|
+
}
|
|
3098
|
+
});
|
|
3099
|
+
|
|
3100
|
+
// src/importers/index.ts
|
|
3101
|
+
var importers_exports = {};
|
|
3102
|
+
__export(importers_exports, {
|
|
3103
|
+
IMPORT_FORMATS: () => IMPORT_FORMATS,
|
|
3104
|
+
JsonReportError: () => JsonReportError,
|
|
3105
|
+
UnknownFormatError: () => UnknownFormatError,
|
|
3106
|
+
XmlParseError: () => XmlParseError,
|
|
3107
|
+
detectFormat: () => detectFormat,
|
|
3108
|
+
readReport: () => readReport
|
|
3109
|
+
});
|
|
3110
|
+
function detectFormat(source, file) {
|
|
3111
|
+
if (looksLikeJUnit(source)) return "junit";
|
|
3112
|
+
if (looksLikePlaywrightJson(source)) return "playwright-json";
|
|
3113
|
+
throw new UnknownFormatError(
|
|
3114
|
+
file,
|
|
3115
|
+
'no <testsuite> and no Playwright "suites" \u2014 this is not a report we can read.'
|
|
3116
|
+
);
|
|
3117
|
+
}
|
|
3118
|
+
function readReport(source, file, format) {
|
|
3119
|
+
const chosen = format ?? detectFormat(source, file);
|
|
3120
|
+
return {
|
|
3121
|
+
format: chosen,
|
|
3122
|
+
results: chosen === "junit" ? readJUnit(source, file) : readPlaywrightJson(source, file)
|
|
3123
|
+
};
|
|
3124
|
+
}
|
|
3125
|
+
var IMPORT_FORMATS, UnknownFormatError;
|
|
3126
|
+
var init_importers = __esm({
|
|
3127
|
+
"src/importers/index.ts"() {
|
|
3128
|
+
"use strict";
|
|
3129
|
+
init_cjs_shims();
|
|
3130
|
+
init_junit();
|
|
3131
|
+
init_playwright_json();
|
|
3132
|
+
init_xml();
|
|
3133
|
+
init_playwright_json();
|
|
3134
|
+
IMPORT_FORMATS = ["junit", "playwright-json"];
|
|
3135
|
+
UnknownFormatError = class extends Error {
|
|
3136
|
+
constructor(file, detail) {
|
|
3137
|
+
super(`${file} \u2014 ${detail} Say which with --format ${IMPORT_FORMATS.join(" | ")}.`);
|
|
3138
|
+
this.name = "UnknownFormatError";
|
|
3139
|
+
}
|
|
3140
|
+
};
|
|
3141
|
+
}
|
|
3142
|
+
});
|
|
3143
|
+
|
|
3144
|
+
// src/cli/commands/run-import.ts
|
|
3145
|
+
var run_import_exports = {};
|
|
3146
|
+
__export(run_import_exports, {
|
|
3147
|
+
handleRunImport: () => handleRunImport
|
|
3148
|
+
});
|
|
3149
|
+
async function handleRunImport(options) {
|
|
3150
|
+
const write = options.write ?? ((line) => void process.stdout.write(`${line}
|
|
3151
|
+
`));
|
|
3152
|
+
let source;
|
|
3153
|
+
try {
|
|
3154
|
+
source = fs8.readFileSync(options.file, "utf8");
|
|
3155
|
+
} catch {
|
|
3156
|
+
throw new RunCommandError(`Cannot read ${options.file}.`);
|
|
3157
|
+
}
|
|
3158
|
+
const { format, results } = readReport(source, options.file, options.format);
|
|
3159
|
+
if (results.length === 0) {
|
|
3160
|
+
throw new RunCommandError(`No test cases in ${options.file} \u2014 it parsed, but there is nothing in it.`);
|
|
3161
|
+
}
|
|
3162
|
+
const env = readEnv();
|
|
3163
|
+
const apiUrl = resolveApiUrl(options.apiUrl ?? env.config.apiUrl);
|
|
3164
|
+
const token = options.token ?? env.config.token ?? loadToken() ?? "";
|
|
3165
|
+
if (token === "") throw new NoTokenError();
|
|
3166
|
+
const session = await startRun(
|
|
3167
|
+
{
|
|
3168
|
+
apiUrl,
|
|
3169
|
+
token,
|
|
3170
|
+
...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
|
|
3171
|
+
...options.key !== void 0 ? { externalKey: options.key } : {},
|
|
3172
|
+
...options.create === true ? { offerDiscovered: true } : {},
|
|
3173
|
+
kind: "automated",
|
|
3174
|
+
meta: { runner: format },
|
|
3175
|
+
log: write
|
|
3176
|
+
},
|
|
3177
|
+
// The file is the complete list of what ran, so the platform can resolve every test in one
|
|
3178
|
+
// request and derive what never ran from the same list (AC-09/AC-10) — the lazy path would be
|
|
3179
|
+
// a lookup per result.
|
|
3180
|
+
results.map((r) => r.keys)
|
|
3181
|
+
);
|
|
3182
|
+
for (const result2 of results) await session.add(result2);
|
|
3183
|
+
await session.finish();
|
|
3184
|
+
const stats = session.stats;
|
|
3185
|
+
const result = {
|
|
3186
|
+
runId: session.runId,
|
|
3187
|
+
format,
|
|
3188
|
+
parsed: results.length,
|
|
3189
|
+
accepted: stats.accepted,
|
|
3190
|
+
duplicate: stats.duplicate,
|
|
3191
|
+
conflict: stats.conflict,
|
|
3192
|
+
rejected: stats.rejected,
|
|
3193
|
+
unresolved: stats.unresolved,
|
|
3194
|
+
offered: stats.offered,
|
|
3195
|
+
deferred: stats.deferred,
|
|
3196
|
+
unlocatable: results.filter((r) => r.specRef === void 0).length,
|
|
3197
|
+
offering: options.create === true
|
|
3198
|
+
};
|
|
3199
|
+
for (const line of describe(result, apiUrl)) write(line);
|
|
3200
|
+
return result;
|
|
3201
|
+
}
|
|
3202
|
+
function runUrl2(apiUrl, id) {
|
|
3203
|
+
const web = dashboardUrl(apiUrl);
|
|
3204
|
+
return web === void 0 ? `${apiUrl}/v1/runs/${id}` : `${web}/runs/${id}`;
|
|
3205
|
+
}
|
|
3206
|
+
function describe(result, apiUrl) {
|
|
3207
|
+
const lines = [
|
|
3208
|
+
`Imported ${result.parsed} result(s) from a ${result.format} report: ${result.accepted} accepted, ${result.duplicate} already there, ${result.unresolved} unmatched.`
|
|
3209
|
+
];
|
|
3210
|
+
if (result.conflict > 0 || result.rejected > 0) {
|
|
3211
|
+
lines.push(`${result.conflict} conflicted and ${result.rejected} were refused.`);
|
|
3212
|
+
}
|
|
3213
|
+
if (result.deferred > 0) {
|
|
3214
|
+
lines.push(
|
|
3215
|
+
`${result.deferred} could not be sent and are in the fallback file \u2014 "plune run report" retries them.`
|
|
3216
|
+
);
|
|
3217
|
+
}
|
|
3218
|
+
if (result.offered > 0) {
|
|
3219
|
+
lines.push(`${result.offered} unknown test(s) offered to the review queue.`);
|
|
3220
|
+
} else if (result.unresolved > 0 && !result.offering) {
|
|
3221
|
+
lines.push("Nothing was offered to the review queue \u2014 pass --create to propose the unmatched tests.");
|
|
3222
|
+
} else if (result.unresolved > 0) {
|
|
3223
|
+
lines.push("Nothing new to offer \u2014 the unmatched tests are already in the review queue.");
|
|
3224
|
+
}
|
|
3225
|
+
if (result.unlocatable > 0) {
|
|
3226
|
+
lines.push(
|
|
3227
|
+
`${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.`
|
|
3228
|
+
);
|
|
3229
|
+
}
|
|
3230
|
+
if (result.runId !== null) lines.push(runUrl2(apiUrl, result.runId));
|
|
3231
|
+
return lines;
|
|
3232
|
+
}
|
|
3233
|
+
var fs8;
|
|
3234
|
+
var init_run_import = __esm({
|
|
3235
|
+
"src/cli/commands/run-import.ts"() {
|
|
3236
|
+
"use strict";
|
|
3237
|
+
init_cjs_shims();
|
|
3238
|
+
fs8 = __toESM(require("fs"), 1);
|
|
3239
|
+
init_session();
|
|
3240
|
+
init_importers();
|
|
3241
|
+
init_run_lifecycle();
|
|
3242
|
+
init_api_url();
|
|
3243
|
+
init_credentials();
|
|
3244
|
+
init_env2();
|
|
3245
|
+
}
|
|
3246
|
+
});
|
|
3247
|
+
|
|
2752
3248
|
// src/cli/commands/report.ts
|
|
2753
3249
|
var report_exports = {};
|
|
2754
3250
|
__export(report_exports, {
|
|
@@ -2765,7 +3261,7 @@ function handleReport(opts = {}) {
|
|
|
2765
3261
|
const file = path7.join(cwd, ".plune", "last-run.json");
|
|
2766
3262
|
let raw;
|
|
2767
3263
|
try {
|
|
2768
|
-
raw =
|
|
3264
|
+
raw = fs9.readFileSync(file, "utf8");
|
|
2769
3265
|
} catch {
|
|
2770
3266
|
throw new ReportNotFoundError('No saved run found. Run "plune run" first.');
|
|
2771
3267
|
}
|
|
@@ -2780,12 +3276,12 @@ function handleReport(opts = {}) {
|
|
|
2780
3276
|
}
|
|
2781
3277
|
return parsed;
|
|
2782
3278
|
}
|
|
2783
|
-
var
|
|
3279
|
+
var fs9, path7, ReportNotFoundError;
|
|
2784
3280
|
var init_report = __esm({
|
|
2785
3281
|
"src/cli/commands/report.ts"() {
|
|
2786
3282
|
"use strict";
|
|
2787
3283
|
init_cjs_shims();
|
|
2788
|
-
|
|
3284
|
+
fs9 = __toESM(require("fs"), 1);
|
|
2789
3285
|
path7 = __toESM(require("path"), 1);
|
|
2790
3286
|
ReportNotFoundError = class extends Error {
|
|
2791
3287
|
code = "NO_SAVED_RUN";
|
|
@@ -2860,7 +3356,7 @@ function isRunResult2(v) {
|
|
|
2860
3356
|
function loadRunResult(file, label) {
|
|
2861
3357
|
let raw;
|
|
2862
3358
|
try {
|
|
2863
|
-
raw =
|
|
3359
|
+
raw = fs10.readFileSync(file, "utf8");
|
|
2864
3360
|
} catch {
|
|
2865
3361
|
throw new DiffInputError(`Cannot read ${label} run file: ${file}`);
|
|
2866
3362
|
}
|
|
@@ -2880,12 +3376,12 @@ function handleDiff(opts) {
|
|
|
2880
3376
|
const current = loadRunResult(opts.currentPath, "current");
|
|
2881
3377
|
return diffRuns(baseline, current);
|
|
2882
3378
|
}
|
|
2883
|
-
var
|
|
3379
|
+
var fs10, DiffInputError;
|
|
2884
3380
|
var init_diff2 = __esm({
|
|
2885
3381
|
"src/cli/commands/diff.ts"() {
|
|
2886
3382
|
"use strict";
|
|
2887
3383
|
init_cjs_shims();
|
|
2888
|
-
|
|
3384
|
+
fs10 = __toESM(require("fs"), 1);
|
|
2889
3385
|
init_diff();
|
|
2890
3386
|
DiffInputError = class extends Error {
|
|
2891
3387
|
code = "DIFF_INPUT";
|
|
@@ -3011,7 +3507,7 @@ async function runInitWizard(cwd) {
|
|
|
3011
3507
|
return;
|
|
3012
3508
|
}
|
|
3013
3509
|
const outputPath = path8.join(cwd, "plune.yaml");
|
|
3014
|
-
if (
|
|
3510
|
+
if (fs11.existsSync(outputPath)) {
|
|
3015
3511
|
const overwrite = await clack.confirm({
|
|
3016
3512
|
message: "plune.yaml already exists. Overwrite?"
|
|
3017
3513
|
});
|
|
@@ -3050,17 +3546,17 @@ async function runInitWizard(cwd) {
|
|
|
3050
3546
|
`;
|
|
3051
3547
|
const spin = clack.spinner();
|
|
3052
3548
|
spin.start("Writing plune.yaml\u2026");
|
|
3053
|
-
|
|
3549
|
+
fs11.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
|
|
3054
3550
|
spin.stop("plune.yaml created!");
|
|
3055
3551
|
clack.outro("plune.yaml is ready.");
|
|
3056
3552
|
}
|
|
3057
|
-
var clack,
|
|
3553
|
+
var clack, fs11, path8, import_yaml2;
|
|
3058
3554
|
var init_wizard = __esm({
|
|
3059
3555
|
"src/config/init/wizard.ts"() {
|
|
3060
3556
|
"use strict";
|
|
3061
3557
|
init_cjs_shims();
|
|
3062
3558
|
clack = __toESM(require("@clack/prompts"), 1);
|
|
3063
|
-
|
|
3559
|
+
fs11 = __toESM(require("fs"), 1);
|
|
3064
3560
|
path8 = __toESM(require("path"), 1);
|
|
3065
3561
|
import_yaml2 = require("yaml");
|
|
3066
3562
|
init_errors();
|
|
@@ -3070,19 +3566,19 @@ var init_wizard = __esm({
|
|
|
3070
3566
|
// src/cli/templates/index.ts
|
|
3071
3567
|
function writeTemplateFile(cwd, rel, content, force) {
|
|
3072
3568
|
const target = path9.join(cwd, rel);
|
|
3073
|
-
if (!force &&
|
|
3569
|
+
if (!force && fs12.existsSync(target)) {
|
|
3074
3570
|
return "skipped";
|
|
3075
3571
|
}
|
|
3076
|
-
|
|
3077
|
-
|
|
3572
|
+
fs12.mkdirSync(path9.dirname(target), { recursive: true });
|
|
3573
|
+
fs12.writeFileSync(target, content, "utf8");
|
|
3078
3574
|
return "written";
|
|
3079
3575
|
}
|
|
3080
|
-
var
|
|
3576
|
+
var fs12, path9, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
|
|
3081
3577
|
var init_templates = __esm({
|
|
3082
3578
|
"src/cli/templates/index.ts"() {
|
|
3083
3579
|
"use strict";
|
|
3084
3580
|
init_cjs_shims();
|
|
3085
|
-
|
|
3581
|
+
fs12 = __toESM(require("fs"), 1);
|
|
3086
3582
|
path9 = __toESM(require("path"), 1);
|
|
3087
3583
|
ENV_EXAMPLE_TPL = `# Copy this file to .env and replace the placeholders with your real keys.
|
|
3088
3584
|
# .env is gitignored; .env.example is safe to commit.
|
|
@@ -3342,7 +3838,7 @@ function readRun(cwd, file) {
|
|
|
3342
3838
|
const target = file !== void 0 ? path10.resolve(cwd, file) : path10.join(cwd, ".plune", "last-run.json");
|
|
3343
3839
|
let raw;
|
|
3344
3840
|
try {
|
|
3345
|
-
raw =
|
|
3841
|
+
raw = fs13.readFileSync(target, "utf8");
|
|
3346
3842
|
} catch {
|
|
3347
3843
|
throw new SyncFileError(
|
|
3348
3844
|
file !== void 0 ? `Run file not found: ${target}` : 'No run to sync. Run "plune run" first (or pass --file <path>).'
|
|
@@ -3406,12 +3902,12 @@ function reportSyncFailure(err, write) {
|
|
|
3406
3902
|
}
|
|
3407
3903
|
return null;
|
|
3408
3904
|
}
|
|
3409
|
-
var
|
|
3905
|
+
var fs13, path10, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
|
|
3410
3906
|
var init_sync = __esm({
|
|
3411
3907
|
"src/cli/commands/sync.ts"() {
|
|
3412
3908
|
"use strict";
|
|
3413
3909
|
init_cjs_shims();
|
|
3414
|
-
|
|
3910
|
+
fs13 = __toESM(require("fs"), 1);
|
|
3415
3911
|
path10 = __toESM(require("path"), 1);
|
|
3416
3912
|
init_run_result_schema();
|
|
3417
3913
|
init_api_url();
|
|
@@ -3457,14 +3953,14 @@ var init_sync = __esm({
|
|
|
3457
3953
|
|
|
3458
3954
|
// src/cli/commands/cairn-artifact.ts
|
|
3459
3955
|
async function findLatestCairnRun(runsDir = "runs") {
|
|
3460
|
-
const entries = await
|
|
3956
|
+
const entries = await fs14.readdir(runsDir, { withFileTypes: true }).catch(() => null);
|
|
3461
3957
|
if (entries === null) throw new NoCairnRunsError(runsDir);
|
|
3462
3958
|
let newest;
|
|
3463
3959
|
for (const entry of entries) {
|
|
3464
3960
|
if (!entry.isDirectory()) continue;
|
|
3465
3961
|
const dir = path11.join(runsDir, entry.name);
|
|
3466
3962
|
try {
|
|
3467
|
-
const report = await
|
|
3963
|
+
const report = await fs14.lstat(path11.join(dir, "report.json"));
|
|
3468
3964
|
if (!report.isFile()) continue;
|
|
3469
3965
|
if (newest === void 0 || report.mtimeMs > newest.at) {
|
|
3470
3966
|
newest = { dir, at: report.mtimeMs };
|
|
@@ -3477,7 +3973,7 @@ async function findLatestCairnRun(runsDir = "runs") {
|
|
|
3477
3973
|
}
|
|
3478
3974
|
async function readJson(file) {
|
|
3479
3975
|
try {
|
|
3480
|
-
return JSON.parse(await
|
|
3976
|
+
return JSON.parse(await fs14.readFile(file, "utf8"));
|
|
3481
3977
|
} catch {
|
|
3482
3978
|
return void 0;
|
|
3483
3979
|
}
|
|
@@ -3562,12 +4058,12 @@ async function readCairnRun(dir, deps = {}) {
|
|
|
3562
4058
|
cases
|
|
3563
4059
|
};
|
|
3564
4060
|
}
|
|
3565
|
-
var
|
|
4061
|
+
var fs14, path11, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
|
|
3566
4062
|
var init_cairn_artifact = __esm({
|
|
3567
4063
|
"src/cli/commands/cairn-artifact.ts"() {
|
|
3568
4064
|
"use strict";
|
|
3569
4065
|
init_cjs_shims();
|
|
3570
|
-
|
|
4066
|
+
fs14 = __toESM(require("fs/promises"), 1);
|
|
3571
4067
|
path11 = __toESM(require("path"), 1);
|
|
3572
4068
|
SUPPORTED_ARTIFACT_VERSION = 1;
|
|
3573
4069
|
INGEST_SCHEMA_VERSION = 1;
|
|
@@ -3914,6 +4410,36 @@ function createProgram() {
|
|
|
3914
4410
|
await failRunCommand(err, verboseOf(command));
|
|
3915
4411
|
}
|
|
3916
4412
|
});
|
|
4413
|
+
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(
|
|
4414
|
+
async (file, options, command) => {
|
|
4415
|
+
const [{ handleRunImport: handleRunImport2 }, { IMPORT_FORMATS: IMPORT_FORMATS2, UnknownFormatError: UnknownFormatError2, XmlParseError: XmlParseError2, JsonReportError: JsonReportError2 }] = await Promise.all([
|
|
4416
|
+
Promise.resolve().then(() => (init_run_import(), run_import_exports)),
|
|
4417
|
+
Promise.resolve().then(() => (init_importers(), importers_exports))
|
|
4418
|
+
]);
|
|
4419
|
+
const format = options.format;
|
|
4420
|
+
if (format !== void 0 && !IMPORT_FORMATS2.includes(format)) {
|
|
4421
|
+
process.stderr.write(`Unknown --format "${format}". Use ${IMPORT_FORMATS2.join(" or ")}.
|
|
4422
|
+
`);
|
|
4423
|
+
process.exit(2);
|
|
4424
|
+
return;
|
|
4425
|
+
}
|
|
4426
|
+
try {
|
|
4427
|
+
await handleRunImport2({
|
|
4428
|
+
file,
|
|
4429
|
+
...format !== void 0 ? { format } : {},
|
|
4430
|
+
...options.key !== void 0 ? { key: options.key } : {},
|
|
4431
|
+
...options.create ? { create: true } : {}
|
|
4432
|
+
});
|
|
4433
|
+
} catch (err) {
|
|
4434
|
+
if (err instanceof UnknownFormatError2 || err instanceof XmlParseError2 || err instanceof JsonReportError2) {
|
|
4435
|
+
process.stderr.write(err.message + "\n");
|
|
4436
|
+
process.exit(2);
|
|
4437
|
+
return;
|
|
4438
|
+
}
|
|
4439
|
+
await failRunCommand(err, verboseOf(command));
|
|
4440
|
+
}
|
|
4441
|
+
}
|
|
4442
|
+
);
|
|
3917
4443
|
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
4444
|
const globals = command.optsWithGlobals();
|
|
3919
4445
|
const [{ loadEnv: loadEnv2 }, { handleReport: handleReport2, ReportNotFoundError: ReportNotFoundError2 }, { renderReport: renderReport2 }] = await Promise.all([
|
package/package.json
CHANGED