@plune-ai/cli 0.7.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/dist/cli.cjs CHANGED
@@ -2267,19 +2267,35 @@ function count(env, name) {
2267
2267
  const n = Number(raw);
2268
2268
  return Number.isInteger(n) && n > 0 ? n : void 0;
2269
2269
  }
2270
+ function list(env, name) {
2271
+ const raw = value(env, name);
2272
+ if (raw === void 0) return void 0;
2273
+ const items = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
2274
+ return items.length === 0 ? void 0 : items;
2275
+ }
2270
2276
  function readEnv(env = process.env) {
2271
2277
  const externalKey = value(env, "PLUNE_RUN");
2272
2278
  const token = value(env, "PLUNE_TOKEN");
2273
2279
  const apiUrl = value(env, "PLUNE_API_URL");
2274
2280
  const fallbackPath = value(env, "PLUNE_FALLBACK");
2275
2281
  const batchSize = count(env, "PLUNE_BATCH_SIZE");
2282
+ const title = value(env, "PLUNE_RUN_TITLE");
2283
+ const environment = value(env, "PLUNE_ENV");
2284
+ const labels = list(env, "PLUNE_LABELS");
2285
+ const offerDiscovered = flag(env, "PLUNE_CREATE");
2276
2286
  return {
2277
2287
  config: {
2278
2288
  ...externalKey !== void 0 ? { externalKey } : {},
2279
2289
  ...token !== void 0 ? { token } : {},
2280
2290
  ...apiUrl !== void 0 ? { apiUrl } : {},
2281
2291
  ...fallbackPath !== void 0 ? { fallbackPath } : {},
2282
- ...batchSize !== void 0 ? { batchSize } : {}
2292
+ ...batchSize !== void 0 ? { batchSize } : {},
2293
+ ...title !== void 0 ? { title } : {},
2294
+ ...environment !== void 0 ? { environment } : {},
2295
+ ...labels !== void 0 ? { labels } : {},
2296
+ // Only when set: `false` here would outrank a config file that asked for it, and an unset
2297
+ // variable is not an instruction.
2298
+ ...offerDiscovered ? { offerDiscovered } : {}
2283
2299
  },
2284
2300
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
2285
2301
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
@@ -2293,12 +2309,7 @@ var init_env2 = __esm({
2293
2309
  "src/reporter-core/env.ts"() {
2294
2310
  "use strict";
2295
2311
  init_cjs_shims();
2296
- UNSUPPORTED_VARS = [
2297
- "PLUNE_RUN_TITLE",
2298
- "PLUNE_ENV",
2299
- "PLUNE_LABELS",
2300
- "PLUNE_GROUP"
2301
- ];
2312
+ UNSUPPORTED_VARS = ["PLUNE_GROUP"];
2302
2313
  }
2303
2314
  });
2304
2315
 
@@ -2331,6 +2342,14 @@ function chunk(items, size) {
2331
2342
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
2332
2343
  return out;
2333
2344
  }
2345
+ function warnIfDropped(sent, run, log) {
2346
+ const names = Object.keys(sent);
2347
+ if (names.length === 0) return;
2348
+ if (names.some((name) => run[name] != null)) return;
2349
+ log(
2350
+ `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
2351
+ );
2352
+ }
2334
2353
  async function startRun(passed, expected) {
2335
2354
  const env = readEnv();
2336
2355
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
@@ -2340,6 +2359,7 @@ async function startRun(passed, expected) {
2340
2359
  const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
2341
2360
  const externalKey = cfg.externalKey ?? null;
2342
2361
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
2362
+ const offerDiscovered = cfg.offerDiscovered ?? false;
2343
2363
  const token = cfg.token ?? loadToken() ?? "";
2344
2364
  const client = createClient({
2345
2365
  apiUrl: resolveApiUrl(cfg.apiUrl),
@@ -2352,10 +2372,12 @@ async function startRun(passed, expected) {
2352
2372
  conflict: 0,
2353
2373
  rejected: 0,
2354
2374
  unresolved: 0,
2375
+ offered: 0,
2355
2376
  deferred: 0
2356
2377
  };
2357
2378
  const resolved = /* @__PURE__ */ new Map();
2358
2379
  let buffer = [];
2380
+ const discoveries = /* @__PURE__ */ new Map();
2359
2381
  let runId = null;
2360
2382
  let joined = false;
2361
2383
  let offline = token === "";
@@ -2398,16 +2420,23 @@ async function startRun(passed, expected) {
2398
2420
  if (expected !== void 0 && expected.length > EXPECTED_MAX) {
2399
2421
  log(`plune: ${expected.length} tests is more than the run configuration holds \u2014 reporting the first ${EXPECTED_MAX}.`);
2400
2422
  }
2423
+ const described = {
2424
+ ...cfg.title !== void 0 ? { title: cfg.title } : {},
2425
+ ...cfg.environment !== void 0 ? { environment: cfg.environment } : {},
2426
+ ...cfg.labels !== void 0 ? { labels: cfg.labels } : {}
2427
+ };
2401
2428
  const start = await client.post("/v1/runs", {
2402
2429
  schemaVersion: 2,
2403
2430
  kind: cfg.kind ?? "automated",
2404
2431
  ...externalKey !== null ? { externalKey } : {},
2405
2432
  ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
2406
- ...configuration !== void 0 ? { configuration } : {}
2433
+ ...configuration !== void 0 ? { configuration } : {},
2434
+ ...described
2407
2435
  });
2408
2436
  if (start.ok) {
2409
2437
  runId = start.body.run.id;
2410
2438
  joined = start.status === 200;
2439
+ warnIfDropped(described, start.body.run, log);
2411
2440
  } else {
2412
2441
  log(`plune: could not start the run (${start.detail || start.kind}). Results will be written to ${fallbackPath}.`);
2413
2442
  }
@@ -2422,6 +2451,32 @@ async function startRun(passed, expected) {
2422
2451
  }
2423
2452
  return unanswered ? { kind: "unanswered" } : { kind: "unmatched" };
2424
2453
  }
2454
+ function remember(result) {
2455
+ if (!offerDiscovered) return;
2456
+ if (result.title === void 0 || result.specRef === void 0) return;
2457
+ if (result.keys.length === 0) return;
2458
+ discoveries.set(result.keys[0].value, {
2459
+ keys: result.keys,
2460
+ title: result.title,
2461
+ source: result.source,
2462
+ specRef: result.specRef,
2463
+ rawStatus: result.rawStatus
2464
+ });
2465
+ }
2466
+ async function offer() {
2467
+ if (discoveries.size === 0 || offline || runId === null) return;
2468
+ for (const batch of chunk([...discoveries.values()], DISCOVER_MAX)) {
2469
+ const out = await client.post("/v1/review-items/discovered", {
2470
+ discovered: batch,
2471
+ runId
2472
+ });
2473
+ if (!out.ok) {
2474
+ log(`plune: could not offer ${batch.length} unknown tests for review (${out.detail || out.kind}).`);
2475
+ return;
2476
+ }
2477
+ stats.offered += out.body.results.filter((r) => r.outcome === "queued").length;
2478
+ }
2479
+ }
2425
2480
  async function flush() {
2426
2481
  if (buffer.length === 0) return;
2427
2482
  const pending = buffer;
@@ -2440,6 +2495,7 @@ async function startRun(passed, expected) {
2440
2495
  submissions.push({ ...rest, testCaseId: match.testCaseId });
2441
2496
  } else if (match.kind === "unmatched") {
2442
2497
  stats.unresolved += 1;
2498
+ remember(result);
2443
2499
  } else {
2444
2500
  unanswered.push(result);
2445
2501
  }
@@ -2473,6 +2529,7 @@ async function startRun(passed, expected) {
2473
2529
  if (stats.conflict > 0) parts.push(`${stats.conflict} conflicting`);
2474
2530
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
2475
2531
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
2532
+ if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
2476
2533
  if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
2477
2534
  log(`plune: ${parts.join(" \xB7 ")}`);
2478
2535
  }
@@ -2495,6 +2552,7 @@ async function startRun(passed, expected) {
2495
2552
  if (done) return;
2496
2553
  done = true;
2497
2554
  await flush();
2555
+ await offer();
2498
2556
  if (runId !== null && !offline) {
2499
2557
  const out = await client.post(`/v1/runs/${runId}/events`, {
2500
2558
  event: "finish",
@@ -2508,6 +2566,7 @@ async function startRun(passed, expected) {
2508
2566
  if (done) return;
2509
2567
  done = true;
2510
2568
  await flush();
2569
+ await offer();
2511
2570
  if (runId !== null) {
2512
2571
  log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
2513
2572
  }
@@ -2515,7 +2574,7 @@ async function startRun(passed, expected) {
2515
2574
  }
2516
2575
  };
2517
2576
  }
2518
- var RESOLVE_CHUNK, BATCH_MAX, BATCH_DEFAULT, EXPECTED_MAX;
2577
+ var RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX;
2519
2578
  var init_session = __esm({
2520
2579
  "src/reporter-core/session.ts"() {
2521
2580
  "use strict";
@@ -2527,6 +2586,7 @@ var init_session = __esm({
2527
2586
  init_fallback();
2528
2587
  RESOLVE_CHUNK = 500;
2529
2588
  BATCH_MAX = 500;
2589
+ DISCOVER_MAX = 500;
2530
2590
  BATCH_DEFAULT = 100;
2531
2591
  EXPECTED_MAX = 1e4;
2532
2592
  }
@@ -2689,6 +2749,502 @@ var init_run_lifecycle = __esm({
2689
2749
  }
2690
2750
  });
2691
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
+
2692
3248
  // src/cli/commands/report.ts
2693
3249
  var report_exports = {};
2694
3250
  __export(report_exports, {
@@ -2705,7 +3261,7 @@ function handleReport(opts = {}) {
2705
3261
  const file = path7.join(cwd, ".plune", "last-run.json");
2706
3262
  let raw;
2707
3263
  try {
2708
- raw = fs8.readFileSync(file, "utf8");
3264
+ raw = fs9.readFileSync(file, "utf8");
2709
3265
  } catch {
2710
3266
  throw new ReportNotFoundError('No saved run found. Run "plune run" first.');
2711
3267
  }
@@ -2720,12 +3276,12 @@ function handleReport(opts = {}) {
2720
3276
  }
2721
3277
  return parsed;
2722
3278
  }
2723
- var fs8, path7, ReportNotFoundError;
3279
+ var fs9, path7, ReportNotFoundError;
2724
3280
  var init_report = __esm({
2725
3281
  "src/cli/commands/report.ts"() {
2726
3282
  "use strict";
2727
3283
  init_cjs_shims();
2728
- fs8 = __toESM(require("fs"), 1);
3284
+ fs9 = __toESM(require("fs"), 1);
2729
3285
  path7 = __toESM(require("path"), 1);
2730
3286
  ReportNotFoundError = class extends Error {
2731
3287
  code = "NO_SAVED_RUN";
@@ -2800,7 +3356,7 @@ function isRunResult2(v) {
2800
3356
  function loadRunResult(file, label) {
2801
3357
  let raw;
2802
3358
  try {
2803
- raw = fs9.readFileSync(file, "utf8");
3359
+ raw = fs10.readFileSync(file, "utf8");
2804
3360
  } catch {
2805
3361
  throw new DiffInputError(`Cannot read ${label} run file: ${file}`);
2806
3362
  }
@@ -2820,12 +3376,12 @@ function handleDiff(opts) {
2820
3376
  const current = loadRunResult(opts.currentPath, "current");
2821
3377
  return diffRuns(baseline, current);
2822
3378
  }
2823
- var fs9, DiffInputError;
3379
+ var fs10, DiffInputError;
2824
3380
  var init_diff2 = __esm({
2825
3381
  "src/cli/commands/diff.ts"() {
2826
3382
  "use strict";
2827
3383
  init_cjs_shims();
2828
- fs9 = __toESM(require("fs"), 1);
3384
+ fs10 = __toESM(require("fs"), 1);
2829
3385
  init_diff();
2830
3386
  DiffInputError = class extends Error {
2831
3387
  code = "DIFF_INPUT";
@@ -2951,7 +3507,7 @@ async function runInitWizard(cwd) {
2951
3507
  return;
2952
3508
  }
2953
3509
  const outputPath = path8.join(cwd, "plune.yaml");
2954
- if (fs10.existsSync(outputPath)) {
3510
+ if (fs11.existsSync(outputPath)) {
2955
3511
  const overwrite = await clack.confirm({
2956
3512
  message: "plune.yaml already exists. Overwrite?"
2957
3513
  });
@@ -2990,17 +3546,17 @@ async function runInitWizard(cwd) {
2990
3546
  `;
2991
3547
  const spin = clack.spinner();
2992
3548
  spin.start("Writing plune.yaml\u2026");
2993
- fs10.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
3549
+ fs11.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
2994
3550
  spin.stop("plune.yaml created!");
2995
3551
  clack.outro("plune.yaml is ready.");
2996
3552
  }
2997
- var clack, fs10, path8, import_yaml2;
3553
+ var clack, fs11, path8, import_yaml2;
2998
3554
  var init_wizard = __esm({
2999
3555
  "src/config/init/wizard.ts"() {
3000
3556
  "use strict";
3001
3557
  init_cjs_shims();
3002
3558
  clack = __toESM(require("@clack/prompts"), 1);
3003
- fs10 = __toESM(require("fs"), 1);
3559
+ fs11 = __toESM(require("fs"), 1);
3004
3560
  path8 = __toESM(require("path"), 1);
3005
3561
  import_yaml2 = require("yaml");
3006
3562
  init_errors();
@@ -3010,19 +3566,19 @@ var init_wizard = __esm({
3010
3566
  // src/cli/templates/index.ts
3011
3567
  function writeTemplateFile(cwd, rel, content, force) {
3012
3568
  const target = path9.join(cwd, rel);
3013
- if (!force && fs11.existsSync(target)) {
3569
+ if (!force && fs12.existsSync(target)) {
3014
3570
  return "skipped";
3015
3571
  }
3016
- fs11.mkdirSync(path9.dirname(target), { recursive: true });
3017
- fs11.writeFileSync(target, content, "utf8");
3572
+ fs12.mkdirSync(path9.dirname(target), { recursive: true });
3573
+ fs12.writeFileSync(target, content, "utf8");
3018
3574
  return "written";
3019
3575
  }
3020
- var fs11, path9, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
3576
+ var fs12, path9, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
3021
3577
  var init_templates = __esm({
3022
3578
  "src/cli/templates/index.ts"() {
3023
3579
  "use strict";
3024
3580
  init_cjs_shims();
3025
- fs11 = __toESM(require("fs"), 1);
3581
+ fs12 = __toESM(require("fs"), 1);
3026
3582
  path9 = __toESM(require("path"), 1);
3027
3583
  ENV_EXAMPLE_TPL = `# Copy this file to .env and replace the placeholders with your real keys.
3028
3584
  # .env is gitignored; .env.example is safe to commit.
@@ -3282,7 +3838,7 @@ function readRun(cwd, file) {
3282
3838
  const target = file !== void 0 ? path10.resolve(cwd, file) : path10.join(cwd, ".plune", "last-run.json");
3283
3839
  let raw;
3284
3840
  try {
3285
- raw = fs12.readFileSync(target, "utf8");
3841
+ raw = fs13.readFileSync(target, "utf8");
3286
3842
  } catch {
3287
3843
  throw new SyncFileError(
3288
3844
  file !== void 0 ? `Run file not found: ${target}` : 'No run to sync. Run "plune run" first (or pass --file <path>).'
@@ -3346,12 +3902,12 @@ function reportSyncFailure(err, write) {
3346
3902
  }
3347
3903
  return null;
3348
3904
  }
3349
- var fs12, path10, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
3905
+ var fs13, path10, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
3350
3906
  var init_sync = __esm({
3351
3907
  "src/cli/commands/sync.ts"() {
3352
3908
  "use strict";
3353
3909
  init_cjs_shims();
3354
- fs12 = __toESM(require("fs"), 1);
3910
+ fs13 = __toESM(require("fs"), 1);
3355
3911
  path10 = __toESM(require("path"), 1);
3356
3912
  init_run_result_schema();
3357
3913
  init_api_url();
@@ -3397,14 +3953,14 @@ var init_sync = __esm({
3397
3953
 
3398
3954
  // src/cli/commands/cairn-artifact.ts
3399
3955
  async function findLatestCairnRun(runsDir = "runs") {
3400
- const entries = await fs13.readdir(runsDir, { withFileTypes: true }).catch(() => null);
3956
+ const entries = await fs14.readdir(runsDir, { withFileTypes: true }).catch(() => null);
3401
3957
  if (entries === null) throw new NoCairnRunsError(runsDir);
3402
3958
  let newest;
3403
3959
  for (const entry of entries) {
3404
3960
  if (!entry.isDirectory()) continue;
3405
3961
  const dir = path11.join(runsDir, entry.name);
3406
3962
  try {
3407
- const report = await fs13.lstat(path11.join(dir, "report.json"));
3963
+ const report = await fs14.lstat(path11.join(dir, "report.json"));
3408
3964
  if (!report.isFile()) continue;
3409
3965
  if (newest === void 0 || report.mtimeMs > newest.at) {
3410
3966
  newest = { dir, at: report.mtimeMs };
@@ -3417,7 +3973,7 @@ async function findLatestCairnRun(runsDir = "runs") {
3417
3973
  }
3418
3974
  async function readJson(file) {
3419
3975
  try {
3420
- return JSON.parse(await fs13.readFile(file, "utf8"));
3976
+ return JSON.parse(await fs14.readFile(file, "utf8"));
3421
3977
  } catch {
3422
3978
  return void 0;
3423
3979
  }
@@ -3431,8 +3987,8 @@ async function defaultReadEvidence(dir) {
3431
3987
  });
3432
3988
  }
3433
3989
  function casesFromDesign(report) {
3434
- const list = Array.isArray(report["testCases"]) ? report["testCases"] : [];
3435
- return list.map((raw) => {
3990
+ const list2 = Array.isArray(report["testCases"]) ? report["testCases"] : [];
3991
+ return list2.map((raw) => {
3436
3992
  const c = asRecord(raw);
3437
3993
  const steps = (Array.isArray(c["steps"]) ? c["steps"] : []).map((s) => str(s)).filter((s) => s !== "");
3438
3994
  return {
@@ -3447,8 +4003,8 @@ function casesFromDesign(report) {
3447
4003
  });
3448
4004
  }
3449
4005
  function casesFromApi(report, verdicts) {
3450
- const list = Array.isArray(report["cases"]) ? report["cases"] : [];
3451
- return list.map((raw) => {
4006
+ const list2 = Array.isArray(report["cases"]) ? report["cases"] : [];
4007
+ return list2.map((raw) => {
3452
4008
  const c = asRecord(raw);
3453
4009
  const name = str(c["name"], "unnamed operation");
3454
4010
  const request = `${str(c["method"], "GET")} ${str(c["path"], "/")}`;
@@ -3502,12 +4058,12 @@ async function readCairnRun(dir, deps = {}) {
3502
4058
  cases
3503
4059
  };
3504
4060
  }
3505
- var fs13, path11, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
4061
+ var fs14, path11, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
3506
4062
  var init_cairn_artifact = __esm({
3507
4063
  "src/cli/commands/cairn-artifact.ts"() {
3508
4064
  "use strict";
3509
4065
  init_cjs_shims();
3510
- fs13 = __toESM(require("fs/promises"), 1);
4066
+ fs14 = __toESM(require("fs/promises"), 1);
3511
4067
  path11 = __toESM(require("path"), 1);
3512
4068
  SUPPORTED_ARTIFACT_VERSION = 1;
3513
4069
  INGEST_SCHEMA_VERSION = 1;
@@ -3854,6 +4410,36 @@ function createProgram() {
3854
4410
  await failRunCommand(err, verboseOf(command));
3855
4411
  }
3856
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
+ );
3857
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) => {
3858
4444
  const globals = command.optsWithGlobals();
3859
4445
  const [{ loadEnv: loadEnv2 }, { handleReport: handleReport2, ReportNotFoundError: ReportNotFoundError2 }, { renderReport: renderReport2 }] = await Promise.all([