@plune-ai/cli 0.17.0 → 0.17.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/dist/cli.cjs CHANGED
@@ -512,7 +512,7 @@ var init_retry = __esm({
512
512
  "use strict";
513
513
  init_cjs_shims();
514
514
  init_errors2();
515
- defaultSleep = (ms) => new Promise((resolve8) => setTimeout(resolve8, ms));
515
+ defaultSleep = (ms) => new Promise((resolve9) => setTimeout(resolve9, ms));
516
516
  }
517
517
  });
518
518
 
@@ -874,7 +874,7 @@ var init_embedder = __esm({
874
874
  throw new Error(
875
875
  "The `semantic-similarity` and RAG assertions need local embeddings, which ship separately: install @huggingface/transformers to enable them. Every other assertion works without it."
876
876
  );
877
- }).then(({ pipeline }) => pipeline("feature-extraction", MODEL_ID));
877
+ }).then(({ pipeline: pipeline2 }) => pipeline2("feature-extraction", MODEL_ID));
878
878
  return this.extractor;
879
879
  }
880
880
  };
@@ -2226,7 +2226,7 @@ var init_credentials = __esm({
2226
2226
 
2227
2227
  // src/reporter-core/client.ts
2228
2228
  function nodeTransport(url, init, idleMs = IDLE_MS) {
2229
- return new Promise((resolve8, reject) => {
2229
+ return new Promise((resolve9, reject) => {
2230
2230
  const target = new URL(url);
2231
2231
  const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
2232
2232
  const req = send(target, { method: init.method, headers: init.headers }, (res) => {
@@ -2236,7 +2236,7 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
2236
2236
  res.on("end", () => {
2237
2237
  const status = res.statusCode ?? 0;
2238
2238
  const text2 = Buffer.concat(chunks).toString("utf8");
2239
- resolve8({
2239
+ resolve9({
2240
2240
  ok: status >= 200 && status <= 299,
2241
2241
  status,
2242
2242
  headers: {
@@ -2254,12 +2254,42 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
2254
2254
  req.end(init.body);
2255
2255
  });
2256
2256
  }
2257
+ function nodePut(url, headers, source, idleMs) {
2258
+ return new Promise((resolve9, reject) => {
2259
+ const target = new URL(url);
2260
+ const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
2261
+ const length = "body" in source ? source.body.length : source.size;
2262
+ let answered;
2263
+ const settle = (err) => answered !== void 0 ? resolve9(answered) : reject(err);
2264
+ const req = send(
2265
+ target,
2266
+ { method: "PUT", headers: { ...withoutLength(headers), "content-length": String(length) } },
2267
+ (res) => {
2268
+ const retry = res.headers["retry-after"];
2269
+ answered = { status: res.statusCode ?? 0, retryAfter: Array.isArray(retry) ? retry[0] ?? null : retry ?? null };
2270
+ res.on("error", settle);
2271
+ res.on("close", () => {
2272
+ resolve9(answered);
2273
+ req.destroy();
2274
+ });
2275
+ res.resume();
2276
+ }
2277
+ );
2278
+ req.setTimeout(idleMs, () => req.destroy(Object.assign(new Error("idle"), { code: "ETIMEDOUT" })));
2279
+ req.on("error", settle);
2280
+ if ("body" in source) req.end(source.body);
2281
+ else (0, import_node_stream.pipeline)((0, import_node_fs2.createReadStream)(source.path), req, (err) => err ? settle(err) : void 0);
2282
+ });
2283
+ }
2257
2284
  async function detailOf(res) {
2258
2285
  try {
2259
2286
  const body = await res.json();
2260
- return typeof body.error === "string" ? body.error : "";
2287
+ return {
2288
+ detail: typeof body.error === "string" ? body.error : "",
2289
+ ...typeof body.code === "string" && /^[a-z0-9_]{1,60}$/.test(body.code) ? { code: body.code } : {}
2290
+ };
2261
2291
  } catch {
2262
- return "";
2292
+ return { detail: "" };
2263
2293
  }
2264
2294
  }
2265
2295
  function retryAfterMs2(res) {
@@ -2280,7 +2310,7 @@ function createClient(opts) {
2280
2310
  const wait = opts.wait ?? sleep;
2281
2311
  const base = opts.apiUrl.replace(/\/+$/, "");
2282
2312
  const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
2283
- async function send(method, path15, payload) {
2313
+ async function send(method, path17, payload) {
2284
2314
  let last = {
2285
2315
  kind: "unavailable",
2286
2316
  status: null,
@@ -2289,7 +2319,7 @@ function createClient(opts) {
2289
2319
  for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
2290
2320
  let res;
2291
2321
  try {
2292
- res = await doFetch(`${base}${path15}`, {
2322
+ res = await doFetch(`${base}${path17}`, {
2293
2323
  method,
2294
2324
  headers: { "content-type": payload?.type ?? "application/json", authorization: `Bearer ${opts.token}` },
2295
2325
  // A DELETE carries none. Sending `"undefined"` as a body is what a naive
@@ -2310,10 +2340,16 @@ function createClient(opts) {
2310
2340
  return { ok: false, kind: "other", status: res.status, detail: "the answer was not JSON" };
2311
2341
  }
2312
2342
  }
2313
- const detail = scrub(await detailOf(res));
2343
+ const said = await detailOf(res);
2314
2344
  const kind = classify3(res.status);
2315
- last = { kind, status: res.status, detail };
2345
+ last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
2316
2346
  if (kind !== "unavailable") break;
2347
+ if (res.status === 429 && retryAfterMs2(res) === null) {
2348
+ last.kind = "invalid";
2349
+ last.detail = "quota exceeded (no Retry-After)";
2350
+ if (said.detail) last.detail += ` \u2014 ${said.detail}`;
2351
+ break;
2352
+ }
2317
2353
  if (attempt < RETRY_DELAYS_MS.length) {
2318
2354
  await wait(retryAfterMs2(res) ?? RETRY_DELAYS_MS[attempt]);
2319
2355
  }
@@ -2321,20 +2357,57 @@ function createClient(opts) {
2321
2357
  return { ok: false, ...last };
2322
2358
  }
2323
2359
  return {
2324
- post: (path15, body) => send("POST", path15, { body: JSON.stringify(body), type: "application/json" }),
2325
- upload: (path15, bytes, contentType) => send("POST", path15, { body: bytes, type: contentType }),
2326
- del: (path15) => send("DELETE", path15)
2360
+ post: (path17, body) => send("POST", path17, { body: JSON.stringify(body), type: "application/json" }),
2361
+ upload: (path17, bytes, contentType) => send("POST", path17, { body: bytes, type: contentType }),
2362
+ del: (path17) => send("DELETE", path17),
2363
+ async put(url, headers, source) {
2364
+ let last = { ok: false, status: null, code: "network" };
2365
+ for (let attempt = 0; attempt <= PUT_DELAYS_MS.length; attempt += 1) {
2366
+ try {
2367
+ let status;
2368
+ let retryAfter;
2369
+ if (opts.fetchImpl === void 0) ({ status, retryAfter } = await nodePut(url, headers, source, IDLE_MS));
2370
+ else {
2371
+ const body = "body" in source ? source.body : await (0, import_node_fs2.openAsBlob)(source.path);
2372
+ const res = await opts.fetchImpl(url, { method: "PUT", headers: withoutLength(headers), body });
2373
+ await res.arrayBuffer().catch(() => void 0);
2374
+ status = res.status;
2375
+ retryAfter = res.headers.get("retry-after");
2376
+ }
2377
+ if (status >= 200 && status <= 299) return { ok: true };
2378
+ last = { ok: false, status, code: `bucket_${status}` };
2379
+ if (status < 500 && status !== 429) return last;
2380
+ const seconds = retryAfter === null ? NaN : Number(retryAfter.trim());
2381
+ if (attempt < PUT_DELAYS_MS.length) {
2382
+ await wait(Number.isFinite(seconds) && seconds >= 0 ? Math.min(seconds, 60) * 1e3 : PUT_DELAYS_MS[attempt]);
2383
+ }
2384
+ continue;
2385
+ } catch (err) {
2386
+ last = { ok: false, status: null, code: codeOfError(err) };
2387
+ }
2388
+ if (attempt < PUT_DELAYS_MS.length) await wait(PUT_DELAYS_MS[attempt]);
2389
+ }
2390
+ return last;
2391
+ }
2327
2392
  };
2328
2393
  }
2329
- var import_node_http, import_node_https, RETRY_DELAYS_MS, IDLE_MS, sleep;
2394
+ var import_node_fs2, import_node_stream, import_node_http, import_node_https, RETRY_DELAYS_MS, IDLE_MS, PUT_DELAYS_MS, withoutLength, codeOfError, sleep;
2330
2395
  var init_client = __esm({
2331
2396
  "src/reporter-core/client.ts"() {
2332
2397
  "use strict";
2333
2398
  init_cjs_shims();
2399
+ import_node_fs2 = require("fs");
2400
+ import_node_stream = require("stream");
2334
2401
  import_node_http = require("http");
2335
2402
  import_node_https = require("https");
2336
2403
  RETRY_DELAYS_MS = [300, 900];
2337
2404
  IDLE_MS = 3e5;
2405
+ PUT_DELAYS_MS = [500, 1500, 4e3];
2406
+ withoutLength = (headers) => Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-length"));
2407
+ codeOfError = (err) => {
2408
+ const code = err?.code;
2409
+ return typeof code === "string" && /^[A-Z0-9_]{2,40}$/.test(code) ? code : "network";
2410
+ };
2338
2411
  sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2339
2412
  }
2340
2413
  });
@@ -2476,7 +2549,7 @@ function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
2476
2549
  }
2477
2550
  function repoRootOf(dir, stopAt) {
2478
2551
  for (let here = (0, import_node_path2.resolve)(dir); ; here = (0, import_node_path2.dirname)(here)) {
2479
- if ((0, import_node_fs2.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
2552
+ if ((0, import_node_fs3.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
2480
2553
  if (here === stopAt || (0, import_node_path2.dirname)(here) === here) return void 0;
2481
2554
  }
2482
2555
  }
@@ -2496,12 +2569,12 @@ function machineless(raw, repoRoot, home) {
2496
2569
  if (repoRoot === void 0) text2 = text2.replace(ANY_HOME, "~");
2497
2570
  return text2;
2498
2571
  }
2499
- function pathIn(path15) {
2500
- const parts = path15.split(/[\\/]+/);
2572
+ function pathIn(path17) {
2573
+ const parts = path17.split(/[\\/]+/);
2501
2574
  while (parts.length > 1 && parts.at(-1) === "") parts.pop();
2502
2575
  if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
2503
2576
  const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
2504
- const escaped = /^\/(?!\/)/.test(path15) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
2577
+ const escaped = /^\/(?!\/)/.test(path17) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
2505
2578
  const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
2506
2579
  return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
2507
2580
  }
@@ -2546,35 +2619,36 @@ function placeOf(error, testFile) {
2546
2619
  }
2547
2620
  function fileOf(raw) {
2548
2621
  if (!raw.startsWith("file://")) return raw;
2549
- let path15 = raw.slice("file://".length);
2622
+ let path17 = raw.slice("file://".length);
2550
2623
  try {
2551
- path15 = decodeURIComponent(path15);
2624
+ path17 = decodeURIComponent(path17);
2552
2625
  } catch {
2553
2626
  }
2554
- return /^\/[A-Za-z]:\//.test(path15) ? path15.slice(1) : path15;
2627
+ return /^\/[A-Za-z]:\//.test(path17) ? path17.slice(1) : path17;
2555
2628
  }
2556
2629
  function fromRoot(place, root) {
2557
2630
  if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
2558
2631
  const base = normal(root).replace(/\/+$/, "");
2559
2632
  const file = normal(place.file);
2560
2633
  if (!file.startsWith(`${base}/`)) return void 0;
2561
- const relative2 = file.slice(base.length + 1);
2562
- if (!REPO_RELATIVE.test(relative2)) return void 0;
2634
+ const relative3 = file.slice(base.length + 1);
2635
+ if (!REPO_RELATIVE.test(relative3)) return void 0;
2563
2636
  const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
2564
- return column === void 0 ? { file: relative2, line: place.line } : { file: relative2, line: place.line, column };
2637
+ return column === void 0 ? { file: relative3, line: place.line } : { file: relative3, line: place.line, column };
2565
2638
  }
2566
2639
  function webLink(href) {
2567
2640
  return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
2568
2641
  }
2569
- var import_node_fs2, import_node_os2, import_node_path2, import_node_util, mediaTypeOf, TEXT_TYPES, isTextType, PATH_START, TEXT_LIMIT, HEADLINE_LIMIT, jsonBytes, ANY_HOME, fileNameOf, omitted, METADATA_TYPE, REPO_RELATIVE, FRAME, FRAME_MAX, firstLine, normal;
2642
+ var import_node_fs3, import_node_os2, import_node_path2, import_node_util, printable, mediaTypeOf, TEXT_TYPES, isTextType, PATH_START, TEXT_LIMIT, HEADLINE_LIMIT, jsonBytes, ANY_HOME, fileNameOf, omitted, METADATA_TYPE, REPO_RELATIVE, FRAME, FRAME_MAX, firstLine, normal;
2570
2643
  var init_failure_detail = __esm({
2571
2644
  "src/reporter-core/failure-detail.ts"() {
2572
2645
  "use strict";
2573
2646
  init_cjs_shims();
2574
- import_node_fs2 = require("fs");
2647
+ import_node_fs3 = require("fs");
2575
2648
  import_node_os2 = require("os");
2576
2649
  import_node_path2 = require("path");
2577
2650
  import_node_util = require("util");
2651
+ printable = (text2) => text2.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
2578
2652
  mediaTypeOf = (contentType) => contentType?.split(";")[0]?.trim().toLowerCase() || void 0;
2579
2653
  TEXT_TYPES = /* @__PURE__ */ new Set(["application/json", "text/plain"]);
2580
2654
  isTextType = (contentType) => TEXT_TYPES.has(mediaTypeOf(contentType) ?? "");
@@ -2590,7 +2664,7 @@ var init_failure_detail = __esm({
2590
2664
  FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
2591
2665
  FRAME_MAX = 8192;
2592
2666
  firstLine = (text2) => (0, import_node_util.stripVTControlCharacters)(text2).split("\n").map((line) => line.trim()).find((line) => line !== "");
2593
- normal = (path15) => import_node_path2.posix.normalize(path15.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
2667
+ normal = (path17) => import_node_path2.posix.normalize(path17.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
2594
2668
  }
2595
2669
  });
2596
2670
 
@@ -2610,7 +2684,13 @@ function nameOf(file) {
2610
2684
  }
2611
2685
  function kindOf(type) {
2612
2686
  if (type.startsWith("image/")) return "screenshot";
2613
- return TEXT_TYPES.has(type) ? "text" : void 0;
2687
+ return TEXT_TYPES.has(type) ? "text" : "file";
2688
+ }
2689
+ function linkOf(item) {
2690
+ const link = item;
2691
+ if (typeof link?.id !== "string" || typeof link.url !== "string") return void 0;
2692
+ const headers = typeof link.headers === "object" && link.headers !== null ? link.headers : {};
2693
+ return { id: link.id, url: link.url, headers };
2614
2694
  }
2615
2695
  function textTypeOf(contentType, type) {
2616
2696
  const charset = /;\s*charset\s*=\s*"?([^\s";]+)"?/i.exec(contentType)?.[1];
@@ -2683,7 +2763,7 @@ function warnIfDropped(sent, run, log) {
2683
2763
  `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
2684
2764
  );
2685
2765
  }
2686
- async function startRun(passed, declared) {
2766
+ async function startRun(passed, declared2) {
2687
2767
  const env = readEnv();
2688
2768
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
2689
2769
  const cfg = { ...env.config, ...stated };
@@ -2711,7 +2791,8 @@ async function startRun(passed, declared) {
2711
2791
  unoffered: 0,
2712
2792
  deferred: 0,
2713
2793
  screenshots: { uploaded: 0, skipped: 0, failed: 0 },
2714
- textFiles: { uploaded: 0, skipped: 0, failed: 0 }
2794
+ textFiles: { uploaded: 0, skipped: 0, failed: 0 },
2795
+ otherFiles: { uploaded: 0, skipped: 0, failed: 0 }
2715
2796
  };
2716
2797
  const resolved = /* @__PURE__ */ new Map();
2717
2798
  let buffer = [];
@@ -2722,7 +2803,13 @@ async function startRun(passed, declared) {
2722
2803
  let done = false;
2723
2804
  const shortened = /* @__PURE__ */ new Set();
2724
2805
  const skipsSaid = /* @__PURE__ */ new Set();
2725
- const countsOf = (kind) => kind === "text" ? stats.textFiles : stats.screenshots;
2806
+ const countsOf = (kind) => kind === "text" ? stats.textFiles : kind === "file" ? stats.otherFiles : stats.screenshots;
2807
+ let bucketSeen = false;
2808
+ let noStorage = 0;
2809
+ let uploadsMissing = false;
2810
+ const failedSaid = /* @__PURE__ */ new Set();
2811
+ const codesSaid = /* @__PURE__ */ new Set();
2812
+ const rooms = /* @__PURE__ */ new Map();
2726
2813
  function bound(keys) {
2727
2814
  return keys.map((key) => {
2728
2815
  const fit = boundKey(key);
@@ -2730,14 +2817,14 @@ async function startRun(passed, declared) {
2730
2817
  shortened.add(key.value);
2731
2818
  if (shortened.size === 1) {
2732
2819
  log(
2733
- `plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 "${key.value.slice(0, 80)}\u2026". Give the test a shorter name; until then this is its identity.`
2820
+ `plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 ${JSON.stringify(printable(key.value.slice(0, 80)))}\u2026 Give the test a shorter name; until then this is its identity.`
2734
2821
  );
2735
2822
  }
2736
2823
  }
2737
2824
  return fit;
2738
2825
  });
2739
2826
  }
2740
- const expected = declared?.map(bound);
2827
+ const expected = declared2?.map(bound);
2741
2828
  if (offline) {
2742
2829
  log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
2743
2830
  }
@@ -2860,20 +2947,49 @@ async function startRun(passed, declared) {
2860
2947
  }
2861
2948
  function skip(kind, name, why) {
2862
2949
  countsOf(kind).skipped += 1;
2863
- if (skipsSaid.has(`${kind}/${why}`)) return;
2864
- skipsSaid.add(`${kind}/${why}`);
2865
- log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
2950
+ if (why === "noStorage") noStorage += 1;
2951
+ const said = why === "noStorage" ? why : `${kind}/${why}`;
2952
+ if (skipsSaid.has(said)) return;
2953
+ skipsSaid.add(said);
2954
+ if (why === "noStorage") log(`plune: "${name}" not stored \u2014 ${SKIPPED[why]}.`);
2955
+ else log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
2956
+ }
2957
+ function ask(job, size) {
2958
+ return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
2959
+ files: [{ name: job.name, contentType: job.type, size }]
2960
+ });
2961
+ }
2962
+ function failed(job, why) {
2963
+ countsOf(job.kind).failed += 1;
2964
+ if (!failedSaid.has(job.kind)) {
2965
+ failedSaid.add(job.kind);
2966
+ log(`plune: could not upload a ${KINDS[job.kind].label} (${why}) \u2014 its result is reported without it.`);
2967
+ }
2968
+ }
2969
+ function failedToBucket(job, code) {
2970
+ countsOf(job.kind).failed += 1;
2971
+ if (codesSaid.has(code)) return;
2972
+ codesSaid.add(code);
2973
+ log(`plune: "${job.name}" not uploaded to your bucket (${code}) \u2014 its result is reported without it.`);
2866
2974
  }
2867
- async function uploadOne(job) {
2975
+ function refusedHere(job, size) {
2976
+ if (job.kind === "screenshot") {
2977
+ if (!SCREENSHOT_TYPES.has(job.type)) return "type";
2978
+ if (size > SCREENSHOT_BYTES) return "size";
2979
+ }
2980
+ if (job.kind === "text" && size > TEXT_BYTES) return "textSize";
2981
+ return void 0;
2982
+ }
2983
+ async function uploadDirect(job, size) {
2868
2984
  const { kind } = job;
2869
- const { label, bytes: ceiling, size } = KINDS[kind];
2985
+ if (kind === "file") return skip(kind, job.name, "noStorage");
2986
+ const { bytes: ceiling, size: tooBig } = KINDS[kind];
2987
+ if (size > ceiling) return skip(kind, job.name, tooBig);
2870
2988
  let bytes;
2871
2989
  if (job.body !== void 0) bytes = job.body;
2872
- else if (job.path === void 0) return skip(kind, job.name, "missing");
2873
2990
  else {
2874
2991
  try {
2875
- if ((await import_node_fs3.promises.stat(job.path)).size > ceiling) return skip(kind, job.name, size);
2876
- bytes = await import_node_fs3.promises.readFile(job.path);
2992
+ bytes = await import_node_fs4.promises.readFile(job.path);
2877
2993
  } catch {
2878
2994
  return skip(kind, job.name, "missing");
2879
2995
  }
@@ -2885,32 +3001,96 @@ async function startRun(passed, declared) {
2885
3001
  bytes,
2886
3002
  job.type
2887
3003
  );
2888
- const counts = countsOf(kind);
2889
- if (out.ok) {
2890
- counts.uploaded += 1;
2891
- return;
3004
+ if (out.ok) countsOf(kind).uploaded += 1;
3005
+ else failed(job, out.detail || out.kind);
3006
+ }
3007
+ async function uploadToBucket(job, size, first) {
3008
+ const source = job.body !== void 0 ? { body: job.body } : { path: job.path, size };
3009
+ let link = first;
3010
+ for (let renewed = false; ; ) {
3011
+ const put = await client.put(link.url, link.headers, source);
3012
+ if (put.ok) break;
3013
+ if (put.status !== 403 || renewed) return failedToBucket(job, put.code);
3014
+ renewed = true;
3015
+ const again = await ask(job, size);
3016
+ if (!again.ok) return failedToBucket(job, codeOf2(again));
3017
+ const fresh = linkOf(again.body?.files?.[0]);
3018
+ if (fresh === void 0) return failedToBucket(job, "bad_answer");
3019
+ link = fresh;
2892
3020
  }
2893
- counts.failed += 1;
2894
- if (counts.failed === 1) {
2895
- log(`plune: could not upload a ${label} (${out.detail || out.kind}) \u2014 its result is reported without it.`);
3021
+ const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {});
3022
+ if (!done2.ok) return failedToBucket(job, codeOf2(done2));
3023
+ countsOf(job.kind).uploaded += 1;
3024
+ }
3025
+ let free = UPLOADS_AT_ONCE;
3026
+ const waiting = [];
3027
+ async function gated(work) {
3028
+ if (free === 0) await new Promise((resolve9) => waiting.push(resolve9));
3029
+ else free -= 1;
3030
+ try {
3031
+ return await work();
3032
+ } finally {
3033
+ const next = waiting.shift();
3034
+ if (next !== void 0) next();
3035
+ else free += 1;
2896
3036
  }
2897
3037
  }
3038
+ async function plan(job) {
3039
+ const { kind } = job;
3040
+ let size;
3041
+ if (job.body !== void 0) size = job.body.length;
3042
+ else if (job.path === void 0) return void skip(kind, job.name, "missing");
3043
+ else {
3044
+ try {
3045
+ size = (await import_node_fs4.promises.stat(job.path)).size;
3046
+ } catch {
3047
+ return void skip(kind, job.name, "missing");
3048
+ }
3049
+ }
3050
+ if (size === 0) return void skip(kind, job.name, "empty");
3051
+ const direct = () => {
3052
+ if (kind === "file") return void skip(kind, job.name, "noStorage");
3053
+ const roomKey = `${job.id}/${kind}`;
3054
+ const left = rooms.get(roomKey) ?? KINDS[kind].room;
3055
+ if (left === 0) return void skip(kind, job.name, KINDS[kind].limit);
3056
+ rooms.set(roomKey, left - 1);
3057
+ return () => uploadDirect(job, size);
3058
+ };
3059
+ if (uploadsMissing) return direct();
3060
+ const out = await gated(() => ask(job, size));
3061
+ if (!out.ok) {
3062
+ if (out.status === 404 && out.detail === "" && out.code === void 0) {
3063
+ uploadsMissing = true;
3064
+ return direct();
3065
+ }
3066
+ if (out.code !== void 0) failedToBucket(job, out.code);
3067
+ else failed(job, out.detail || out.kind);
3068
+ return void 0;
3069
+ }
3070
+ const item = out.body?.files?.[0];
3071
+ if (item?.skipped === "no_storage") return void skip(kind, job.name, refusedHere(job, size) ?? "noStorage");
3072
+ if (item?.direct === true) return direct();
3073
+ const link = linkOf(item);
3074
+ if (link === void 0) return void failed(job, "bad_answer");
3075
+ if (!bucketSeen) {
3076
+ bucketSeen = true;
3077
+ log(BUCKET_FOUND);
3078
+ }
3079
+ return () => uploadToBucket(job, size, link);
3080
+ }
2898
3081
  async function upload(items, filesByKey) {
2899
3082
  if (filesByKey.size === 0 || !Array.isArray(items)) return;
2900
3083
  const jobs = [];
2901
3084
  for (const item of items) {
2902
3085
  if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
2903
- const room = { screenshot: KINDS.screenshot.room, text: KINDS.text.room };
2904
3086
  for (const file of filesByKey.get(item.resultKey) ?? []) {
2905
3087
  const type = mediaTypeOf(file.contentType) ?? (file.path === void 0 ? void 0 : TYPE_BY_EXTENSION[(0, import_node_path3.extname)(file.path).toLowerCase()]);
2906
- const kind = type === void 0 ? void 0 : kindOf(type);
2907
- if (type === void 0 || kind === void 0 || kind === "screenshot" && file.path === void 0) continue;
3088
+ if (type === void 0 || type === METADATA_TYPE2) continue;
3089
+ const kind = kindOf(type);
3090
+ if (kind !== "text" && file.path === void 0) continue;
2908
3091
  const name = nameOf(file);
2909
3092
  if (file.name.startsWith("_")) skip(kind, name, "hidden");
2910
- else if (kind === "screenshot" && !SCREENSHOT_TYPES.has(type)) skip(kind, name, "type");
2911
- else if (room[kind] === 0) skip(kind, name, KINDS[kind].limit);
2912
3093
  else {
2913
- room[kind] -= 1;
2914
3094
  jobs.push({
2915
3095
  id: item.id,
2916
3096
  kind,
@@ -2922,10 +3102,16 @@ async function startRun(passed, declared) {
2922
3102
  }
2923
3103
  }
2924
3104
  }
2925
- let next = 0;
3105
+ const byResult = /* @__PURE__ */ new Map();
3106
+ for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
2926
3107
  await Promise.all(
2927
- Array.from({ length: Math.min(UPLOADS_AT_ONCE, jobs.length) }, async () => {
2928
- while (next < jobs.length) await uploadOne(jobs[next++]);
3108
+ [...byResult.values()].map(async (group) => {
3109
+ const work = [];
3110
+ for (const job of group) {
3111
+ const act = await plan(job);
3112
+ if (act !== void 0) work.push(gated(act));
3113
+ }
3114
+ await Promise.all(work);
2929
3115
  })
2930
3116
  );
2931
3117
  }
@@ -3003,8 +3189,20 @@ async function startRun(passed, declared) {
3003
3189
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
3004
3190
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
3005
3191
  if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
3006
- for (const said of [tally(stats.screenshots, "screenshot"), tally(stats.textFiles, "text file")]) {
3007
- if (said !== void 0) parts.push(said);
3192
+ const kinds = [stats.screenshots, stats.textFiles, stats.otherFiles];
3193
+ const sum = (what) => kinds.reduce((n, counts) => n + counts[what], 0);
3194
+ if (bucketSeen) {
3195
+ const other = sum("skipped") - noStorage;
3196
+ parts.push(
3197
+ `${sum("uploaded")} file(s) uploaded to your bucket` + (noStorage > 0 ? `, ${noStorage} skipped (no bucket connected)` : "") + (other > 0 ? `, ${other} skipped` : "") + (sum("failed") > 0 ? `, ${sum("failed")} failed to upload` : "")
3198
+ );
3199
+ } else {
3200
+ for (const said of [tally(stats.screenshots, "screenshot"), tally(stats.textFiles, "text file")]) {
3201
+ if (said !== void 0) parts.push(said);
3202
+ }
3203
+ const other = stats.otherFiles;
3204
+ if (other.skipped > 0) parts.push(`${other.skipped} file(s) not stored (no bucket connected)`);
3205
+ if (other.failed > 0) parts.push(`${other.failed} file(s) failed to upload`);
3008
3206
  }
3009
3207
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
3010
3208
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
@@ -3057,13 +3255,13 @@ async function startRun(passed, declared) {
3057
3255
  }
3058
3256
  };
3059
3257
  }
3060
- var import_node_crypto2, import_node_fs3, import_node_path3, import_node_util2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TEXT_BYTES, TEXTS_PER_RESULT, UTF8, TYPE_BY_EXTENSION, UPLOADS_AT_ONCE, NAME_MAX, SKIPPED, KINDS, BATCH_BYTES, ENVELOPE_BYTES;
3258
+ var import_node_crypto2, import_node_fs4, import_node_path3, import_node_util2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TEXT_BYTES, TEXTS_PER_RESULT, UTF8, TYPE_BY_EXTENSION, METADATA_TYPE2, UPLOADS_AT_ONCE, NAME_MAX, SKIPPED, BUCKET_FOUND, KINDS, codeOf2, BATCH_BYTES, ENVELOPE_BYTES;
3061
3259
  var init_session = __esm({
3062
3260
  "src/reporter-core/session.ts"() {
3063
3261
  "use strict";
3064
3262
  init_cjs_shims();
3065
3263
  import_node_crypto2 = require("crypto");
3066
- import_node_fs3 = require("fs");
3264
+ import_node_fs4 = require("fs");
3067
3265
  import_node_path3 = require("path");
3068
3266
  import_node_util2 = require("util");
3069
3267
  init_api_url();
@@ -3092,6 +3290,7 @@ var init_session = __esm({
3092
3290
  ".jpeg": "image/jpeg",
3093
3291
  ".webp": "image/webp"
3094
3292
  };
3293
+ METADATA_TYPE2 = "application/plune.metadata+json";
3095
3294
  UPLOADS_AT_ONCE = 4;
3096
3295
  NAME_MAX = 200;
3097
3296
  SKIPPED = {
@@ -3104,12 +3303,17 @@ var init_session = __esm({
3104
3303
  textSize: "Plune takes a text file of at most 512 KiB",
3105
3304
  empty: "the file is empty",
3106
3305
  binary: "the file holds a NUL byte, and Plune takes UTF-8 text without one",
3107
- encoding: "Plune takes UTF-8 text only"
3306
+ encoding: "Plune takes UTF-8 text only",
3307
+ noStorage: "no storage bucket is connected to this project"
3108
3308
  };
3309
+ BUCKET_FOUND = "plune: storage bucket found for this project \u2014 screenshots, text, trace and video go straight to it over short-lived links.";
3109
3310
  KINDS = {
3110
3311
  screenshot: { label: "screenshot", bytes: SCREENSHOT_BYTES, room: SCREENSHOTS_PER_RESULT, limit: "limit", size: "size" },
3111
- text: { label: "text file", bytes: TEXT_BYTES, room: TEXTS_PER_RESULT, limit: "textLimit", size: "textSize" }
3312
+ text: { label: "text file", bytes: TEXT_BYTES, room: TEXTS_PER_RESULT, limit: "textLimit", size: "textSize" },
3313
+ // Only the label is used: a file of this kind has no home in the database, so no ceiling of its own is known here.
3314
+ file: { label: "file", bytes: 0, room: 0, limit: "limit", size: "size" }
3112
3315
  };
3316
+ codeOf2 = (out) => out.code ?? (out.status === null ? "network" : `http_${out.status}`);
3113
3317
  BATCH_BYTES = 8 * 1024 * 1024;
3114
3318
  ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ results: [] }));
3115
3319
  }
@@ -3200,21 +3404,21 @@ async function handleRunDelete(id, options = {}) {
3200
3404
  );
3201
3405
  }
3202
3406
  write(`Run ${id} deleted, with its results and the review-queue entries it produced.`);
3203
- write("Recoverable for six months \u2014 ask Plune to put it back.");
3407
+ write("Recoverable for 30 days \u2014 ask Plune to put it back.");
3204
3408
  }
3205
3409
  async function handleRunExec(options) {
3206
3410
  const [command, ...args] = options.argv;
3207
3411
  if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
3208
3412
  const run = await handleRunStart(options);
3209
3413
  const spawnImpl = options.spawnImpl ?? import_node_child_process.spawn;
3210
- const code = await new Promise((resolve8) => {
3414
+ const code = await new Promise((resolve9) => {
3211
3415
  const child = spawnImpl(command, args, {
3212
3416
  stdio: "inherit",
3213
3417
  shell: process.platform === "win32",
3214
3418
  env: { ...process.env, PLUNE_RUN: run.externalKey, PLUNE_PROCEED: "1" }
3215
3419
  });
3216
- child.on("close", (exitCode) => resolve8(exitCode ?? 1));
3217
- child.on("error", () => resolve8(1));
3420
+ child.on("close", (exitCode) => resolve9(exitCode ?? 1));
3421
+ child.on("error", () => resolve9(1));
3218
3422
  });
3219
3423
  await handleRunFinish(run.id, options);
3220
3424
  return code;
@@ -3345,6 +3549,346 @@ var init_result_key = __esm({
3345
3549
  }
3346
3550
  });
3347
3551
 
3552
+ // src/importers/attachments.ts
3553
+ function reportFolder(report) {
3554
+ const folder = path9.dirname(path9.resolve(report));
3555
+ try {
3556
+ return fs8.realpathSync(folder);
3557
+ } catch {
3558
+ return folder;
3559
+ }
3560
+ }
3561
+ function reportFolders(report) {
3562
+ return { lexical: path9.dirname(path9.resolve(report)), real: reportFolder(report) };
3563
+ }
3564
+ function within(folder, target) {
3565
+ const rel = path9.relative(folder, target);
3566
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${path9.sep}`) && !path9.isAbsolute(rel);
3567
+ }
3568
+ function resolveAttachment(folder, ref, lexical = folder) {
3569
+ if (/^[\\/]{2}/.test(ref) || ref.includes("\0")) return { why: "outside" };
3570
+ const face = path9.resolve(lexical, ref);
3571
+ if (!within(lexical, face) && !within(folder, face)) return { why: "outside" };
3572
+ let real;
3573
+ try {
3574
+ real = fs8.realpathSync(path9.resolve(folder, ref));
3575
+ } catch {
3576
+ return { why: "absent" };
3577
+ }
3578
+ if (!within(folder, real)) return { why: "outside" };
3579
+ try {
3580
+ return fs8.statSync(real).isFile() ? { file: real } : { why: "not-a-file" };
3581
+ } catch {
3582
+ return { why: "absent" };
3583
+ }
3584
+ }
3585
+ function markersOf(test) {
3586
+ const refs = /* @__PURE__ */ new Set();
3587
+ for (const stream of test.children) {
3588
+ if (stream.name !== "system-out" && stream.name !== "system-err") continue;
3589
+ for (const match of stream.text.matchAll(MARKER)) {
3590
+ const ref = match[1]?.trim() ?? "";
3591
+ if (ref !== "") refs.add(ref);
3592
+ }
3593
+ }
3594
+ return [...refs];
3595
+ }
3596
+ function filesOfTest(test, folder, title, missing, options = {}) {
3597
+ const files = [];
3598
+ const all = markersOf(test);
3599
+ if (all.length > MARKERS_PER_TEST) {
3600
+ options.notes?.push(
3601
+ `plune: ${JSON.stringify(printable(title))}: ${all.length - MARKERS_PER_TEST} more markers ignored (at most ${MARKERS_PER_TEST} per test)`
3602
+ );
3603
+ }
3604
+ for (const ref of all.slice(0, MARKERS_PER_TEST)) {
3605
+ const found = resolveAttachment(folder, ref, options.lexical);
3606
+ if (!("file" in found)) {
3607
+ missing.push({ title, path: ref, ...options.resultKey !== void 0 ? { resultKey: options.resultKey } : {} });
3608
+ continue;
3609
+ }
3610
+ const real = found.file;
3611
+ if (!files.some((f) => f.path === real)) {
3612
+ files.push({ name: printable(ref.split(/[\\/]/).pop() || path9.basename(real)), contentType: typeOfFile(real), path: real });
3613
+ }
3614
+ }
3615
+ return files;
3616
+ }
3617
+ var fs8, path9, MARKER, TYPES, typeOfFile, MARKERS_PER_TEST;
3618
+ var init_attachments = __esm({
3619
+ "src/importers/attachments.ts"() {
3620
+ "use strict";
3621
+ init_cjs_shims();
3622
+ fs8 = __toESM(require("fs"), 1);
3623
+ path9 = __toESM(require("path"), 1);
3624
+ init_failure_detail();
3625
+ MARKER = /^[ \t]*\[\[ATTACHMENT\|([^\r\n]*?)\]\][ \t]*$/gm;
3626
+ TYPES = {
3627
+ ".png": "image/png",
3628
+ ".jpg": "image/jpeg",
3629
+ ".jpeg": "image/jpeg",
3630
+ ".webp": "image/webp",
3631
+ ".gif": "image/gif",
3632
+ ".svg": "image/svg+xml",
3633
+ ".json": "application/json",
3634
+ ".txt": "text/plain",
3635
+ ".log": "text/plain",
3636
+ ".md": "text/plain",
3637
+ ".csv": "text/csv",
3638
+ ".html": "text/html",
3639
+ ".htm": "text/html",
3640
+ ".xml": "application/xml",
3641
+ ".pdf": "application/pdf",
3642
+ ".zip": "application/zip",
3643
+ ".webm": "video/webm",
3644
+ ".mp4": "video/mp4",
3645
+ ".har": "application/json"
3646
+ };
3647
+ typeOfFile = (file) => TYPES[path9.extname(file).toLowerCase()] ?? "application/octet-stream";
3648
+ MARKERS_PER_TEST = 100;
3649
+ }
3650
+ });
3651
+
3652
+ // src/importers/allure.ts
3653
+ function isFolder(path17) {
3654
+ try {
3655
+ return fs9.statSync(path17).isDirectory();
3656
+ } catch {
3657
+ return false;
3658
+ }
3659
+ }
3660
+ function load(file, maxBytes) {
3661
+ let size;
3662
+ try {
3663
+ size = fs9.statSync(file).size;
3664
+ } catch (err) {
3665
+ throw new AllureReportError(`${printable(file)} \u2014 cannot read \u2014 ${printable(err instanceof Error ? err.message : String(err))}`);
3666
+ }
3667
+ if (size > maxBytes) {
3668
+ throw new AllureReportError(`${printable(file)} \u2014 ${size} bytes, over the ${maxBytes} an Allure result may have; not read.`);
3669
+ }
3670
+ let parsed;
3671
+ try {
3672
+ parsed = JSON.parse(fs9.readFileSync(file, "utf8"));
3673
+ } catch (err) {
3674
+ throw new AllureReportError(`${printable(file)} \u2014 not JSON \u2014 ${printable(err instanceof Error ? err.message : String(err))}`);
3675
+ }
3676
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
3677
+ throw new AllureReportError(`${printable(file)} \u2014 not an Allure result: expected a JSON object.`);
3678
+ }
3679
+ return parsed;
3680
+ }
3681
+ function listOf(v) {
3682
+ const out = [];
3683
+ if (!Array.isArray(v)) return out;
3684
+ for (const x of v) if (typeof x === "object" && x !== null && !Array.isArray(x)) out.push(x);
3685
+ return out;
3686
+ }
3687
+ function keyOf(r, name) {
3688
+ const full = textOf(r.fullName);
3689
+ if (name.endsWith("]") && full.includes("#")) return `${full.split("#")[0]}#${name}`;
3690
+ let base;
3691
+ if (full.includes(" \u203A ")) base = full.split(" \u203A ").join("#");
3692
+ else if (full.includes("#")) base = full;
3693
+ else if (labelOf(r, "package") !== "") base = `${labelOf(r, "package")}#${name}`;
3694
+ else base = full !== "" ? full : name;
3695
+ const params = listOf(r.parameters).map((p) => textOf(p.value));
3696
+ return params.length > 0 && !base.endsWith("]") ? `${base}[${params.join("-")}]` : base;
3697
+ }
3698
+ function labelGroup(r) {
3699
+ const out = [];
3700
+ for (const l of ["parentSuite", "suite", "subSuite"]) {
3701
+ const v = labelOf(r, l);
3702
+ if (v !== "" && out.at(-1) !== v) out.push(v);
3703
+ }
3704
+ return out;
3705
+ }
3706
+ function attachmentsOf(holder, out) {
3707
+ let deep = false;
3708
+ const todo = [{ h: holder, depth: 0 }];
3709
+ while (todo.length > 0) {
3710
+ const { h, depth } = todo.pop();
3711
+ for (const a of listOf(h.attachments)) out.push(a);
3712
+ const steps = listOf(h.steps);
3713
+ if (depth >= MAX_DEPTH) {
3714
+ deep ||= steps.length > 0;
3715
+ continue;
3716
+ }
3717
+ for (let i = steps.length - 1; i >= 0; i--) todo.push({ h: steps[i], depth: depth + 1 });
3718
+ }
3719
+ return deep;
3720
+ }
3721
+ function readAllure(dir, missing = [], notes = [], maxBytes = MAX_JSON_BYTES) {
3722
+ if (!isFolder(dir)) throw new AllureReportError(`${printable(dir)} \u2014 not a folder.`);
3723
+ const resultNames = namesIn(dir, RESULT);
3724
+ if (resultNames.length === 0) {
3725
+ throw new AllureReportError(
3726
+ `${printable(dir)} \u2014 no *-result.json files in it, so there is no Allure report to read. Point at the folder an Allure adapter wrote (pytest --alluredir, allure-results), or at a JUnit or Playwright JSON file.`
3727
+ );
3728
+ }
3729
+ const { lexical, real: folder } = reportFolders(path10.join(dir, "_"));
3730
+ const tests = resultNames.map((n) => load(path10.join(dir, n), maxBytes));
3731
+ const containers = namesIn(dir, CONTAINER).map((n) => load(path10.join(dir, n), maxBytes));
3732
+ const direct = /* @__PURE__ */ new Map();
3733
+ for (const c of containers) {
3734
+ for (const child of Array.isArray(c.children) ? c.children : []) {
3735
+ if (typeof child !== "string") continue;
3736
+ const list2 = direct.get(child);
3737
+ if (list2 === void 0) direct.set(child, [c]);
3738
+ else list2.push(c);
3739
+ }
3740
+ }
3741
+ const around = (uuid) => {
3742
+ const seen = /* @__PURE__ */ new Set();
3743
+ const todo = [uuid];
3744
+ while (todo.length > 0) {
3745
+ for (const c of direct.get(todo.pop()) ?? []) {
3746
+ if (seen.has(c)) continue;
3747
+ seen.add(c);
3748
+ if (typeof c.uuid === "string") todo.push(c.uuid);
3749
+ }
3750
+ }
3751
+ return [...seen];
3752
+ };
3753
+ const ctx = { folder, lexical, missing, notes, around, direct };
3754
+ const groups = /* @__PURE__ */ new Map();
3755
+ tests.forEach((r, i) => {
3756
+ const id = textOf(r.historyId);
3757
+ const g = id === "" ? `\0${i}` : id;
3758
+ const list2 = groups.get(g);
3759
+ if (list2 === void 0) groups.set(g, [r]);
3760
+ else list2.push(r);
3761
+ });
3762
+ const out = [];
3763
+ for (const attempts of groups.values()) {
3764
+ const ordered = attempts.map((r, i) => ({ r, i })).sort(
3765
+ (a, b) => stopOf(a.r) - stopOf(b.r) || (timeOf(a.r.start) ?? 0) - (timeOf(b.r.start) ?? 0) || compareText(textOf(a.r.uuid), textOf(b.r.uuid)) || a.i - b.i
3766
+ ).map((x) => x.r);
3767
+ ordered.forEach((r, retry) => out.push(pendingOf(r, retry, ctx)));
3768
+ }
3769
+ return out;
3770
+ }
3771
+ function pendingOf(r, retry, ctx) {
3772
+ const name = textOf(r.name);
3773
+ const uuid = textOf(r.uuid);
3774
+ const key = keyOf(r, name);
3775
+ const ancestors = uuid === "" ? [] : ctx.around(uuid);
3776
+ const suite = (uuid === "" ? [] : ctx.direct.get(uuid) ?? []).filter((c) => textOf(c.name) !== "" && c.name !== name).sort((a, b) => (Array.isArray(b.children) ? b.children.length : 0) - (Array.isArray(a.children) ? a.children.length : 0))[0];
3777
+ const group = suite !== void 0 ? [textOf(suite.name)] : labelGroup(r);
3778
+ const title = [...group, name].join(" \u203A ");
3779
+ const safeTitle = printable(title);
3780
+ const rkey = resultKey(key, retry);
3781
+ const files = [];
3782
+ const announced = /* @__PURE__ */ new Set();
3783
+ const ignored = /* @__PURE__ */ new Set();
3784
+ const announce = (att) => {
3785
+ const source = textOf(att.source);
3786
+ if (source === "" || announced.has(source) || ignored.has(source)) return;
3787
+ if (announced.size >= MARKERS_PER_TEST) {
3788
+ ignored.add(source);
3789
+ return;
3790
+ }
3791
+ announced.add(source);
3792
+ const found = resolveAttachment(ctx.folder, source, ctx.lexical);
3793
+ if (!("file" in found)) {
3794
+ ctx.missing.push({ title: safeTitle, resultKey: rkey, path: source });
3795
+ return;
3796
+ }
3797
+ if (files.some((f) => f.path === found.file)) return;
3798
+ const type = textOf(att.type).trim();
3799
+ const name2 = printable(textOf(att.name) !== "" ? textOf(att.name) : path10.basename(found.file));
3800
+ files.push({ name: name2, contentType: type !== "" ? type : typeOfFile(found.file), path: found.file });
3801
+ };
3802
+ const holders = [];
3803
+ for (const c of [...ancestors].reverse()) for (const h of listOf(c.befores)) holders.push(h);
3804
+ holders.push(r);
3805
+ for (const c of ancestors) for (const h of listOf(c.afters)) holders.push(h);
3806
+ const atts = [];
3807
+ let deep = false;
3808
+ for (const h of holders) deep = attachmentsOf(h, atts) || deep;
3809
+ atts.forEach(announce);
3810
+ if (ignored.size > 0) {
3811
+ ctx.notes.push(`plune: ${JSON.stringify(safeTitle)}: ${ignored.size} more markers ignored (at most ${MARKERS_PER_TEST} per test)`);
3812
+ }
3813
+ if (deep) ctx.notes.push(`plune: ${JSON.stringify(safeTitle)}: steps nested deeper than ${MAX_DEPTH} are ignored`);
3814
+ const status = textOf(r.status) !== "" ? textOf(r.status) : "unknown";
3815
+ const details = typeof r.statusDetails === "object" && r.statusDetails !== null ? r.statusDetails : {};
3816
+ const message = textOf(details.message);
3817
+ const trace = textOf(details.trace);
3818
+ const attempt = {
3819
+ status,
3820
+ errors: [{ text: [message, trace].filter((s) => s !== "").join("\n\n") }],
3821
+ steps: declared(r.steps),
3822
+ attachments: files.map(({ name: n, contentType, path: p }) => ({
3823
+ name: n,
3824
+ ...contentType !== void 0 ? { contentType } : {},
3825
+ ...p !== void 0 ? { path: p } : {}
3826
+ })),
3827
+ testFile: ""
3828
+ };
3829
+ const errorContext = errorContextOf(attempt);
3830
+ const failure = FAILED.has(status) ? failureOf(attempt) : void 0;
3831
+ const pkg2 = labelOf(r, "package");
3832
+ const where = (key.includes("#") ? key.split("#")[0] : pkg2)?.replace(/^[A-Za-z][A-Za-z0-9_-]+:(?![\\/])/, "");
3833
+ const stated = TOKEN.exec(name)?.[1];
3834
+ const started = timeOf(r.start);
3835
+ const stopped = timeOf(r.stop);
3836
+ const durationMs = started !== void 0 && stopped !== void 0 ? Math.max(0, stopped - started) : 0;
3837
+ return {
3838
+ resultKey: rkey,
3839
+ ...stated !== void 0 ? { testCaseId: stated } : {},
3840
+ keys: [{ kind: "path-title", value: key }],
3841
+ source: SOURCE,
3842
+ rawStatus: status,
3843
+ title,
3844
+ ...where !== void 0 && where !== "" ? { specRef: where } : {},
3845
+ ...started !== void 0 ? {
3846
+ execution: {
3847
+ startedAt: new Date(started).toISOString(),
3848
+ finishedAt: new Date(started + durationMs).toISOString(),
3849
+ durationMs,
3850
+ retry
3851
+ }
3852
+ } : {},
3853
+ ...errorContext !== "" ? { errorContext } : {},
3854
+ ...failure !== void 0 ? { failure } : {},
3855
+ ...files.length > 0 ? { files } : {}
3856
+ };
3857
+ }
3858
+ var fs9, path10, SOURCE, TOKEN, AllureReportError, RESULT, CONTAINER, MAX_JSON_BYTES, MAX_DEPTH, MAX_TIME, namesIn, timeOf, textOf, labelOf, FAILED, declared, stopOf, compareText;
3859
+ var init_allure = __esm({
3860
+ "src/importers/allure.ts"() {
3861
+ "use strict";
3862
+ init_cjs_shims();
3863
+ fs9 = __toESM(require("fs"), 1);
3864
+ path10 = __toESM(require("path"), 1);
3865
+ init_failure_detail();
3866
+ init_result_key();
3867
+ init_attachments();
3868
+ SOURCE = "allure";
3869
+ TOKEN = /@P([A-Za-z0-9_-]+)/;
3870
+ AllureReportError = class extends Error {
3871
+ constructor(message) {
3872
+ super(message);
3873
+ this.name = "AllureReportError";
3874
+ }
3875
+ };
3876
+ RESULT = /-result\.json$/;
3877
+ CONTAINER = /-container\.json$/;
3878
+ MAX_JSON_BYTES = 64 * 1024 * 1024;
3879
+ MAX_DEPTH = 256;
3880
+ MAX_TIME = 864e13;
3881
+ namesIn = (dir, pattern) => fs9.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isFile() && pattern.test(d.name)).map((d) => d.name).sort();
3882
+ timeOf = (v) => typeof v === "number" && Math.abs(v) <= MAX_TIME ? v : void 0;
3883
+ textOf = (v) => typeof v === "string" ? v : "";
3884
+ labelOf = (r, name) => textOf(listOf(r.labels).find((l) => l.name === name)?.value);
3885
+ FAILED = /* @__PURE__ */ new Set(["failed", "broken"]);
3886
+ declared = (steps, depth = 0) => depth >= MAX_DEPTH ? [] : listOf(steps).map((s) => ({ title: textOf(s.name), failed: FAILED.has(textOf(s.status)), steps: declared(s.steps, depth + 1) }));
3887
+ stopOf = (r) => timeOf(r.stop) ?? timeOf(r.start) ?? 0;
3888
+ compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
3889
+ }
3890
+ });
3891
+
3348
3892
  // src/importers/xml.ts
3349
3893
  function decode(raw) {
3350
3894
  if (!raw.includes("&")) return raw;
@@ -3409,9 +3953,9 @@ function parseXml(source, file) {
3409
3953
  if (end === -1) throw new XmlParseError(file, line, "a closing tag is never closed");
3410
3954
  const name2 = source.slice(i + 2, end).trim();
3411
3955
  const open = top();
3412
- if (open === root) throw new XmlParseError(file, line, `</${name2}> closes nothing`);
3956
+ if (open === root) throw new XmlParseError(file, line, `</${printable(name2)}> closes nothing`);
3413
3957
  if (open.name !== name2) {
3414
- throw new XmlParseError(file, line, `</${name2}> closes <${open.name}>, opened on line ${open.line}`);
3958
+ throw new XmlParseError(file, line, `</${printable(name2)}> closes <${printable(open.name)}>, opened on line ${open.line}`);
3415
3959
  }
3416
3960
  stack.pop();
3417
3961
  advance(end + 1);
@@ -3427,7 +3971,7 @@ function parseXml(source, file) {
3427
3971
  let selfClosing = false;
3428
3972
  for (; ; ) {
3429
3973
  while (j < source.length && SPACE.test(source[j])) j += 1;
3430
- if (j >= source.length) throw new XmlParseError(file, startLine, `<${name}> is never closed`);
3974
+ if (j >= source.length) throw new XmlParseError(file, startLine, `<${printable(name)}> is never closed`);
3431
3975
  if (source[j] === "/" && source[j + 1] === ">") {
3432
3976
  selfClosing = true;
3433
3977
  j += 2;
@@ -3440,19 +3984,19 @@ function parseXml(source, file) {
3440
3984
  const attrStart = j;
3441
3985
  while (j < source.length && !ATTR_NAME_END.test(source[j])) j += 1;
3442
3986
  const attr = source.slice(attrStart, j);
3443
- if (attr === "") throw new XmlParseError(file, startLine, `<${name}> has an attribute with no name`);
3987
+ if (attr === "") throw new XmlParseError(file, startLine, `<${printable(name)}> has an attribute with no name`);
3444
3988
  while (j < source.length && SPACE.test(source[j])) j += 1;
3445
3989
  if (source[j] !== "=") {
3446
- throw new XmlParseError(file, startLine, `<${name}> attribute "${attr}" has no value`);
3990
+ throw new XmlParseError(file, startLine, `<${printable(name)}> attribute "${printable(attr)}" has no value`);
3447
3991
  }
3448
3992
  j += 1;
3449
3993
  while (j < source.length && SPACE.test(source[j])) j += 1;
3450
3994
  const quote = source[j];
3451
3995
  if (quote !== '"' && quote !== "'") {
3452
- throw new XmlParseError(file, startLine, `<${name}> attribute "${attr}" is not quoted`);
3996
+ throw new XmlParseError(file, startLine, `<${printable(name)}> attribute "${printable(attr)}" is not quoted`);
3453
3997
  }
3454
3998
  const close = source.indexOf(quote, j + 1);
3455
- if (close === -1) throw new XmlParseError(file, startLine, `<${name}> attribute "${attr}" is never closed`);
3999
+ if (close === -1) throw new XmlParseError(file, startLine, `<${printable(name)}> attribute "${printable(attr)}" is never closed`);
3456
4000
  el.attrs[attr] = decode(source.slice(j + 1, close));
3457
4001
  j = close + 1;
3458
4002
  }
@@ -3462,7 +4006,7 @@ function parseXml(source, file) {
3462
4006
  }
3463
4007
  if (stack.length > 1) {
3464
4008
  const open = top();
3465
- throw new XmlParseError(file, open.line, `<${open.name}> is never closed`);
4009
+ throw new XmlParseError(file, open.line, `<${printable(open.name)}> is never closed`);
3466
4010
  }
3467
4011
  if (root.children.length === 0) throw new XmlParseError(file, 1, "no elements \u2014 is this an XML report?");
3468
4012
  return root;
@@ -3472,6 +4016,7 @@ var init_xml = __esm({
3472
4016
  "src/importers/xml.ts"() {
3473
4017
  "use strict";
3474
4018
  init_cjs_shims();
4019
+ init_failure_detail();
3475
4020
  XmlParseError = class extends Error {
3476
4021
  constructor(file, line, detail) {
3477
4022
  super(`${file}:${line} \u2014 ${detail}`);
@@ -3518,7 +4063,7 @@ function playwrightOf(classname, name) {
3518
4063
  if (!SPEC_FILE.test(classname)) return void 0;
3519
4064
  return { file: classname.replaceAll("\\", "/"), titles: name.split(" \u203A ") };
3520
4065
  }
3521
- function keyOf(classname, name, playwright) {
4066
+ function keyOf2(classname, name, playwright) {
3522
4067
  if (playwright !== void 0) return { kind: "path-title", value: [playwright.file, ...playwright.titles].join("#") };
3523
4068
  return { kind: "path-title", value: classname === "" ? name : `${classname}#${name}` };
3524
4069
  }
@@ -3551,8 +4096,9 @@ function executionOf(test, suite) {
3551
4096
  retry: 0
3552
4097
  };
3553
4098
  }
3554
- function readJUnit(source, file) {
4099
+ function readJUnit(source, file, missing = [], notes = []) {
3555
4100
  const doc = parseXml(source, file);
4101
+ const { lexical, real: folder } = reportFolders(file);
3556
4102
  const found = [];
3557
4103
  locate(doc, void 0, found);
3558
4104
  return found.map(({ test, suite }) => {
@@ -3560,36 +4106,41 @@ function readJUnit(source, file) {
3560
4106
  const classname = test.attrs["classname"] ?? test.attrs["class"] ?? "";
3561
4107
  const { status, detail } = outcomeOf(test);
3562
4108
  const playwright = playwrightOf(classname, name);
3563
- const key = keyOf(classname, name, playwright);
3564
- const stated = TOKEN.exec(playwright?.titles.at(-1) ?? name)?.[1];
4109
+ const key = keyOf2(classname, name, playwright);
4110
+ const stated = TOKEN2.exec(playwright?.titles.at(-1) ?? name)?.[1];
3565
4111
  const specRef = specRefOf(test, suite, classname);
3566
4112
  const errorContext = errorTextOf(detail);
3567
4113
  const execution = executionOf(test, suite);
4114
+ const title = titleOf(classname, name);
4115
+ const rkey = resultKey(key.value, 0);
4116
+ const files = filesOfTest(test, folder, title, missing, { lexical, resultKey: rkey, notes });
3568
4117
  return {
3569
- resultKey: resultKey(key.value, 0),
4118
+ resultKey: rkey,
3570
4119
  ...stated !== void 0 ? { testCaseId: stated } : {},
3571
4120
  keys: [key],
3572
- source: SOURCE,
4121
+ source: SOURCE2,
3573
4122
  rawStatus: status,
3574
- title: titleOf(classname, name),
4123
+ title,
3575
4124
  ...specRef !== void 0 ? { specRef } : {},
3576
4125
  ...execution !== void 0 ? { execution } : {},
3577
- ...errorContext !== "" ? { errorContext } : {}
4126
+ ...errorContext !== "" ? { errorContext } : {},
4127
+ ...files.length > 0 ? { files } : {}
3578
4128
  };
3579
4129
  });
3580
4130
  }
3581
4131
  function looksLikeJUnit(source) {
3582
4132
  return /<\s*testsuites?\b/.test(source.slice(0, 4096));
3583
4133
  }
3584
- var SOURCE, TOKEN, SPEC_FILE;
4134
+ var SOURCE2, TOKEN2, SPEC_FILE;
3585
4135
  var init_junit = __esm({
3586
4136
  "src/importers/junit.ts"() {
3587
4137
  "use strict";
3588
4138
  init_cjs_shims();
3589
4139
  init_result_key();
4140
+ init_attachments();
3590
4141
  init_xml();
3591
- SOURCE = "junit";
3592
- TOKEN = /@P([A-Za-z0-9_-]+)/;
4142
+ SOURCE2 = "junit";
4143
+ TOKEN2 = /@P([A-Za-z0-9_-]+)/;
3593
4144
  SPEC_FILE = /\.(?:spec|test)\.[cm]?[jt]sx?$/;
3594
4145
  }
3595
4146
  });
@@ -3598,7 +4149,7 @@ var init_junit = __esm({
3598
4149
  function statedIdOf(test, title) {
3599
4150
  const annotation = test.annotations?.find((a) => a.type === "PluneId")?.description;
3600
4151
  if (annotation !== void 0 && annotation !== "") return annotation;
3601
- return TOKEN2.exec(title)?.[1];
4152
+ return TOKEN3.exec(title)?.[1];
3602
4153
  }
3603
4154
  function attemptOf(result, file, ctx) {
3604
4155
  const steps = (list2) => (list2 ?? []).map((s) => ({ title: s.title ?? "", failed: s.error !== void 0, steps: steps(s.steps) }));
@@ -3609,10 +4160,10 @@ function attemptOf(result, file, ctx) {
3609
4160
  ...typeof at?.file === "string" && typeof at.line === "number" ? { location: { file: at.file, line: at.line, ...typeof at.column === "number" ? { column: at.column } : {} } } : {}
3610
4161
  })),
3611
4162
  steps: steps(result.steps),
3612
- attachments: (result.attachments ?? []).map(({ name, contentType, path: path15, body }) => ({
4163
+ attachments: (result.attachments ?? []).map(({ name, contentType, path: path17, body }) => ({
3613
4164
  name: name ?? "",
3614
4165
  ...contentType !== void 0 ? { contentType } : {},
3615
- ...path15 !== void 0 ? { path: path15 } : {},
4166
+ ...path17 !== void 0 ? { path: path17 } : {},
3616
4167
  // Decoded only where the platform would take it (plune-ai/plune#928): a report of screenshots kept as
3617
4168
  // bodies is megabytes of base64 that nothing here will read.
3618
4169
  ...typeof body === "string" && isTextType(contentType) ? { body: Buffer.from(body, "base64") } : {}
@@ -3645,7 +4196,7 @@ function pendingFrom(spec, test, result, file, titles, ctx) {
3645
4196
  keys,
3646
4197
  title: titles.join(" \u203A "),
3647
4198
  specRef: spec.line !== void 0 ? `${file}:${spec.line}` : file,
3648
- source: SOURCE2,
4199
+ source: SOURCE3,
3649
4200
  // Untouched, as everywhere: `timedOut` means what the project's map says it means, not what
3650
4201
  // this file decided.
3651
4202
  rawStatus: result.status ?? "unknown",
@@ -3680,7 +4231,7 @@ function readPlaywrightJson(source, file) {
3680
4231
  try {
3681
4232
  parsed = JSON.parse(source);
3682
4233
  } catch (err) {
3683
- throw new JsonReportError(file, `not JSON \u2014 ${err instanceof Error ? err.message : String(err)}`);
4234
+ throw new JsonReportError(file, `not JSON \u2014 ${printable(err instanceof Error ? err.message : String(err))}`);
3684
4235
  }
3685
4236
  if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.suites)) {
3686
4237
  throw new JsonReportError(file, 'no "suites" array \u2014 is this a Playwright JSON report?');
@@ -3688,7 +4239,7 @@ function readPlaywrightJson(source, file) {
3688
4239
  const { config, suites } = parsed;
3689
4240
  const rootDir = typeof config?.rootDir === "string" ? config.rootDir : void 0;
3690
4241
  const buildHref = config?.metadata?.ci?.buildHref;
3691
- const repoRoot = rootDir !== void 0 && (0, import_node_fs4.existsSync)(rootDir) ? repoRootOf(rootDir) : void 0;
4242
+ const repoRoot = rootDir !== void 0 && (0, import_node_fs5.existsSync)(rootDir) ? repoRootOf(rootDir) : void 0;
3692
4243
  const ctx = {
3693
4244
  ...rootDir !== void 0 ? { rootDir } : {},
3694
4245
  ...repoRoot !== void 0 ? { repoRoot } : {},
@@ -3707,20 +4258,20 @@ function readPlaywrightJson(source, file) {
3707
4258
  function looksLikePlaywrightJson(source) {
3708
4259
  return /^\s*\{/.test(source) && source.includes('"suites"');
3709
4260
  }
3710
- var import_node_fs4, SOURCE2, TOKEN2, NOT_FAILED, JsonReportError;
4261
+ var import_node_fs5, SOURCE3, TOKEN3, NOT_FAILED, JsonReportError;
3711
4262
  var init_playwright_json = __esm({
3712
4263
  "src/importers/playwright-json.ts"() {
3713
4264
  "use strict";
3714
4265
  init_cjs_shims();
3715
- import_node_fs4 = require("fs");
4266
+ import_node_fs5 = require("fs");
3716
4267
  init_failure_detail();
3717
4268
  init_result_key();
3718
- SOURCE2 = "playwright";
3719
- TOKEN2 = /@P([A-Za-z0-9_-]+)/;
4269
+ SOURCE3 = "playwright";
4270
+ TOKEN3 = /@P([A-Za-z0-9_-]+)/;
3720
4271
  NOT_FAILED = /* @__PURE__ */ new Set(["passed", "skipped"]);
3721
4272
  JsonReportError = class extends Error {
3722
4273
  constructor(file, detail) {
3723
- super(`${file} \u2014 ${detail}`);
4274
+ super(`${printable(file)} \u2014 ${detail}`);
3724
4275
  this.file = file;
3725
4276
  this.name = "JsonReportError";
3726
4277
  }
@@ -3732,11 +4283,14 @@ var init_playwright_json = __esm({
3732
4283
  // src/importers/index.ts
3733
4284
  var importers_exports = {};
3734
4285
  __export(importers_exports, {
4286
+ AllureReportError: () => AllureReportError,
3735
4287
  IMPORT_FORMATS: () => IMPORT_FORMATS,
3736
4288
  JsonReportError: () => JsonReportError,
3737
4289
  UnknownFormatError: () => UnknownFormatError,
3738
4290
  XmlParseError: () => XmlParseError,
3739
4291
  detectFormat: () => detectFormat,
4292
+ isFolder: () => isFolder,
4293
+ readFolder: () => readFolder,
3740
4294
  readReport: () => readReport
3741
4295
  });
3742
4296
  function detectFormat(source, file) {
@@ -3747,21 +4301,34 @@ function detectFormat(source, file) {
3747
4301
  'no <testsuite> and no Playwright "suites" \u2014 this is not a report we can read.'
3748
4302
  );
3749
4303
  }
4304
+ function readFolder(dir, format) {
4305
+ if (format !== void 0 && format !== "allure") {
4306
+ throw new UnknownFormatError(dir, `this is a folder, and only allure-results is read from one, not ${format}.`);
4307
+ }
4308
+ const missing = [];
4309
+ const notes = [];
4310
+ return { format: "allure", results: readAllure(dir, missing, notes), missing, notes };
4311
+ }
3750
4312
  function readReport(source, file, format) {
3751
4313
  const chosen2 = format ?? detectFormat(source, file);
3752
- if (chosen2 === "junit") return { format: chosen2, results: readJUnit(source, file) };
3753
- return { format: chosen2, ...readPlaywrightJson(source, file) };
4314
+ const missing = [];
4315
+ if (chosen2 === "allure") throw new UnknownFormatError(file, "allure-results is a folder, and this is a file.");
4316
+ const notes = [];
4317
+ if (chosen2 === "junit") return { format: chosen2, results: readJUnit(source, file, missing, notes), missing, notes };
4318
+ return { format: chosen2, ...readPlaywrightJson(source, file), missing, notes };
3754
4319
  }
3755
4320
  var IMPORT_FORMATS, UnknownFormatError;
3756
4321
  var init_importers = __esm({
3757
4322
  "src/importers/index.ts"() {
3758
4323
  "use strict";
3759
4324
  init_cjs_shims();
4325
+ init_allure();
3760
4326
  init_junit();
3761
4327
  init_playwright_json();
3762
4328
  init_xml();
3763
4329
  init_playwright_json();
3764
- IMPORT_FORMATS = ["junit", "playwright-json"];
4330
+ init_allure();
4331
+ IMPORT_FORMATS = ["junit", "playwright-json", "allure"];
3765
4332
  UnknownFormatError = class extends Error {
3766
4333
  constructor(file, detail) {
3767
4334
  super(`${file} \u2014 ${detail} Say which with --format ${IMPORT_FORMATS.join(" | ")}.`);
@@ -3779,16 +4346,25 @@ __export(run_import_exports, {
3779
4346
  async function handleRunImport(options) {
3780
4347
  const write = options.write ?? ((line) => void process.stdout.write(`${line}
3781
4348
  `));
3782
- let source;
3783
- try {
3784
- source = fs8.readFileSync(options.file, "utf8");
3785
- } catch {
3786
- throw new RunCommandError(`Cannot read ${options.file}.`);
4349
+ let read;
4350
+ if (isFolder(options.file)) {
4351
+ read = readFolder(options.file, options.format);
4352
+ } else {
4353
+ let source;
4354
+ try {
4355
+ source = fs10.readFileSync(options.file, "utf8");
4356
+ } catch {
4357
+ throw new RunCommandError(`Cannot read ${options.file}.`);
4358
+ }
4359
+ read = readReport(source, options.file, options.format);
3787
4360
  }
3788
- const { format, results, ciUrl, ci } = readReport(source, options.file, options.format);
4361
+ const { format, results, missing, notes, ciUrl, ci } = read;
3789
4362
  if (results.length === 0) {
3790
4363
  throw new RunCommandError(`No test cases in ${options.file} \u2014 it parsed, but there is nothing in it.`);
3791
4364
  }
4365
+ for (const m of missing) write(`plune: attachment not found \u2014 ${JSON.stringify(printable(m.title))}: ${printable(m.path)}`);
4366
+ for (const note of notes) write(note);
4367
+ const announced = (format === "junit" || format === "allure") && (missing.length > 0 || results.some((r) => (r.files?.length ?? 0) > 0));
3792
4368
  const env = readEnv();
3793
4369
  const apiUrl = resolveApiUrl(options.apiUrl ?? env.config.apiUrl);
3794
4370
  const token = options.token ?? env.config.token ?? loadToken() ?? "";
@@ -3833,6 +4409,13 @@ async function handleRunImport(options) {
3833
4409
  deferred: stats.deferred,
3834
4410
  screenshots: { ...stats.screenshots },
3835
4411
  textFiles: { ...stats.textFiles },
4412
+ missingAttachments: missing,
4413
+ filesUploaded: stats.screenshots.uploaded + stats.textFiles.uploaded + stats.otherFiles.uploaded,
4414
+ announced,
4415
+ filesNotSent: Math.max(
4416
+ 0,
4417
+ results.reduce((n, r) => n + (r.files?.length ?? 0), 0) - [stats.screenshots, stats.textFiles, stats.otherFiles].reduce((n, c) => n + c.uploaded + c.skipped + c.failed, 0)
4418
+ ),
3836
4419
  unlocatable: results.filter((r) => r.specRef === void 0).length,
3837
4420
  offering: options.create === true
3838
4421
  };
@@ -3851,21 +4434,11 @@ function describe(result, apiUrl) {
3851
4434
  // worked (#622).
3852
4435
  // `not delivered` always, zero included (#790): a number that only appears when it is bad is one
3853
4436
  // nobody learns to look for. The `[0-9]+ unmatched` the workflows read stays whole.
3854
- `Read ${result.parsed} result(s) from a ${result.format} report: ${result.accepted} accepted, ${result.duplicate} already there, ${result.unresolved} unmatched, ${result.deferred} not delivered.`
4437
+ `Read ${result.parsed} result(s) from ${result.format === "allure" ? "an" : "a"} ${result.format} report: ${result.accepted} accepted, ${result.duplicate} already there, ${result.unresolved} unmatched, ${result.deferred} not delivered.`
3855
4438
  ];
3856
4439
  if (result.conflict > 0 || result.rejected > 0) {
3857
4440
  lines.push(`${result.conflict} conflicted and ${result.rejected} were refused.`);
3858
4441
  }
3859
- for (const [counts, noun] of [
3860
- [result.screenshots, "screenshot"],
3861
- [result.textFiles, "text file"]
3862
- ]) {
3863
- if (counts.uploaded + counts.skipped + counts.failed > 0) {
3864
- lines.push(
3865
- `${counts.uploaded} ${noun}(s) uploaded to their results` + (counts.skipped > 0 ? `, ${counts.skipped} skipped` : "") + (counts.failed > 0 ? `, ${counts.failed} could not be uploaded` : "") + "."
3866
- );
3867
- }
3868
- }
3869
4442
  if (result.deferred > 0) {
3870
4443
  lines.push(
3871
4444
  `${result.deferred} could not be sent and are in the fallback file \u2014 "plune run report" retries them.`
@@ -3893,16 +4466,22 @@ function describe(result, apiUrl) {
3893
4466
  `${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.`
3894
4467
  );
3895
4468
  }
4469
+ if (result.announced) {
4470
+ lines.push(
4471
+ `Imported ${result.accepted} results \xB7 ${result.filesUploaded} files uploaded \xB7 ${result.missingAttachments.length} files not found` + (result.filesNotSent > 0 ? ` \xB7 ${result.filesNotSent} files not sent (their result was not stored)` : "")
4472
+ );
4473
+ }
3896
4474
  if (result.runId !== null) lines.push(runUrl2(apiUrl, result.runId));
3897
4475
  return lines;
3898
4476
  }
3899
- var fs8, NEXT;
4477
+ var fs10, NEXT;
3900
4478
  var init_run_import = __esm({
3901
4479
  "src/cli/commands/run-import.ts"() {
3902
4480
  "use strict";
3903
4481
  init_cjs_shims();
3904
- fs8 = __toESM(require("fs"), 1);
4482
+ fs10 = __toESM(require("fs"), 1);
3905
4483
  init_session();
4484
+ init_failure_detail();
3906
4485
  init_importers();
3907
4486
  init_run_lifecycle();
3908
4487
  init_api_url();
@@ -3929,10 +4508,10 @@ function isRunResult(v) {
3929
4508
  }
3930
4509
  function handleReport(opts = {}) {
3931
4510
  const cwd = opts.cwd ?? process.cwd();
3932
- const file = path9.join(cwd, ".plune", "last-run.json");
4511
+ const file = path11.join(cwd, ".plune", "last-run.json");
3933
4512
  let raw;
3934
4513
  try {
3935
- raw = fs9.readFileSync(file, "utf8");
4514
+ raw = fs11.readFileSync(file, "utf8");
3936
4515
  } catch {
3937
4516
  throw new ReportNotFoundError('No saved run found. Run "plune run" first.');
3938
4517
  }
@@ -3947,13 +4526,13 @@ function handleReport(opts = {}) {
3947
4526
  }
3948
4527
  return parsed;
3949
4528
  }
3950
- var fs9, path9, ReportNotFoundError;
4529
+ var fs11, path11, ReportNotFoundError;
3951
4530
  var init_report = __esm({
3952
4531
  "src/cli/commands/report.ts"() {
3953
4532
  "use strict";
3954
4533
  init_cjs_shims();
3955
- fs9 = __toESM(require("fs"), 1);
3956
- path9 = __toESM(require("path"), 1);
4534
+ fs11 = __toESM(require("fs"), 1);
4535
+ path11 = __toESM(require("path"), 1);
3957
4536
  ReportNotFoundError = class extends Error {
3958
4537
  code = "NO_SAVED_RUN";
3959
4538
  constructor(message) {
@@ -4027,7 +4606,7 @@ function isRunResult2(v) {
4027
4606
  function loadRunResult(file, label) {
4028
4607
  let raw;
4029
4608
  try {
4030
- raw = fs10.readFileSync(file, "utf8");
4609
+ raw = fs12.readFileSync(file, "utf8");
4031
4610
  } catch {
4032
4611
  throw new DiffInputError(`Cannot read ${label} run file: ${file}`);
4033
4612
  }
@@ -4047,12 +4626,12 @@ function handleDiff(opts) {
4047
4626
  const current = loadRunResult(opts.currentPath, "current");
4048
4627
  return diffRuns(baseline, current);
4049
4628
  }
4050
- var fs10, DiffInputError;
4629
+ var fs12, DiffInputError;
4051
4630
  var init_diff2 = __esm({
4052
4631
  "src/cli/commands/diff.ts"() {
4053
4632
  "use strict";
4054
4633
  init_cjs_shims();
4055
- fs10 = __toESM(require("fs"), 1);
4634
+ fs12 = __toESM(require("fs"), 1);
4056
4635
  init_diff();
4057
4636
  DiffInputError = class extends Error {
4058
4637
  code = "DIFF_INPUT";
@@ -4177,8 +4756,8 @@ async function runInitWizard(cwd) {
4177
4756
  process.exit(1);
4178
4757
  return;
4179
4758
  }
4180
- const outputPath = path10.join(cwd, "plune.yaml");
4181
- if (fs11.existsSync(outputPath)) {
4759
+ const outputPath = path12.join(cwd, "plune.yaml");
4760
+ if (fs13.existsSync(outputPath)) {
4182
4761
  const overwrite = await clack.confirm({
4183
4762
  message: "plune.yaml already exists. Overwrite?"
4184
4763
  });
@@ -4217,18 +4796,18 @@ async function runInitWizard(cwd) {
4217
4796
  `;
4218
4797
  const spin = clack.spinner();
4219
4798
  spin.start("Writing plune.yaml\u2026");
4220
- fs11.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
4799
+ fs13.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
4221
4800
  spin.stop("plune.yaml created!");
4222
4801
  clack.outro("plune.yaml is ready.");
4223
4802
  }
4224
- var clack, fs11, path10, import_yaml2;
4803
+ var clack, fs13, path12, import_yaml2;
4225
4804
  var init_wizard = __esm({
4226
4805
  "src/config/init/wizard.ts"() {
4227
4806
  "use strict";
4228
4807
  init_cjs_shims();
4229
4808
  clack = __toESM(require("@clack/prompts"), 1);
4230
- fs11 = __toESM(require("fs"), 1);
4231
- path10 = __toESM(require("path"), 1);
4809
+ fs13 = __toESM(require("fs"), 1);
4810
+ path12 = __toESM(require("path"), 1);
4232
4811
  import_yaml2 = require("yaml");
4233
4812
  init_errors();
4234
4813
  }
@@ -4236,21 +4815,21 @@ var init_wizard = __esm({
4236
4815
 
4237
4816
  // src/cli/templates/index.ts
4238
4817
  function writeTemplateFile(cwd, rel, content, force) {
4239
- const target = path11.join(cwd, rel);
4240
- if (!force && fs12.existsSync(target)) {
4818
+ const target = path13.join(cwd, rel);
4819
+ if (!force && fs14.existsSync(target)) {
4241
4820
  return "skipped";
4242
4821
  }
4243
- fs12.mkdirSync(path11.dirname(target), { recursive: true });
4244
- fs12.writeFileSync(target, content, "utf8");
4822
+ fs14.mkdirSync(path13.dirname(target), { recursive: true });
4823
+ fs14.writeFileSync(target, content, "utf8");
4245
4824
  return "written";
4246
4825
  }
4247
- var fs12, path11, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
4826
+ var fs14, path13, ENV_EXAMPLE_TPL, EXAMPLE_JSONL_TPL, PLUNE_YAML_TPL;
4248
4827
  var init_templates = __esm({
4249
4828
  "src/cli/templates/index.ts"() {
4250
4829
  "use strict";
4251
4830
  init_cjs_shims();
4252
- fs12 = __toESM(require("fs"), 1);
4253
- path11 = __toESM(require("path"), 1);
4831
+ fs14 = __toESM(require("fs"), 1);
4832
+ path13 = __toESM(require("path"), 1);
4254
4833
  ENV_EXAMPLE_TPL = `# Copy this file to .env and replace the placeholders with your real keys.
4255
4834
  # .env is gitignored; .env.example is safe to commit.
4256
4835
  # Plune reads whichever key matches your configured provider.
@@ -4428,68 +5007,136 @@ var init_logout = __esm({
4428
5007
  }
4429
5008
  });
4430
5009
 
5010
+ // src/types/test-case-schema.ts
5011
+ var import_zod2, TEST_CASE_SCHEMA_VERSION, externalKeySchema, manualStepSchema, assertionResultRecordSchema, manualCaseSchema, automatedCaseSchema, evalCaseSchema, testCaseBaseSchema, testCaseSchema;
5012
+ var init_test_case_schema = __esm({
5013
+ "src/types/test-case-schema.ts"() {
5014
+ "use strict";
5015
+ init_cjs_shims();
5016
+ import_zod2 = require("zod");
5017
+ TEST_CASE_SCHEMA_VERSION = 2;
5018
+ externalKeySchema = import_zod2.z.object({
5019
+ kind: import_zod2.z.enum(["playwright-id", "path-title", "allure-history", "cairn-stable", "eval-id", "qase", "testrail"]),
5020
+ value: import_zod2.z.string().min(1)
5021
+ });
5022
+ manualStepSchema = import_zod2.z.object({
5023
+ action: import_zod2.z.string().min(1),
5024
+ expected: import_zod2.z.string().min(1).optional()
5025
+ });
5026
+ assertionResultRecordSchema = import_zod2.z.object({
5027
+ type: import_zod2.z.string().min(1),
5028
+ passed: import_zod2.z.boolean(),
5029
+ score: import_zod2.z.number().optional(),
5030
+ reason: import_zod2.z.string().optional()
5031
+ });
5032
+ manualCaseSchema = import_zod2.z.object({
5033
+ type: import_zod2.z.literal("manual"),
5034
+ steps: import_zod2.z.array(manualStepSchema).min(1),
5035
+ expected: import_zod2.z.string().min(1).optional()
5036
+ });
5037
+ automatedCaseSchema = import_zod2.z.object({
5038
+ type: import_zod2.z.literal("automated"),
5039
+ specRef: import_zod2.z.string().min(1)
5040
+ });
5041
+ evalCaseSchema = import_zod2.z.object({
5042
+ type: import_zod2.z.literal("eval"),
5043
+ prompt: import_zod2.z.string().min(1),
5044
+ goldenSetId: import_zod2.z.string().min(1).optional(),
5045
+ assertions: import_zod2.z.array(assertionResultRecordSchema).min(1)
5046
+ });
5047
+ testCaseBaseSchema = import_zod2.z.object({
5048
+ schemaVersion: import_zod2.z.literal(TEST_CASE_SCHEMA_VERSION),
5049
+ id: import_zod2.z.string().min(1),
5050
+ externalKeys: import_zod2.z.array(externalKeySchema).optional(),
5051
+ /** ADR 0045 / plune#1246 — optional, purely additive; schemaVersion stays 2. */
5052
+ is_flaky: import_zod2.z.boolean().optional(),
5053
+ /** ADR 0045 / plune#1246 — optional, purely additive; schemaVersion stays 2. */
5054
+ is_muted: import_zod2.z.boolean().optional()
5055
+ });
5056
+ testCaseSchema = import_zod2.z.discriminatedUnion("type", [
5057
+ testCaseBaseSchema.merge(manualCaseSchema),
5058
+ testCaseBaseSchema.merge(automatedCaseSchema),
5059
+ testCaseBaseSchema.merge(evalCaseSchema)
5060
+ ]);
5061
+ }
5062
+ });
5063
+
5064
+ // src/types/results.ts
5065
+ var ROW_INCONCLUSIVE;
5066
+ var init_results = __esm({
5067
+ "src/types/results.ts"() {
5068
+ "use strict";
5069
+ init_cjs_shims();
5070
+ init_test_case_schema();
5071
+ ROW_INCONCLUSIVE = ["blocked", "skipped"];
5072
+ }
5073
+ });
5074
+
4431
5075
  // src/types/run-result-schema.ts
4432
5076
  function parseRunResult(value2) {
4433
5077
  return runResultSchema.parse(value2);
4434
5078
  }
4435
- var import_zod2, RUN_RESULT_SCHEMA_VERSION, binaryVerdictSchema, assertionResultRecordSchema, usageSchema, runErrorSchema, rowResultSchema, summarySchema, evalResultSchema, runResultSchema;
5079
+ var import_zod3, RUN_RESULT_SCHEMA_VERSION, binaryVerdictSchema, assertionResultRecordSchema2, usageSchema, runErrorSchema, rowResultSchema, summarySchema, evalResultSchema, runResultSchema;
4436
5080
  var init_run_result_schema = __esm({
4437
5081
  "src/types/run-result-schema.ts"() {
4438
5082
  "use strict";
4439
5083
  init_cjs_shims();
4440
- import_zod2 = require("zod");
5084
+ import_zod3 = require("zod");
5085
+ init_results();
4441
5086
  RUN_RESULT_SCHEMA_VERSION = 1;
4442
- binaryVerdictSchema = import_zod2.z.object({
4443
- question: import_zod2.z.string().min(1),
4444
- passed: import_zod2.z.boolean()
5087
+ binaryVerdictSchema = import_zod3.z.object({
5088
+ question: import_zod3.z.string().min(1),
5089
+ passed: import_zod3.z.boolean()
4445
5090
  });
4446
- assertionResultRecordSchema = import_zod2.z.object({
4447
- type: import_zod2.z.string(),
4448
- passed: import_zod2.z.boolean(),
4449
- score: import_zod2.z.number().optional(),
4450
- reason: import_zod2.z.string().optional(),
4451
- binaryVerdicts: import_zod2.z.array(binaryVerdictSchema).optional()
5091
+ assertionResultRecordSchema2 = import_zod3.z.object({
5092
+ type: import_zod3.z.string(),
5093
+ passed: import_zod3.z.boolean(),
5094
+ score: import_zod3.z.number().optional(),
5095
+ reason: import_zod3.z.string().optional(),
5096
+ binaryVerdicts: import_zod3.z.array(binaryVerdictSchema).optional()
4452
5097
  });
4453
- usageSchema = import_zod2.z.object({
4454
- input_tokens: import_zod2.z.number(),
4455
- output_tokens: import_zod2.z.number(),
4456
- cost_usd: import_zod2.z.number()
5098
+ usageSchema = import_zod3.z.object({
5099
+ input_tokens: import_zod3.z.number(),
5100
+ output_tokens: import_zod3.z.number(),
5101
+ cost_usd: import_zod3.z.number()
4457
5102
  });
4458
- runErrorSchema = import_zod2.z.object({
4459
- code: import_zod2.z.string(),
4460
- message: import_zod2.z.string()
5103
+ runErrorSchema = import_zod3.z.object({
5104
+ code: import_zod3.z.string(),
5105
+ message: import_zod3.z.string()
4461
5106
  });
4462
- rowResultSchema = import_zod2.z.object({
4463
- vars: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.unknown()),
4464
- output: import_zod2.z.string().nullable(),
4465
- cached: import_zod2.z.boolean(),
5107
+ rowResultSchema = import_zod3.z.object({
5108
+ vars: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()),
5109
+ output: import_zod3.z.string().nullable(),
5110
+ cached: import_zod3.z.boolean(),
4466
5111
  usage: usageSchema.optional(),
4467
- latency_ms: import_zod2.z.number().optional(),
5112
+ latency_ms: import_zod3.z.number().optional(),
4468
5113
  error: runErrorSchema.optional(),
4469
- assertions: import_zod2.z.array(assertionResultRecordSchema)
5114
+ assertions: import_zod3.z.array(assertionResultRecordSchema2),
5115
+ /** ADR 0045. Optional and additive — the same enum the platform validates on `POST /v1/runs`. */
5116
+ inconclusive: import_zod3.z.enum(ROW_INCONCLUSIVE).optional()
4470
5117
  });
4471
- summarySchema = import_zod2.z.object({
4472
- total: import_zod2.z.number(),
4473
- passed: import_zod2.z.number(),
4474
- failed: import_zod2.z.number(),
4475
- errored: import_zod2.z.number(),
4476
- cost_usd: import_zod2.z.number(),
4477
- duration_ms: import_zod2.z.number()
5118
+ summarySchema = import_zod3.z.object({
5119
+ total: import_zod3.z.number(),
5120
+ passed: import_zod3.z.number(),
5121
+ failed: import_zod3.z.number(),
5122
+ errored: import_zod3.z.number(),
5123
+ cost_usd: import_zod3.z.number(),
5124
+ duration_ms: import_zod3.z.number()
4478
5125
  });
4479
- evalResultSchema = import_zod2.z.object({
4480
- id: import_zod2.z.string(),
4481
- tags: import_zod2.z.array(import_zod2.z.string()),
4482
- rows: import_zod2.z.array(rowResultSchema),
4483
- passed: import_zod2.z.boolean()
5126
+ evalResultSchema = import_zod3.z.object({
5127
+ id: import_zod3.z.string(),
5128
+ tags: import_zod3.z.array(import_zod3.z.string()),
5129
+ rows: import_zod3.z.array(rowResultSchema),
5130
+ passed: import_zod3.z.boolean()
4484
5131
  });
4485
- runResultSchema = import_zod2.z.object({
4486
- schemaVersion: import_zod2.z.literal(RUN_RESULT_SCHEMA_VERSION),
4487
- plune_version: import_zod2.z.string(),
4488
- started_at: import_zod2.z.string(),
4489
- finished_at: import_zod2.z.string(),
4490
- config_hash: import_zod2.z.string(),
5132
+ runResultSchema = import_zod3.z.object({
5133
+ schemaVersion: import_zod3.z.literal(RUN_RESULT_SCHEMA_VERSION),
5134
+ plune_version: import_zod3.z.string(),
5135
+ started_at: import_zod3.z.string(),
5136
+ finished_at: import_zod3.z.string(),
5137
+ config_hash: import_zod3.z.string(),
4491
5138
  summary: summarySchema,
4492
- evals: import_zod2.z.array(evalResultSchema)
5139
+ evals: import_zod3.z.array(evalResultSchema)
4493
5140
  });
4494
5141
  }
4495
5142
  });
@@ -4506,10 +5153,10 @@ __export(sync_exports, {
4506
5153
  reportSyncFailure: () => reportSyncFailure
4507
5154
  });
4508
5155
  function readRun(cwd, file) {
4509
- const target = file !== void 0 ? path12.resolve(cwd, file) : path12.join(cwd, ".plune", "last-run.json");
5156
+ const target = file !== void 0 ? path14.resolve(cwd, file) : path14.join(cwd, ".plune", "last-run.json");
4510
5157
  let raw;
4511
5158
  try {
4512
- raw = fs13.readFileSync(target, "utf8");
5159
+ raw = fs15.readFileSync(target, "utf8");
4513
5160
  } catch {
4514
5161
  throw new SyncFileError(
4515
5162
  file !== void 0 ? `Run file not found: ${target}` : 'No run to sync. Run "plune run" first (or pass --file <path>).'
@@ -4573,13 +5220,13 @@ function reportSyncFailure(err, write) {
4573
5220
  }
4574
5221
  return null;
4575
5222
  }
4576
- var fs13, path12, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
5223
+ var fs15, path14, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
4577
5224
  var init_sync = __esm({
4578
5225
  "src/cli/commands/sync.ts"() {
4579
5226
  "use strict";
4580
5227
  init_cjs_shims();
4581
- fs13 = __toESM(require("fs"), 1);
4582
- path12 = __toESM(require("path"), 1);
5228
+ fs15 = __toESM(require("fs"), 1);
5229
+ path14 = __toESM(require("path"), 1);
4583
5230
  init_run_result_schema();
4584
5231
  init_api_url();
4585
5232
  init_credentials();
@@ -4624,14 +5271,14 @@ var init_sync = __esm({
4624
5271
 
4625
5272
  // src/cli/commands/cairn-artifact.ts
4626
5273
  async function findLatestCairnRun(runsDir = "runs") {
4627
- const entries = await fs14.readdir(runsDir, { withFileTypes: true }).catch(() => null);
5274
+ const entries = await fs16.readdir(runsDir, { withFileTypes: true }).catch(() => null);
4628
5275
  if (entries === null) throw new NoCairnRunsError(runsDir);
4629
5276
  let newest;
4630
5277
  for (const entry of entries) {
4631
5278
  if (!entry.isDirectory()) continue;
4632
- const dir = path13.join(runsDir, entry.name);
5279
+ const dir = path15.join(runsDir, entry.name);
4633
5280
  try {
4634
- const report = await fs14.lstat(path13.join(dir, "report.json"));
5281
+ const report = await fs16.lstat(path15.join(dir, "report.json"));
4635
5282
  if (!report.isFile()) continue;
4636
5283
  if (newest === void 0 || report.mtimeMs > newest.at) {
4637
5284
  newest = { dir, at: report.mtimeMs };
@@ -4644,13 +5291,13 @@ async function findLatestCairnRun(runsDir = "runs") {
4644
5291
  }
4645
5292
  async function readJson(file) {
4646
5293
  try {
4647
- return JSON.parse(await fs14.readFile(file, "utf8"));
5294
+ return JSON.parse(await fs16.readFile(file, "utf8"));
4648
5295
  } catch {
4649
5296
  return void 0;
4650
5297
  }
4651
5298
  }
4652
5299
  async function defaultReadEvidence(dir) {
4653
- const raw = await readJson(path13.join(dir, "api-evidence.json"));
5300
+ const raw = await readJson(path15.join(dir, "api-evidence.json"));
4654
5301
  if (!Array.isArray(raw)) return void 0;
4655
5302
  return raw.map((r) => {
4656
5303
  const rec = asRecord(r);
@@ -4692,7 +5339,7 @@ function casesFromApi(report, verdicts) {
4692
5339
  });
4693
5340
  }
4694
5341
  async function readCairnRun(dir, deps = {}) {
4695
- const raw = await readJson(path13.join(dir, "report.json"));
5342
+ const raw = await readJson(path15.join(dir, "report.json"));
4696
5343
  if (raw === void 0) throw new CairnRunNotFoundError(dir);
4697
5344
  const report = asRecord(raw);
4698
5345
  if (report["schemaVersion"] !== SUPPORTED_ARTIFACT_VERSION) {
@@ -4722,20 +5369,20 @@ async function readCairnRun(dir, deps = {}) {
4722
5369
  return {
4723
5370
  schemaVersion: INGEST_SCHEMA_VERSION,
4724
5371
  run: {
4725
- runId: str(report["runId"], path13.basename(dir)),
5372
+ runId: str(report["runId"], path15.basename(dir)),
4726
5373
  mode,
4727
5374
  target: str(report["url"], "unknown")
4728
5375
  },
4729
5376
  cases
4730
5377
  };
4731
5378
  }
4732
- var fs14, path13, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
5379
+ var fs16, path15, SUPPORTED_ARTIFACT_VERSION, INGEST_SCHEMA_VERSION, NoCairnRunsError, CairnRunNotFoundError, CairnRunVersionError, CairnRunPartialError, CairnEvidenceAmbiguousError, asRecord, str;
4733
5380
  var init_cairn_artifact = __esm({
4734
5381
  "src/cli/commands/cairn-artifact.ts"() {
4735
5382
  "use strict";
4736
5383
  init_cjs_shims();
4737
- fs14 = __toESM(require("fs/promises"), 1);
4738
- path13 = __toESM(require("path"), 1);
5384
+ fs16 = __toESM(require("fs/promises"), 1);
5385
+ path15 = __toESM(require("path"), 1);
4739
5386
  SUPPORTED_ARTIFACT_VERSION = 1;
4740
5387
  INGEST_SCHEMA_VERSION = 1;
4741
5388
  NoCairnRunsError = class extends Error {
@@ -4902,7 +5549,7 @@ __export(cases_exports, {
4902
5549
  function gitDirty(file) {
4903
5550
  try {
4904
5551
  const out = (0, import_node_child_process2.execFileSync)("git", ["status", "--porcelain", "--", file], {
4905
- cwd: path14.dirname(file),
5552
+ cwd: path16.dirname(file),
4906
5553
  stdio: ["ignore", "pipe", "ignore"],
4907
5554
  encoding: "utf8"
4908
5555
  });
@@ -4928,9 +5575,9 @@ async function handlePull(deps = {}) {
4928
5575
  const cwd = deps.cwd ?? process.cwd();
4929
5576
  const apiUrl = resolveApiUrl(deps.apiUrl);
4930
5577
  const token = tokenOf(deps);
4931
- const file = path14.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4932
- if (fs15.existsSync(file) && !deps.force && (deps.isDirty ?? gitDirty)(file))
4933
- throw new DirtyFileError(path14.relative(cwd, file) || file);
5578
+ const file = path16.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
5579
+ if (fs17.existsSync(file) && !deps.force && (deps.isDirty ?? gitDirty)(file))
5580
+ throw new DirtyFileError(path16.relative(cwd, file) || file);
4934
5581
  const doFetch = deps.fetchImpl ?? fetch;
4935
5582
  const endpoint = deps.suiteId !== void 0 ? `${apiUrl}/v1/suites/${encodeURIComponent(deps.suiteId)}/markdown` : `${apiUrl}/v1/test-cases/markdown`;
4936
5583
  let res;
@@ -4945,18 +5592,18 @@ async function handlePull(deps = {}) {
4945
5592
  throw new SyncHttpError(res.status, detail);
4946
5593
  }
4947
5594
  const text2 = await res.text();
4948
- fs15.mkdirSync(path14.dirname(file), { recursive: true });
4949
- fs15.writeFileSync(file, text2, "utf8");
5595
+ fs17.mkdirSync(path16.dirname(file), { recursive: true });
5596
+ fs17.writeFileSync(file, text2, "utf8");
4950
5597
  return { file, cases: Number(res.headers.get("x-plune-cases") ?? "0") };
4951
5598
  }
4952
5599
  async function handlePush(deps = {}) {
4953
5600
  const cwd = deps.cwd ?? process.cwd();
4954
5601
  const apiUrl = resolveApiUrl(deps.apiUrl);
4955
5602
  const token = tokenOf(deps);
4956
- const file = path14.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
5603
+ const file = path16.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4957
5604
  let text2;
4958
5605
  try {
4959
- text2 = fs15.readFileSync(file, "utf8");
5606
+ text2 = fs17.readFileSync(file, "utf8");
4960
5607
  } catch {
4961
5608
  throw new SyncFileError(
4962
5609
  `No document to push: ${file}. Run "plune pull" first (or pass a file).`
@@ -5007,18 +5654,18 @@ function reportCasesFailure(err, write) {
5007
5654
  }
5008
5655
  return null;
5009
5656
  }
5010
- var import_node_child_process2, fs15, path14, DEFAULT_CASES_FILE, DirtyFileError;
5657
+ var import_node_child_process2, fs17, path16, DEFAULT_CASES_FILE, DirtyFileError;
5011
5658
  var init_cases = __esm({
5012
5659
  "src/cli/commands/cases.ts"() {
5013
5660
  "use strict";
5014
5661
  init_cjs_shims();
5015
5662
  import_node_child_process2 = require("child_process");
5016
- fs15 = __toESM(require("fs"), 1);
5017
- path14 = __toESM(require("path"), 1);
5663
+ fs17 = __toESM(require("fs"), 1);
5664
+ path16 = __toESM(require("path"), 1);
5018
5665
  init_api_url();
5019
5666
  init_credentials();
5020
5667
  init_sync();
5021
- DEFAULT_CASES_FILE = path14.join("plune", "cases.md");
5668
+ DEFAULT_CASES_FILE = path16.join("plune", "cases.md");
5022
5669
  DirtyFileError = class extends Error {
5023
5670
  constructor(file) {
5024
5671
  super(`${file} has uncommitted changes; commit them or pass --force to overwrite.`);
@@ -5099,11 +5746,11 @@ module.exports = __toCommonJS(cli_exports);
5099
5746
  init_cjs_shims();
5100
5747
  init_api_url();
5101
5748
  var import_commander = require("commander");
5102
- var import_node_fs5 = require("fs");
5749
+ var import_node_fs6 = require("fs");
5103
5750
  var import_node_path4 = require("path");
5104
5751
  var import_node_url = require("url");
5105
5752
  var __dirname = (0, import_node_path4.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
5106
- var pkg = JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path4.join)(__dirname, "..", "package.json"), "utf-8"));
5753
+ var pkg = JSON.parse((0, import_node_fs6.readFileSync)((0, import_node_path4.join)(__dirname, "..", "package.json"), "utf-8"));
5107
5754
  function toFormat(s) {
5108
5755
  return s === "json" || s === "markdown" ? s : "console";
5109
5756
  }
@@ -5112,7 +5759,7 @@ function shouldColor(outputPath) {
5112
5759
  }
5113
5760
  function writeReport(text2, outputPath) {
5114
5761
  if (outputPath !== void 0) {
5115
- (0, import_node_fs5.writeFileSync)(outputPath, text2);
5762
+ (0, import_node_fs6.writeFileSync)(outputPath, text2);
5116
5763
  } else {
5117
5764
  process.stdout.write(text2 + "\n");
5118
5765
  }
@@ -5303,11 +5950,15 @@ function createProgram() {
5303
5950
  await failRunCommand(err, verboseOf(command));
5304
5951
  }
5305
5952
  });
5306
- 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(
5953
+ runCommand.command("import").argument("<file>", "A JUnit XML or Playwright JSON report, or an Allure allure-results folder").description("Turn a report another runner wrote into a run in Plune \u2014 no provider key needed").option("--format <fmt>", "junit | playwright-json | allure; detected from the file (a folder is allure) 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).option(
5954
+ "--fail-on-missing-attachments",
5955
+ "Exit 1 when a file the report names ([[ATTACHMENT|path]] in JUnit, a source in allure-results) is not there; the results are sent either way",
5956
+ false
5957
+ ).action(
5307
5958
  async (file, options, command) => {
5308
5959
  const [
5309
5960
  { handleRunImport: handleRunImport2 },
5310
- { IMPORT_FORMATS: IMPORT_FORMATS2, UnknownFormatError: UnknownFormatError2, XmlParseError: XmlParseError2, JsonReportError: JsonReportError2 }
5961
+ { IMPORT_FORMATS: IMPORT_FORMATS2, UnknownFormatError: UnknownFormatError2, XmlParseError: XmlParseError2, JsonReportError: JsonReportError2, AllureReportError: AllureReportError2 }
5311
5962
  ] = await Promise.all([
5312
5963
  Promise.resolve().then(() => (init_run_import(), run_import_exports)),
5313
5964
  Promise.resolve().then(() => (init_importers(), importers_exports))
@@ -5323,14 +5974,15 @@ function createProgram() {
5323
5974
  return;
5324
5975
  }
5325
5976
  try {
5326
- await handleRunImport2({
5977
+ const imported = await handleRunImport2({
5327
5978
  file,
5328
5979
  ...format !== void 0 ? { format } : {},
5329
5980
  ...options.key !== void 0 ? { key: options.key } : {},
5330
5981
  ...options.create ? { create: true } : {}
5331
5982
  });
5983
+ if (options.failOnMissingAttachments && imported.missingAttachments.length > 0) process.exitCode = 1;
5332
5984
  } catch (err) {
5333
- if (err instanceof UnknownFormatError2 || err instanceof XmlParseError2 || err instanceof JsonReportError2) {
5985
+ if (err instanceof UnknownFormatError2 || err instanceof XmlParseError2 || err instanceof JsonReportError2 || err instanceof AllureReportError2) {
5334
5986
  process.stderr.write(err.message + "\n");
5335
5987
  process.exit(2);
5336
5988
  return;
@@ -5409,10 +6061,10 @@ function createProgram() {
5409
6061
  const { handleLogin: handleLogin2, reportLoginFailure: reportLoginFailure2 } = await Promise.resolve().then(() => (init_login(), login_exports));
5410
6062
  try {
5411
6063
  const token = options.token ?? await readTokenFromStdin();
5412
- const { path: path15, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
6064
+ const { path: path17, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
5413
6065
  process.stdout.write(
5414
- verified ? `Logged in. Token checked against the API and saved to ${path15}
5415
- ` : `Token saved to ${path15} WITHOUT being checked (--skip-verify).
6066
+ verified ? `Logged in. Token checked against the API and saved to ${path17}
6067
+ ` : `Token saved to ${path17} WITHOUT being checked (--skip-verify).
5416
6068
  `
5417
6069
  );
5418
6070
  } catch (err) {
@@ -5429,9 +6081,9 @@ function createProgram() {
5429
6081
  const verbose = command.optsWithGlobals().verbose === true;
5430
6082
  const { handleLogout: handleLogout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
5431
6083
  try {
5432
- const { removed, path: path15 } = handleLogout2();
6084
+ const { removed, path: path17 } = handleLogout2();
5433
6085
  process.stdout.write(
5434
- removed ? `Logged out. Removed ${path15}
6086
+ removed ? `Logged out. Removed ${path17}
5435
6087
  ` : "Not logged in \u2014 nothing to remove.\n"
5436
6088
  );
5437
6089
  } catch (err) {
@@ -5560,7 +6212,7 @@ function isMainModule() {
5560
6212
  const entry = process.argv[1];
5561
6213
  if (entry === void 0) return false;
5562
6214
  try {
5563
- return (0, import_node_fs5.realpathSync)(entry) === (0, import_node_fs5.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
6215
+ return (0, import_node_fs6.realpathSync)(entry) === (0, import_node_fs6.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
5564
6216
  } catch {
5565
6217
  return false;
5566
6218
  }