@plune-ai/cli 0.16.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
  });
@@ -2456,11 +2529,150 @@ var init_fallback = __esm({
2456
2529
  }
2457
2530
  });
2458
2531
 
2532
+ // src/reporter-core/failure-detail.ts
2533
+ function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
2534
+ const error = chosen(attempt);
2535
+ const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
2536
+ const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
2537
+ const steps = chainOf(attempt.steps);
2538
+ const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
2539
+ const artifacts = attempt.attachments.filter((a) => a.path !== void 0 && !a.name.startsWith("_") && a.contentType !== METADATA_TYPE).map(({ name, contentType }) => ({ name: fileNameOf(name), ...contentType ? { contentType } : {} })).filter((a) => a.name !== "");
2540
+ const ciUrl = webLink(attempt.buildHref);
2541
+ const failure = {
2542
+ ...headline !== void 0 ? { headline } : {},
2543
+ ...steps.length > 0 ? { steps } : {},
2544
+ ...location !== void 0 ? { location } : {},
2545
+ ...artifacts.length > 0 ? { artifacts } : {},
2546
+ ...ciUrl !== void 0 ? { ciUrl } : {}
2547
+ };
2548
+ return Object.keys(failure).length === 0 ? void 0 : failure;
2549
+ }
2550
+ function repoRootOf(dir, stopAt) {
2551
+ for (let here = (0, import_node_path2.resolve)(dir); ; here = (0, import_node_path2.dirname)(here)) {
2552
+ if ((0, import_node_fs3.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
2553
+ if (here === stopAt || (0, import_node_path2.dirname)(here) === here) return void 0;
2554
+ }
2555
+ }
2556
+ function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
2557
+ const text2 = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
2558
+ return cut(machineless(text2, attempt.repoRoot, home));
2559
+ }
2560
+ function filesOf(attempt) {
2561
+ return attempt.attachments.filter((a) => a.path !== void 0 || a.body !== void 0 && isTextType(a.contentType));
2562
+ }
2563
+ function machineless(raw, repoRoot, home) {
2564
+ let text2 = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
2565
+ const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
2566
+ if (root !== void 0) text2 = text2.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
2567
+ const own = pathIn(home);
2568
+ if (own !== void 0) text2 = text2.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
2569
+ if (repoRoot === void 0) text2 = text2.replace(ANY_HOME, "~");
2570
+ return text2;
2571
+ }
2572
+ function pathIn(path17) {
2573
+ const parts = path17.split(/[\\/]+/);
2574
+ while (parts.length > 1 && parts.at(-1) === "") parts.pop();
2575
+ if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
2576
+ const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
2577
+ const escaped = /^\/(?!\/)/.test(path17) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
2578
+ const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
2579
+ return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
2580
+ }
2581
+ function cut(text2) {
2582
+ if (jsonBytes(text2) <= TEXT_LIMIT) return text2;
2583
+ const lines = text2.split("\n");
2584
+ const size = lines.map((line) => jsonBytes(line) + 2);
2585
+ const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
2586
+ let used = 0;
2587
+ let head = 0;
2588
+ let tail = lines.length;
2589
+ while (head < tail && used + size[head] <= budget / 2) used += size[head++];
2590
+ while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
2591
+ return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
2592
+ }
2593
+ function chosen({ status, errors }) {
2594
+ if (status === "timedOut") {
2595
+ const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
2596
+ if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
2597
+ }
2598
+ return errors[0];
2599
+ }
2600
+ function chainOf(steps) {
2601
+ const chain = [];
2602
+ for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
2603
+ chain.push(failed.title);
2604
+ }
2605
+ return chain;
2606
+ }
2607
+ function placeOf(error, testFile) {
2608
+ const own = normal(testFile);
2609
+ let first;
2610
+ for (const line of (0, import_node_util.stripVTControlCharacters)(error.text).split("\n")) {
2611
+ if (line.length > FRAME_MAX) continue;
2612
+ const frame = FRAME.exec(line);
2613
+ if (frame === null) continue;
2614
+ const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
2615
+ if (normal(place.file) === own) return place;
2616
+ if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
2617
+ }
2618
+ return error.location ?? first;
2619
+ }
2620
+ function fileOf(raw) {
2621
+ if (!raw.startsWith("file://")) return raw;
2622
+ let path17 = raw.slice("file://".length);
2623
+ try {
2624
+ path17 = decodeURIComponent(path17);
2625
+ } catch {
2626
+ }
2627
+ return /^\/[A-Za-z]:\//.test(path17) ? path17.slice(1) : path17;
2628
+ }
2629
+ function fromRoot(place, root) {
2630
+ if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
2631
+ const base = normal(root).replace(/\/+$/, "");
2632
+ const file = normal(place.file);
2633
+ if (!file.startsWith(`${base}/`)) return void 0;
2634
+ const relative3 = file.slice(base.length + 1);
2635
+ if (!REPO_RELATIVE.test(relative3)) return void 0;
2636
+ const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
2637
+ return column === void 0 ? { file: relative3, line: place.line } : { file: relative3, line: place.line, column };
2638
+ }
2639
+ function webLink(href) {
2640
+ return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
2641
+ }
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;
2643
+ var init_failure_detail = __esm({
2644
+ "src/reporter-core/failure-detail.ts"() {
2645
+ "use strict";
2646
+ init_cjs_shims();
2647
+ import_node_fs3 = require("fs");
2648
+ import_node_os2 = require("os");
2649
+ import_node_path2 = require("path");
2650
+ import_node_util = require("util");
2651
+ printable = (text2) => text2.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
2652
+ mediaTypeOf = (contentType) => contentType?.split(";")[0]?.trim().toLowerCase() || void 0;
2653
+ TEXT_TYPES = /* @__PURE__ */ new Set(["application/json", "text/plain"]);
2654
+ isTextType = (contentType) => TEXT_TYPES.has(mediaTypeOf(contentType) ?? "");
2655
+ PATH_START = "(?<![\\w.~%/\\\\-])";
2656
+ TEXT_LIMIT = 524288;
2657
+ HEADLINE_LIMIT = 8 * 1024;
2658
+ jsonBytes = (text2) => Buffer.byteLength(JSON.stringify(text2)) - 2;
2659
+ ANY_HOME = /(?<=^|[\s'"`(=,[])(?:(?:file|webpack|webpack-internal):(?:\\*\/){2,3})?(?:\\*\/home\\*\/|\\*\/Users\\*\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
2660
+ fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
2661
+ omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
2662
+ METADATA_TYPE = "application/plune.metadata+json";
2663
+ REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
2664
+ FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
2665
+ FRAME_MAX = 8192;
2666
+ firstLine = (text2) => (0, import_node_util.stripVTControlCharacters)(text2).split("\n").map((line) => line.trim()).find((line) => line !== "");
2667
+ normal = (path17) => import_node_path2.posix.normalize(path17.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
2668
+ }
2669
+ });
2670
+
2459
2671
  // src/reporter-core/session.ts
2460
2672
  function nameOf(file) {
2461
- const own = file.name.split(/[\\/]/).pop()?.trim() || (0, import_node_path2.basename)(file.path);
2462
- const fileExt = (0, import_node_path2.extname)(file.path);
2463
- const ownExt = (0, import_node_path2.extname)(own);
2673
+ const own = file.name.split(/[\\/]/).pop()?.trim() || (file.path === void 0 ? "" : (0, import_node_path3.basename)(file.path));
2674
+ const fileExt = file.path === void 0 ? "" : (0, import_node_path3.extname)(file.path);
2675
+ const ownExt = (0, import_node_path3.extname)(own);
2464
2676
  const typed = ownExt !== "" && (ownExt.toLowerCase() === fileExt.toLowerCase() || TYPE_BY_EXTENSION[ownExt.toLowerCase()] !== void 0);
2465
2677
  const ext = typed ? ownExt : fileExt;
2466
2678
  let stem = typed ? own.slice(0, -ownExt.length) : own;
@@ -2470,6 +2682,41 @@ function nameOf(file) {
2470
2682
  }
2471
2683
  return `${stem}${ext}`;
2472
2684
  }
2685
+ function kindOf(type) {
2686
+ if (type.startsWith("image/")) return "screenshot";
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 };
2694
+ }
2695
+ function textTypeOf(contentType, type) {
2696
+ const charset = /;\s*charset\s*=\s*"?([^\s";]+)"?/i.exec(contentType)?.[1];
2697
+ return charset?.toLowerCase() === "utf-8" ? `${type}; charset=utf-8` : type;
2698
+ }
2699
+ function textRefusal(bytes) {
2700
+ if (bytes.length > TEXT_BYTES) return "textSize";
2701
+ if (bytes.length === 0) return "empty";
2702
+ if (bytes.includes(0)) return "binary";
2703
+ try {
2704
+ UTF8.decode(bytes);
2705
+ } catch {
2706
+ return "encoding";
2707
+ }
2708
+ return void 0;
2709
+ }
2710
+ function onDisk(result) {
2711
+ if (!("files" in result) || result.files === void 0) return result;
2712
+ const { files, ...rest } = result;
2713
+ const kept = files.filter((f) => f.path !== void 0).map(({ body: _body, ...file }) => file);
2714
+ return kept.length === 0 ? rest : { ...rest, files: kept };
2715
+ }
2716
+ function tally(counts, noun) {
2717
+ if (counts.uploaded + counts.skipped + counts.failed === 0) return void 0;
2718
+ return `${counts.uploaded} ${noun}(s) uploaded` + (counts.skipped > 0 ? `, ${counts.skipped} skipped` : "") + (counts.failed > 0 ? `, ${counts.failed} failed to upload` : "");
2719
+ }
2473
2720
  function boundKey(key) {
2474
2721
  if (key.value.length <= KEY_MAX) return key;
2475
2722
  const digest = (0, import_node_crypto2.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
@@ -2516,7 +2763,7 @@ function warnIfDropped(sent, run, log) {
2516
2763
  `plune: this deployment stored none of ${names.join(", ")} \u2014 it is older than the fields. The run is otherwise reported in full.`
2517
2764
  );
2518
2765
  }
2519
- async function startRun(passed, declared) {
2766
+ async function startRun(passed, declared2) {
2520
2767
  const env = readEnv();
2521
2768
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
2522
2769
  const cfg = { ...env.config, ...stated };
@@ -2543,7 +2790,9 @@ async function startRun(passed, declared) {
2543
2790
  created: 0,
2544
2791
  unoffered: 0,
2545
2792
  deferred: 0,
2546
- screenshots: { uploaded: 0, skipped: 0, failed: 0 }
2793
+ screenshots: { uploaded: 0, skipped: 0, failed: 0 },
2794
+ textFiles: { uploaded: 0, skipped: 0, failed: 0 },
2795
+ otherFiles: { uploaded: 0, skipped: 0, failed: 0 }
2547
2796
  };
2548
2797
  const resolved = /* @__PURE__ */ new Map();
2549
2798
  let buffer = [];
@@ -2554,6 +2803,13 @@ async function startRun(passed, declared) {
2554
2803
  let done = false;
2555
2804
  const shortened = /* @__PURE__ */ new Set();
2556
2805
  const skipsSaid = /* @__PURE__ */ new Set();
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();
2557
2813
  function bound(keys) {
2558
2814
  return keys.map((key) => {
2559
2815
  const fit = boundKey(key);
@@ -2561,14 +2817,14 @@ async function startRun(passed, declared) {
2561
2817
  shortened.add(key.value);
2562
2818
  if (shortened.size === 1) {
2563
2819
  log(
2564
- `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.`
2565
2821
  );
2566
2822
  }
2567
2823
  }
2568
2824
  return fit;
2569
2825
  });
2570
2826
  }
2571
- const expected = declared?.map(bound);
2827
+ const expected = declared2?.map(bound);
2572
2828
  if (offline) {
2573
2829
  log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
2574
2830
  }
@@ -2581,7 +2837,7 @@ async function startRun(passed, declared) {
2581
2837
  externalKey,
2582
2838
  session: sessionMarker,
2583
2839
  ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
2584
- results
2840
+ results: results.map(onDisk)
2585
2841
  });
2586
2842
  stats.deferred += results.length;
2587
2843
  }
@@ -2689,57 +2945,173 @@ async function startRun(passed, declared) {
2689
2945
  stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
2690
2946
  }
2691
2947
  }
2692
- function skip(name, why) {
2693
- stats.screenshots.skipped += 1;
2694
- if (skipsSaid.has(why)) return;
2695
- skipsSaid.add(why);
2696
- log(`plune: screenshot "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
2948
+ function skip(kind, name, why) {
2949
+ countsOf(kind).skipped += 1;
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.`);
2697
2974
  }
2698
- async function uploadOne(shot) {
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) {
2984
+ const { kind } = job;
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);
2699
2988
  let bytes;
2700
- try {
2701
- if ((await import_node_fs2.promises.stat(shot.path)).size > SCREENSHOT_BYTES) return skip(shot.name, "size");
2702
- bytes = await import_node_fs2.promises.readFile(shot.path);
2703
- } catch {
2704
- return skip(shot.name, "missing");
2989
+ if (job.body !== void 0) bytes = job.body;
2990
+ else {
2991
+ try {
2992
+ bytes = await import_node_fs4.promises.readFile(job.path);
2993
+ } catch {
2994
+ return skip(kind, job.name, "missing");
2995
+ }
2705
2996
  }
2997
+ const refusal = kind === "text" ? textRefusal(bytes) : void 0;
2998
+ if (refusal !== void 0) return skip(kind, job.name, refusal);
2706
2999
  const out = await client.upload(
2707
- `/v1/results/${encodeURIComponent(shot.id)}/files?name=${encodeURIComponent(shot.name)}`,
3000
+ `/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
2708
3001
  bytes,
2709
- shot.type
3002
+ job.type
2710
3003
  );
2711
- if (out.ok) {
2712
- stats.screenshots.uploaded += 1;
2713
- 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;
3020
+ }
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;
2714
3036
  }
2715
- stats.screenshots.failed += 1;
2716
- if (stats.screenshots.failed === 1) {
2717
- log(`plune: could not upload a screenshot (${out.detail || out.kind}) \u2014 its result is reported without it.`);
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);
2718
3078
  }
3079
+ return () => uploadToBucket(job, size, link);
2719
3080
  }
2720
3081
  async function upload(items, filesByKey) {
2721
3082
  if (filesByKey.size === 0 || !Array.isArray(items)) return;
2722
- const shots = [];
3083
+ const jobs = [];
2723
3084
  for (const item of items) {
2724
3085
  if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
2725
- let room = SCREENSHOTS_PER_RESULT;
2726
3086
  for (const file of filesByKey.get(item.resultKey) ?? []) {
2727
- const type = file.contentType?.split(";")[0]?.trim().toLowerCase() || TYPE_BY_EXTENSION[(0, import_node_path2.extname)(file.path).toLowerCase()];
2728
- if (!type?.startsWith("image/")) continue;
3087
+ const type = mediaTypeOf(file.contentType) ?? (file.path === void 0 ? void 0 : TYPE_BY_EXTENSION[(0, import_node_path3.extname)(file.path).toLowerCase()]);
3088
+ if (type === void 0 || type === METADATA_TYPE2) continue;
3089
+ const kind = kindOf(type);
3090
+ if (kind !== "text" && file.path === void 0) continue;
2729
3091
  const name = nameOf(file);
2730
- if (file.name.startsWith("_")) skip(name, "hidden");
2731
- else if (!SCREENSHOT_TYPES.has(type)) skip(name, "type");
2732
- else if (room === 0) skip(name, "limit");
3092
+ if (file.name.startsWith("_")) skip(kind, name, "hidden");
2733
3093
  else {
2734
- room -= 1;
2735
- shots.push({ id: item.id, name, type, path: file.path });
3094
+ jobs.push({
3095
+ id: item.id,
3096
+ kind,
3097
+ name,
3098
+ type: kind === "text" ? textTypeOf(file.contentType ?? "", type) : type,
3099
+ ...file.body !== void 0 ? { body: file.body } : {},
3100
+ ...file.path !== void 0 ? { path: file.path } : {}
3101
+ });
2736
3102
  }
2737
3103
  }
2738
3104
  }
2739
- let next = 0;
3105
+ const byResult = /* @__PURE__ */ new Map();
3106
+ for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
2740
3107
  await Promise.all(
2741
- Array.from({ length: Math.min(UPLOADS_AT_ONCE, shots.length) }, async () => {
2742
- while (next < shots.length) await uploadOne(shots[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);
2743
3115
  })
2744
3116
  );
2745
3117
  }
@@ -2817,11 +3189,20 @@ async function startRun(passed, declared) {
2817
3189
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
2818
3190
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
2819
3191
  if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
2820
- const shots = stats.screenshots;
2821
- if (shots.uploaded + shots.skipped + shots.failed > 0) {
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;
2822
3196
  parts.push(
2823
- `${shots.uploaded} screenshot(s) uploaded` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} failed to upload` : "")
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` : "")
2824
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`);
2825
3206
  }
2826
3207
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
2827
3208
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
@@ -2874,18 +3255,20 @@ async function startRun(passed, declared) {
2874
3255
  }
2875
3256
  };
2876
3257
  }
2877
- var import_node_crypto2, import_node_fs2, import_node_path2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TYPE_BY_EXTENSION, UPLOADS_AT_ONCE, NAME_MAX, SKIPPED, 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;
2878
3259
  var init_session = __esm({
2879
3260
  "src/reporter-core/session.ts"() {
2880
3261
  "use strict";
2881
3262
  init_cjs_shims();
2882
3263
  import_node_crypto2 = require("crypto");
2883
- import_node_fs2 = require("fs");
2884
- import_node_path2 = require("path");
3264
+ import_node_fs4 = require("fs");
3265
+ import_node_path3 = require("path");
3266
+ import_node_util2 = require("util");
2885
3267
  init_api_url();
2886
3268
  init_credentials();
2887
3269
  init_client();
2888
3270
  init_env2();
3271
+ init_failure_detail();
2889
3272
  init_fallback();
2890
3273
  RESOLVE_CHUNK = 500;
2891
3274
  BATCH_MAX = 500;
@@ -2898,12 +3281,16 @@ var init_session = __esm({
2898
3281
  SCREENSHOT_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
2899
3282
  SCREENSHOT_BYTES = 2 * 1024 * 1024;
2900
3283
  SCREENSHOTS_PER_RESULT = 20;
3284
+ TEXT_BYTES = 512 * 1024;
3285
+ TEXTS_PER_RESULT = 10;
3286
+ UTF8 = new import_node_util2.TextDecoder("utf-8", { fatal: true });
2901
3287
  TYPE_BY_EXTENSION = {
2902
3288
  ".png": "image/png",
2903
3289
  ".jpg": "image/jpeg",
2904
3290
  ".jpeg": "image/jpeg",
2905
3291
  ".webp": "image/webp"
2906
3292
  };
3293
+ METADATA_TYPE2 = "application/plune.metadata+json";
2907
3294
  UPLOADS_AT_ONCE = 4;
2908
3295
  NAME_MAX = 200;
2909
3296
  SKIPPED = {
@@ -2911,8 +3298,22 @@ var init_session = __esm({
2911
3298
  type: "Plune takes PNG, JPEG and WebP",
2912
3299
  limit: `a result holds at most ${SCREENSHOTS_PER_RESULT}`,
2913
3300
  missing: "the file cannot be read on this machine",
2914
- size: "Plune takes a file of at most 2 MB"
3301
+ size: "Plune takes a file of at most 2 MB",
3302
+ textLimit: `a result holds at most ${TEXTS_PER_RESULT} text files`,
3303
+ textSize: "Plune takes a text file of at most 512 KiB",
3304
+ empty: "the file is empty",
3305
+ binary: "the file holds a NUL byte, and Plune takes UTF-8 text without one",
3306
+ encoding: "Plune takes UTF-8 text only",
3307
+ noStorage: "no storage bucket is connected to this project"
2915
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.";
3310
+ KINDS = {
3311
+ screenshot: { label: "screenshot", bytes: SCREENSHOT_BYTES, room: SCREENSHOTS_PER_RESULT, limit: "limit", size: "size" },
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" }
3315
+ };
3316
+ codeOf2 = (out) => out.code ?? (out.status === null ? "network" : `http_${out.status}`);
2916
3317
  BATCH_BYTES = 8 * 1024 * 1024;
2917
3318
  ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ results: [] }));
2918
3319
  }
@@ -3003,21 +3404,21 @@ async function handleRunDelete(id, options = {}) {
3003
3404
  );
3004
3405
  }
3005
3406
  write(`Run ${id} deleted, with its results and the review-queue entries it produced.`);
3006
- 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.");
3007
3408
  }
3008
3409
  async function handleRunExec(options) {
3009
3410
  const [command, ...args] = options.argv;
3010
3411
  if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
3011
3412
  const run = await handleRunStart(options);
3012
3413
  const spawnImpl = options.spawnImpl ?? import_node_child_process.spawn;
3013
- const code = await new Promise((resolve8) => {
3414
+ const code = await new Promise((resolve9) => {
3014
3415
  const child = spawnImpl(command, args, {
3015
3416
  stdio: "inherit",
3016
3417
  shell: process.platform === "win32",
3017
3418
  env: { ...process.env, PLUNE_RUN: run.externalKey, PLUNE_PROCEED: "1" }
3018
3419
  });
3019
- child.on("close", (exitCode) => resolve8(exitCode ?? 1));
3020
- child.on("error", () => resolve8(1));
3420
+ child.on("close", (exitCode) => resolve9(exitCode ?? 1));
3421
+ child.on("error", () => resolve9(1));
3021
3422
  });
3022
3423
  await handleRunFinish(run.id, options);
3023
3424
  return code;
@@ -3148,6 +3549,346 @@ var init_result_key = __esm({
3148
3549
  }
3149
3550
  });
3150
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
+
3151
3892
  // src/importers/xml.ts
3152
3893
  function decode(raw) {
3153
3894
  if (!raw.includes("&")) return raw;
@@ -3212,9 +3953,9 @@ function parseXml(source, file) {
3212
3953
  if (end === -1) throw new XmlParseError(file, line, "a closing tag is never closed");
3213
3954
  const name2 = source.slice(i + 2, end).trim();
3214
3955
  const open = top();
3215
- if (open === root) throw new XmlParseError(file, line, `</${name2}> closes nothing`);
3956
+ if (open === root) throw new XmlParseError(file, line, `</${printable(name2)}> closes nothing`);
3216
3957
  if (open.name !== name2) {
3217
- 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}`);
3218
3959
  }
3219
3960
  stack.pop();
3220
3961
  advance(end + 1);
@@ -3230,7 +3971,7 @@ function parseXml(source, file) {
3230
3971
  let selfClosing = false;
3231
3972
  for (; ; ) {
3232
3973
  while (j < source.length && SPACE.test(source[j])) j += 1;
3233
- 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`);
3234
3975
  if (source[j] === "/" && source[j + 1] === ">") {
3235
3976
  selfClosing = true;
3236
3977
  j += 2;
@@ -3243,19 +3984,19 @@ function parseXml(source, file) {
3243
3984
  const attrStart = j;
3244
3985
  while (j < source.length && !ATTR_NAME_END.test(source[j])) j += 1;
3245
3986
  const attr = source.slice(attrStart, j);
3246
- 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`);
3247
3988
  while (j < source.length && SPACE.test(source[j])) j += 1;
3248
3989
  if (source[j] !== "=") {
3249
- 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`);
3250
3991
  }
3251
3992
  j += 1;
3252
3993
  while (j < source.length && SPACE.test(source[j])) j += 1;
3253
3994
  const quote = source[j];
3254
3995
  if (quote !== '"' && quote !== "'") {
3255
- 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`);
3256
3997
  }
3257
3998
  const close = source.indexOf(quote, j + 1);
3258
- 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`);
3259
4000
  el.attrs[attr] = decode(source.slice(j + 1, close));
3260
4001
  j = close + 1;
3261
4002
  }
@@ -3265,7 +4006,7 @@ function parseXml(source, file) {
3265
4006
  }
3266
4007
  if (stack.length > 1) {
3267
4008
  const open = top();
3268
- throw new XmlParseError(file, open.line, `<${open.name}> is never closed`);
4009
+ throw new XmlParseError(file, open.line, `<${printable(open.name)}> is never closed`);
3269
4010
  }
3270
4011
  if (root.children.length === 0) throw new XmlParseError(file, 1, "no elements \u2014 is this an XML report?");
3271
4012
  return root;
@@ -3275,6 +4016,7 @@ var init_xml = __esm({
3275
4016
  "src/importers/xml.ts"() {
3276
4017
  "use strict";
3277
4018
  init_cjs_shims();
4019
+ init_failure_detail();
3278
4020
  XmlParseError = class extends Error {
3279
4021
  constructor(file, line, detail) {
3280
4022
  super(`${file}:${line} \u2014 ${detail}`);
@@ -3321,7 +4063,7 @@ function playwrightOf(classname, name) {
3321
4063
  if (!SPEC_FILE.test(classname)) return void 0;
3322
4064
  return { file: classname.replaceAll("\\", "/"), titles: name.split(" \u203A ") };
3323
4065
  }
3324
- function keyOf(classname, name, playwright) {
4066
+ function keyOf2(classname, name, playwright) {
3325
4067
  if (playwright !== void 0) return { kind: "path-title", value: [playwright.file, ...playwright.titles].join("#") };
3326
4068
  return { kind: "path-title", value: classname === "" ? name : `${classname}#${name}` };
3327
4069
  }
@@ -3354,8 +4096,9 @@ function executionOf(test, suite) {
3354
4096
  retry: 0
3355
4097
  };
3356
4098
  }
3357
- function readJUnit(source, file) {
4099
+ function readJUnit(source, file, missing = [], notes = []) {
3358
4100
  const doc = parseXml(source, file);
4101
+ const { lexical, real: folder } = reportFolders(file);
3359
4102
  const found = [];
3360
4103
  locate(doc, void 0, found);
3361
4104
  return found.map(({ test, suite }) => {
@@ -3363,180 +4106,50 @@ function readJUnit(source, file) {
3363
4106
  const classname = test.attrs["classname"] ?? test.attrs["class"] ?? "";
3364
4107
  const { status, detail } = outcomeOf(test);
3365
4108
  const playwright = playwrightOf(classname, name);
3366
- const key = keyOf(classname, name, playwright);
3367
- 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];
3368
4111
  const specRef = specRefOf(test, suite, classname);
3369
4112
  const errorContext = errorTextOf(detail);
3370
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 });
3371
4117
  return {
3372
- resultKey: resultKey(key.value, 0),
4118
+ resultKey: rkey,
3373
4119
  ...stated !== void 0 ? { testCaseId: stated } : {},
3374
4120
  keys: [key],
3375
- source: SOURCE,
4121
+ source: SOURCE2,
3376
4122
  rawStatus: status,
3377
- title: titleOf(classname, name),
4123
+ title,
3378
4124
  ...specRef !== void 0 ? { specRef } : {},
3379
4125
  ...execution !== void 0 ? { execution } : {},
3380
- ...errorContext !== "" ? { errorContext } : {}
4126
+ ...errorContext !== "" ? { errorContext } : {},
4127
+ ...files.length > 0 ? { files } : {}
3381
4128
  };
3382
4129
  });
3383
4130
  }
3384
4131
  function looksLikeJUnit(source) {
3385
4132
  return /<\s*testsuites?\b/.test(source.slice(0, 4096));
3386
4133
  }
3387
- var SOURCE, TOKEN, SPEC_FILE;
4134
+ var SOURCE2, TOKEN2, SPEC_FILE;
3388
4135
  var init_junit = __esm({
3389
4136
  "src/importers/junit.ts"() {
3390
4137
  "use strict";
3391
4138
  init_cjs_shims();
3392
4139
  init_result_key();
4140
+ init_attachments();
3393
4141
  init_xml();
3394
- SOURCE = "junit";
3395
- TOKEN = /@P([A-Za-z0-9_-]+)/;
4142
+ SOURCE2 = "junit";
4143
+ TOKEN2 = /@P([A-Za-z0-9_-]+)/;
3396
4144
  SPEC_FILE = /\.(?:spec|test)\.[cm]?[jt]sx?$/;
3397
4145
  }
3398
4146
  });
3399
4147
 
3400
- // src/reporter-core/failure-detail.ts
3401
- function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
3402
- const error = chosen(attempt);
3403
- const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
3404
- const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
3405
- const steps = chainOf(attempt.steps);
3406
- const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
3407
- const artifacts = attempt.attachments.filter((a) => a.path !== void 0 && !a.name.startsWith("_") && a.contentType !== METADATA_TYPE).map(({ name, contentType }) => ({ name: fileNameOf(name), ...contentType ? { contentType } : {} })).filter((a) => a.name !== "");
3408
- const ciUrl = webLink(attempt.buildHref);
3409
- const failure = {
3410
- ...headline !== void 0 ? { headline } : {},
3411
- ...steps.length > 0 ? { steps } : {},
3412
- ...location !== void 0 ? { location } : {},
3413
- ...artifacts.length > 0 ? { artifacts } : {},
3414
- ...ciUrl !== void 0 ? { ciUrl } : {}
3415
- };
3416
- return Object.keys(failure).length === 0 ? void 0 : failure;
3417
- }
3418
- function repoRootOf(dir, stopAt) {
3419
- for (let here = (0, import_node_path3.resolve)(dir); ; here = (0, import_node_path3.dirname)(here)) {
3420
- if ((0, import_node_fs3.existsSync)((0, import_node_path3.join)(here, ".git"))) return here;
3421
- if (here === stopAt || (0, import_node_path3.dirname)(here) === here) return void 0;
3422
- }
3423
- }
3424
- function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
3425
- const text2 = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
3426
- return cut(machineless(text2, attempt.repoRoot, home));
3427
- }
3428
- function filesOf(attempt) {
3429
- return attempt.attachments.filter((a) => a.path !== void 0);
3430
- }
3431
- function machineless(raw, repoRoot, home) {
3432
- let text2 = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
3433
- const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
3434
- if (root !== void 0) text2 = text2.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
3435
- const own = pathIn(home);
3436
- if (own !== void 0) text2 = text2.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
3437
- if (repoRoot === void 0) text2 = text2.replace(ANY_HOME, "~");
3438
- return text2;
3439
- }
3440
- function pathIn(path15) {
3441
- const parts = path15.split(/[\\/]+/);
3442
- while (parts.length > 1 && parts.at(-1) === "") parts.pop();
3443
- if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
3444
- const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
3445
- const escaped = /^\/(?!\/)/.test(path15) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
3446
- const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
3447
- return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
3448
- }
3449
- function cut(text2) {
3450
- if (jsonBytes(text2) <= TEXT_LIMIT) return text2;
3451
- const lines = text2.split("\n");
3452
- const size = lines.map((line) => jsonBytes(line) + 2);
3453
- const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
3454
- let used = 0;
3455
- let head = 0;
3456
- let tail = lines.length;
3457
- while (head < tail && used + size[head] <= budget / 2) used += size[head++];
3458
- while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
3459
- return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
3460
- }
3461
- function chosen({ status, errors }) {
3462
- if (status === "timedOut") {
3463
- const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
3464
- if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
3465
- }
3466
- return errors[0];
3467
- }
3468
- function chainOf(steps) {
3469
- const chain = [];
3470
- for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
3471
- chain.push(failed.title);
3472
- }
3473
- return chain;
3474
- }
3475
- function placeOf(error, testFile) {
3476
- const own = normal(testFile);
3477
- let first;
3478
- for (const line of (0, import_node_util.stripVTControlCharacters)(error.text).split("\n")) {
3479
- if (line.length > FRAME_MAX) continue;
3480
- const frame = FRAME.exec(line);
3481
- if (frame === null) continue;
3482
- const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
3483
- if (normal(place.file) === own) return place;
3484
- if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
3485
- }
3486
- return error.location ?? first;
3487
- }
3488
- function fileOf(raw) {
3489
- if (!raw.startsWith("file://")) return raw;
3490
- let path15 = raw.slice("file://".length);
3491
- try {
3492
- path15 = decodeURIComponent(path15);
3493
- } catch {
3494
- }
3495
- return /^\/[A-Za-z]:\//.test(path15) ? path15.slice(1) : path15;
3496
- }
3497
- function fromRoot(place, root) {
3498
- if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
3499
- const base = normal(root).replace(/\/+$/, "");
3500
- const file = normal(place.file);
3501
- if (!file.startsWith(`${base}/`)) return void 0;
3502
- const relative2 = file.slice(base.length + 1);
3503
- if (!REPO_RELATIVE.test(relative2)) return void 0;
3504
- const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
3505
- return column === void 0 ? { file: relative2, line: place.line } : { file: relative2, line: place.line, column };
3506
- }
3507
- function webLink(href) {
3508
- return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
3509
- }
3510
- var import_node_fs3, import_node_os2, import_node_path3, import_node_util, PATH_START, TEXT_LIMIT, HEADLINE_LIMIT, jsonBytes, ANY_HOME, fileNameOf, omitted, METADATA_TYPE, REPO_RELATIVE, FRAME, FRAME_MAX, firstLine, normal;
3511
- var init_failure_detail = __esm({
3512
- "src/reporter-core/failure-detail.ts"() {
3513
- "use strict";
3514
- init_cjs_shims();
3515
- import_node_fs3 = require("fs");
3516
- import_node_os2 = require("os");
3517
- import_node_path3 = require("path");
3518
- import_node_util = require("util");
3519
- PATH_START = "(?<![\\w.~%/\\\\-])";
3520
- TEXT_LIMIT = 524288;
3521
- HEADLINE_LIMIT = 8 * 1024;
3522
- jsonBytes = (text2) => Buffer.byteLength(JSON.stringify(text2)) - 2;
3523
- ANY_HOME = /(?<=^|[\s'"`(=,[])(?:(?:file|webpack|webpack-internal):(?:\\*\/){2,3})?(?:\\*\/home\\*\/|\\*\/Users\\*\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
3524
- fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
3525
- omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
3526
- METADATA_TYPE = "application/plune.metadata+json";
3527
- REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
3528
- FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
3529
- FRAME_MAX = 8192;
3530
- firstLine = (text2) => (0, import_node_util.stripVTControlCharacters)(text2).split("\n").map((line) => line.trim()).find((line) => line !== "");
3531
- normal = (path15) => import_node_path3.posix.normalize(path15.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
3532
- }
3533
- });
3534
-
3535
4148
  // src/importers/playwright-json.ts
3536
4149
  function statedIdOf(test, title) {
3537
4150
  const annotation = test.annotations?.find((a) => a.type === "PluneId")?.description;
3538
4151
  if (annotation !== void 0 && annotation !== "") return annotation;
3539
- return TOKEN2.exec(title)?.[1];
4152
+ return TOKEN3.exec(title)?.[1];
3540
4153
  }
3541
4154
  function attemptOf(result, file, ctx) {
3542
4155
  const steps = (list2) => (list2 ?? []).map((s) => ({ title: s.title ?? "", failed: s.error !== void 0, steps: steps(s.steps) }));
@@ -3547,10 +4160,13 @@ function attemptOf(result, file, ctx) {
3547
4160
  ...typeof at?.file === "string" && typeof at.line === "number" ? { location: { file: at.file, line: at.line, ...typeof at.column === "number" ? { column: at.column } : {} } } : {}
3548
4161
  })),
3549
4162
  steps: steps(result.steps),
3550
- attachments: (result.attachments ?? []).map(({ name, contentType, path: path15 }) => ({
4163
+ attachments: (result.attachments ?? []).map(({ name, contentType, path: path17, body }) => ({
3551
4164
  name: name ?? "",
3552
4165
  ...contentType !== void 0 ? { contentType } : {},
3553
- ...path15 !== void 0 ? { path: path15 } : {}
4166
+ ...path17 !== void 0 ? { path: path17 } : {},
4167
+ // Decoded only where the platform would take it (plune-ai/plune#928): a report of screenshots kept as
4168
+ // bodies is megabytes of base64 that nothing here will read.
4169
+ ...typeof body === "string" && isTextType(contentType) ? { body: Buffer.from(body, "base64") } : {}
3554
4170
  })),
3555
4171
  ...ctx.buildHref !== void 0 ? { buildHref: ctx.buildHref } : {},
3556
4172
  // Spec files are posix and relative to the root dir; the core reads either slash.
@@ -3580,7 +4196,7 @@ function pendingFrom(spec, test, result, file, titles, ctx) {
3580
4196
  keys,
3581
4197
  title: titles.join(" \u203A "),
3582
4198
  specRef: spec.line !== void 0 ? `${file}:${spec.line}` : file,
3583
- source: SOURCE2,
4199
+ source: SOURCE3,
3584
4200
  // Untouched, as everywhere: `timedOut` means what the project's map says it means, not what
3585
4201
  // this file decided.
3586
4202
  rawStatus: result.status ?? "unknown",
@@ -3615,7 +4231,7 @@ function readPlaywrightJson(source, file) {
3615
4231
  try {
3616
4232
  parsed = JSON.parse(source);
3617
4233
  } catch (err) {
3618
- 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))}`);
3619
4235
  }
3620
4236
  if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.suites)) {
3621
4237
  throw new JsonReportError(file, 'no "suites" array \u2014 is this a Playwright JSON report?');
@@ -3623,7 +4239,7 @@ function readPlaywrightJson(source, file) {
3623
4239
  const { config, suites } = parsed;
3624
4240
  const rootDir = typeof config?.rootDir === "string" ? config.rootDir : void 0;
3625
4241
  const buildHref = config?.metadata?.ci?.buildHref;
3626
- 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;
3627
4243
  const ctx = {
3628
4244
  ...rootDir !== void 0 ? { rootDir } : {},
3629
4245
  ...repoRoot !== void 0 ? { repoRoot } : {},
@@ -3642,20 +4258,20 @@ function readPlaywrightJson(source, file) {
3642
4258
  function looksLikePlaywrightJson(source) {
3643
4259
  return /^\s*\{/.test(source) && source.includes('"suites"');
3644
4260
  }
3645
- var import_node_fs4, SOURCE2, TOKEN2, NOT_FAILED, JsonReportError;
4261
+ var import_node_fs5, SOURCE3, TOKEN3, NOT_FAILED, JsonReportError;
3646
4262
  var init_playwright_json = __esm({
3647
4263
  "src/importers/playwright-json.ts"() {
3648
4264
  "use strict";
3649
4265
  init_cjs_shims();
3650
- import_node_fs4 = require("fs");
4266
+ import_node_fs5 = require("fs");
3651
4267
  init_failure_detail();
3652
4268
  init_result_key();
3653
- SOURCE2 = "playwright";
3654
- TOKEN2 = /@P([A-Za-z0-9_-]+)/;
4269
+ SOURCE3 = "playwright";
4270
+ TOKEN3 = /@P([A-Za-z0-9_-]+)/;
3655
4271
  NOT_FAILED = /* @__PURE__ */ new Set(["passed", "skipped"]);
3656
4272
  JsonReportError = class extends Error {
3657
4273
  constructor(file, detail) {
3658
- super(`${file} \u2014 ${detail}`);
4274
+ super(`${printable(file)} \u2014 ${detail}`);
3659
4275
  this.file = file;
3660
4276
  this.name = "JsonReportError";
3661
4277
  }
@@ -3667,11 +4283,14 @@ var init_playwright_json = __esm({
3667
4283
  // src/importers/index.ts
3668
4284
  var importers_exports = {};
3669
4285
  __export(importers_exports, {
4286
+ AllureReportError: () => AllureReportError,
3670
4287
  IMPORT_FORMATS: () => IMPORT_FORMATS,
3671
4288
  JsonReportError: () => JsonReportError,
3672
4289
  UnknownFormatError: () => UnknownFormatError,
3673
4290
  XmlParseError: () => XmlParseError,
3674
4291
  detectFormat: () => detectFormat,
4292
+ isFolder: () => isFolder,
4293
+ readFolder: () => readFolder,
3675
4294
  readReport: () => readReport
3676
4295
  });
3677
4296
  function detectFormat(source, file) {
@@ -3682,21 +4301,34 @@ function detectFormat(source, file) {
3682
4301
  'no <testsuite> and no Playwright "suites" \u2014 this is not a report we can read.'
3683
4302
  );
3684
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
+ }
3685
4312
  function readReport(source, file, format) {
3686
4313
  const chosen2 = format ?? detectFormat(source, file);
3687
- if (chosen2 === "junit") return { format: chosen2, results: readJUnit(source, file) };
3688
- 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 };
3689
4319
  }
3690
4320
  var IMPORT_FORMATS, UnknownFormatError;
3691
4321
  var init_importers = __esm({
3692
4322
  "src/importers/index.ts"() {
3693
4323
  "use strict";
3694
4324
  init_cjs_shims();
4325
+ init_allure();
3695
4326
  init_junit();
3696
4327
  init_playwright_json();
3697
4328
  init_xml();
3698
4329
  init_playwright_json();
3699
- IMPORT_FORMATS = ["junit", "playwright-json"];
4330
+ init_allure();
4331
+ IMPORT_FORMATS = ["junit", "playwright-json", "allure"];
3700
4332
  UnknownFormatError = class extends Error {
3701
4333
  constructor(file, detail) {
3702
4334
  super(`${file} \u2014 ${detail} Say which with --format ${IMPORT_FORMATS.join(" | ")}.`);
@@ -3714,16 +4346,25 @@ __export(run_import_exports, {
3714
4346
  async function handleRunImport(options) {
3715
4347
  const write = options.write ?? ((line) => void process.stdout.write(`${line}
3716
4348
  `));
3717
- let source;
3718
- try {
3719
- source = fs8.readFileSync(options.file, "utf8");
3720
- } catch {
3721
- 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);
3722
4360
  }
3723
- const { format, results, ciUrl, ci } = readReport(source, options.file, options.format);
4361
+ const { format, results, missing, notes, ciUrl, ci } = read;
3724
4362
  if (results.length === 0) {
3725
4363
  throw new RunCommandError(`No test cases in ${options.file} \u2014 it parsed, but there is nothing in it.`);
3726
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));
3727
4368
  const env = readEnv();
3728
4369
  const apiUrl = resolveApiUrl(options.apiUrl ?? env.config.apiUrl);
3729
4370
  const token = options.token ?? env.config.token ?? loadToken() ?? "";
@@ -3767,6 +4408,14 @@ async function handleRunImport(options) {
3767
4408
  ...stats.unofferedWhy !== void 0 ? { unofferedWhy: stats.unofferedWhy } : {},
3768
4409
  deferred: stats.deferred,
3769
4410
  screenshots: { ...stats.screenshots },
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
+ ),
3770
4419
  unlocatable: results.filter((r) => r.specRef === void 0).length,
3771
4420
  offering: options.create === true
3772
4421
  };
@@ -3785,17 +4434,11 @@ function describe(result, apiUrl) {
3785
4434
  // worked (#622).
3786
4435
  // `not delivered` always, zero included (#790): a number that only appears when it is bad is one
3787
4436
  // nobody learns to look for. The `[0-9]+ unmatched` the workflows read stays whole.
3788
- `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.`
3789
4438
  ];
3790
4439
  if (result.conflict > 0 || result.rejected > 0) {
3791
4440
  lines.push(`${result.conflict} conflicted and ${result.rejected} were refused.`);
3792
4441
  }
3793
- const shots = result.screenshots;
3794
- if (shots.uploaded + shots.skipped + shots.failed > 0) {
3795
- lines.push(
3796
- `${shots.uploaded} screenshot(s) uploaded to their results` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} could not be uploaded` : "") + "."
3797
- );
3798
- }
3799
4442
  if (result.deferred > 0) {
3800
4443
  lines.push(
3801
4444
  `${result.deferred} could not be sent and are in the fallback file \u2014 "plune run report" retries them.`
@@ -3823,16 +4466,22 @@ function describe(result, apiUrl) {
3823
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.`
3824
4467
  );
3825
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
+ }
3826
4474
  if (result.runId !== null) lines.push(runUrl2(apiUrl, result.runId));
3827
4475
  return lines;
3828
4476
  }
3829
- var fs8, NEXT;
4477
+ var fs10, NEXT;
3830
4478
  var init_run_import = __esm({
3831
4479
  "src/cli/commands/run-import.ts"() {
3832
4480
  "use strict";
3833
4481
  init_cjs_shims();
3834
- fs8 = __toESM(require("fs"), 1);
4482
+ fs10 = __toESM(require("fs"), 1);
3835
4483
  init_session();
4484
+ init_failure_detail();
3836
4485
  init_importers();
3837
4486
  init_run_lifecycle();
3838
4487
  init_api_url();
@@ -3859,10 +4508,10 @@ function isRunResult(v) {
3859
4508
  }
3860
4509
  function handleReport(opts = {}) {
3861
4510
  const cwd = opts.cwd ?? process.cwd();
3862
- const file = path9.join(cwd, ".plune", "last-run.json");
4511
+ const file = path11.join(cwd, ".plune", "last-run.json");
3863
4512
  let raw;
3864
4513
  try {
3865
- raw = fs9.readFileSync(file, "utf8");
4514
+ raw = fs11.readFileSync(file, "utf8");
3866
4515
  } catch {
3867
4516
  throw new ReportNotFoundError('No saved run found. Run "plune run" first.');
3868
4517
  }
@@ -3877,13 +4526,13 @@ function handleReport(opts = {}) {
3877
4526
  }
3878
4527
  return parsed;
3879
4528
  }
3880
- var fs9, path9, ReportNotFoundError;
4529
+ var fs11, path11, ReportNotFoundError;
3881
4530
  var init_report = __esm({
3882
4531
  "src/cli/commands/report.ts"() {
3883
4532
  "use strict";
3884
4533
  init_cjs_shims();
3885
- fs9 = __toESM(require("fs"), 1);
3886
- path9 = __toESM(require("path"), 1);
4534
+ fs11 = __toESM(require("fs"), 1);
4535
+ path11 = __toESM(require("path"), 1);
3887
4536
  ReportNotFoundError = class extends Error {
3888
4537
  code = "NO_SAVED_RUN";
3889
4538
  constructor(message) {
@@ -3957,7 +4606,7 @@ function isRunResult2(v) {
3957
4606
  function loadRunResult(file, label) {
3958
4607
  let raw;
3959
4608
  try {
3960
- raw = fs10.readFileSync(file, "utf8");
4609
+ raw = fs12.readFileSync(file, "utf8");
3961
4610
  } catch {
3962
4611
  throw new DiffInputError(`Cannot read ${label} run file: ${file}`);
3963
4612
  }
@@ -3977,12 +4626,12 @@ function handleDiff(opts) {
3977
4626
  const current = loadRunResult(opts.currentPath, "current");
3978
4627
  return diffRuns(baseline, current);
3979
4628
  }
3980
- var fs10, DiffInputError;
4629
+ var fs12, DiffInputError;
3981
4630
  var init_diff2 = __esm({
3982
4631
  "src/cli/commands/diff.ts"() {
3983
4632
  "use strict";
3984
4633
  init_cjs_shims();
3985
- fs10 = __toESM(require("fs"), 1);
4634
+ fs12 = __toESM(require("fs"), 1);
3986
4635
  init_diff();
3987
4636
  DiffInputError = class extends Error {
3988
4637
  code = "DIFF_INPUT";
@@ -4107,8 +4756,8 @@ async function runInitWizard(cwd) {
4107
4756
  process.exit(1);
4108
4757
  return;
4109
4758
  }
4110
- const outputPath = path10.join(cwd, "plune.yaml");
4111
- if (fs11.existsSync(outputPath)) {
4759
+ const outputPath = path12.join(cwd, "plune.yaml");
4760
+ if (fs13.existsSync(outputPath)) {
4112
4761
  const overwrite = await clack.confirm({
4113
4762
  message: "plune.yaml already exists. Overwrite?"
4114
4763
  });
@@ -4147,18 +4796,18 @@ async function runInitWizard(cwd) {
4147
4796
  `;
4148
4797
  const spin = clack.spinner();
4149
4798
  spin.start("Writing plune.yaml\u2026");
4150
- fs11.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
4799
+ fs13.writeFileSync(outputPath, header + "\n" + (0, import_yaml2.stringify)(config), "utf8");
4151
4800
  spin.stop("plune.yaml created!");
4152
4801
  clack.outro("plune.yaml is ready.");
4153
4802
  }
4154
- var clack, fs11, path10, import_yaml2;
4803
+ var clack, fs13, path12, import_yaml2;
4155
4804
  var init_wizard = __esm({
4156
4805
  "src/config/init/wizard.ts"() {
4157
4806
  "use strict";
4158
4807
  init_cjs_shims();
4159
4808
  clack = __toESM(require("@clack/prompts"), 1);
4160
- fs11 = __toESM(require("fs"), 1);
4161
- path10 = __toESM(require("path"), 1);
4809
+ fs13 = __toESM(require("fs"), 1);
4810
+ path12 = __toESM(require("path"), 1);
4162
4811
  import_yaml2 = require("yaml");
4163
4812
  init_errors();
4164
4813
  }
@@ -4166,21 +4815,21 @@ var init_wizard = __esm({
4166
4815
 
4167
4816
  // src/cli/templates/index.ts
4168
4817
  function writeTemplateFile(cwd, rel, content, force) {
4169
- const target = path11.join(cwd, rel);
4170
- if (!force && fs12.existsSync(target)) {
4818
+ const target = path13.join(cwd, rel);
4819
+ if (!force && fs14.existsSync(target)) {
4171
4820
  return "skipped";
4172
4821
  }
4173
- fs12.mkdirSync(path11.dirname(target), { recursive: true });
4174
- fs12.writeFileSync(target, content, "utf8");
4822
+ fs14.mkdirSync(path13.dirname(target), { recursive: true });
4823
+ fs14.writeFileSync(target, content, "utf8");
4175
4824
  return "written";
4176
4825
  }
4177
- 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;
4178
4827
  var init_templates = __esm({
4179
4828
  "src/cli/templates/index.ts"() {
4180
4829
  "use strict";
4181
4830
  init_cjs_shims();
4182
- fs12 = __toESM(require("fs"), 1);
4183
- path11 = __toESM(require("path"), 1);
4831
+ fs14 = __toESM(require("fs"), 1);
4832
+ path13 = __toESM(require("path"), 1);
4184
4833
  ENV_EXAMPLE_TPL = `# Copy this file to .env and replace the placeholders with your real keys.
4185
4834
  # .env is gitignored; .env.example is safe to commit.
4186
4835
  # Plune reads whichever key matches your configured provider.
@@ -4358,68 +5007,136 @@ var init_logout = __esm({
4358
5007
  }
4359
5008
  });
4360
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
+
4361
5075
  // src/types/run-result-schema.ts
4362
5076
  function parseRunResult(value2) {
4363
5077
  return runResultSchema.parse(value2);
4364
5078
  }
4365
- 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;
4366
5080
  var init_run_result_schema = __esm({
4367
5081
  "src/types/run-result-schema.ts"() {
4368
5082
  "use strict";
4369
5083
  init_cjs_shims();
4370
- import_zod2 = require("zod");
5084
+ import_zod3 = require("zod");
5085
+ init_results();
4371
5086
  RUN_RESULT_SCHEMA_VERSION = 1;
4372
- binaryVerdictSchema = import_zod2.z.object({
4373
- question: import_zod2.z.string().min(1),
4374
- 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()
4375
5090
  });
4376
- assertionResultRecordSchema = import_zod2.z.object({
4377
- type: import_zod2.z.string(),
4378
- passed: import_zod2.z.boolean(),
4379
- score: import_zod2.z.number().optional(),
4380
- reason: import_zod2.z.string().optional(),
4381
- 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()
4382
5097
  });
4383
- usageSchema = import_zod2.z.object({
4384
- input_tokens: import_zod2.z.number(),
4385
- output_tokens: import_zod2.z.number(),
4386
- 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()
4387
5102
  });
4388
- runErrorSchema = import_zod2.z.object({
4389
- code: import_zod2.z.string(),
4390
- message: import_zod2.z.string()
5103
+ runErrorSchema = import_zod3.z.object({
5104
+ code: import_zod3.z.string(),
5105
+ message: import_zod3.z.string()
4391
5106
  });
4392
- rowResultSchema = import_zod2.z.object({
4393
- vars: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.unknown()),
4394
- output: import_zod2.z.string().nullable(),
4395
- 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(),
4396
5111
  usage: usageSchema.optional(),
4397
- latency_ms: import_zod2.z.number().optional(),
5112
+ latency_ms: import_zod3.z.number().optional(),
4398
5113
  error: runErrorSchema.optional(),
4399
- 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()
4400
5117
  });
4401
- summarySchema = import_zod2.z.object({
4402
- total: import_zod2.z.number(),
4403
- passed: import_zod2.z.number(),
4404
- failed: import_zod2.z.number(),
4405
- errored: import_zod2.z.number(),
4406
- cost_usd: import_zod2.z.number(),
4407
- 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()
4408
5125
  });
4409
- evalResultSchema = import_zod2.z.object({
4410
- id: import_zod2.z.string(),
4411
- tags: import_zod2.z.array(import_zod2.z.string()),
4412
- rows: import_zod2.z.array(rowResultSchema),
4413
- 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()
4414
5131
  });
4415
- runResultSchema = import_zod2.z.object({
4416
- schemaVersion: import_zod2.z.literal(RUN_RESULT_SCHEMA_VERSION),
4417
- plune_version: import_zod2.z.string(),
4418
- started_at: import_zod2.z.string(),
4419
- finished_at: import_zod2.z.string(),
4420
- 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(),
4421
5138
  summary: summarySchema,
4422
- evals: import_zod2.z.array(evalResultSchema)
5139
+ evals: import_zod3.z.array(evalResultSchema)
4423
5140
  });
4424
5141
  }
4425
5142
  });
@@ -4436,10 +5153,10 @@ __export(sync_exports, {
4436
5153
  reportSyncFailure: () => reportSyncFailure
4437
5154
  });
4438
5155
  function readRun(cwd, file) {
4439
- 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");
4440
5157
  let raw;
4441
5158
  try {
4442
- raw = fs13.readFileSync(target, "utf8");
5159
+ raw = fs15.readFileSync(target, "utf8");
4443
5160
  } catch {
4444
5161
  throw new SyncFileError(
4445
5162
  file !== void 0 ? `Run file not found: ${target}` : 'No run to sync. Run "plune run" first (or pass --file <path>).'
@@ -4503,13 +5220,13 @@ function reportSyncFailure(err, write) {
4503
5220
  }
4504
5221
  return null;
4505
5222
  }
4506
- var fs13, path12, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
5223
+ var fs15, path14, NotLoggedInError, TokenRejectedError2, SyncFileError, SyncNetworkError, SyncHttpError;
4507
5224
  var init_sync = __esm({
4508
5225
  "src/cli/commands/sync.ts"() {
4509
5226
  "use strict";
4510
5227
  init_cjs_shims();
4511
- fs13 = __toESM(require("fs"), 1);
4512
- path12 = __toESM(require("path"), 1);
5228
+ fs15 = __toESM(require("fs"), 1);
5229
+ path14 = __toESM(require("path"), 1);
4513
5230
  init_run_result_schema();
4514
5231
  init_api_url();
4515
5232
  init_credentials();
@@ -4554,14 +5271,14 @@ var init_sync = __esm({
4554
5271
 
4555
5272
  // src/cli/commands/cairn-artifact.ts
4556
5273
  async function findLatestCairnRun(runsDir = "runs") {
4557
- const entries = await fs14.readdir(runsDir, { withFileTypes: true }).catch(() => null);
5274
+ const entries = await fs16.readdir(runsDir, { withFileTypes: true }).catch(() => null);
4558
5275
  if (entries === null) throw new NoCairnRunsError(runsDir);
4559
5276
  let newest;
4560
5277
  for (const entry of entries) {
4561
5278
  if (!entry.isDirectory()) continue;
4562
- const dir = path13.join(runsDir, entry.name);
5279
+ const dir = path15.join(runsDir, entry.name);
4563
5280
  try {
4564
- const report = await fs14.lstat(path13.join(dir, "report.json"));
5281
+ const report = await fs16.lstat(path15.join(dir, "report.json"));
4565
5282
  if (!report.isFile()) continue;
4566
5283
  if (newest === void 0 || report.mtimeMs > newest.at) {
4567
5284
  newest = { dir, at: report.mtimeMs };
@@ -4574,13 +5291,13 @@ async function findLatestCairnRun(runsDir = "runs") {
4574
5291
  }
4575
5292
  async function readJson(file) {
4576
5293
  try {
4577
- return JSON.parse(await fs14.readFile(file, "utf8"));
5294
+ return JSON.parse(await fs16.readFile(file, "utf8"));
4578
5295
  } catch {
4579
5296
  return void 0;
4580
5297
  }
4581
5298
  }
4582
5299
  async function defaultReadEvidence(dir) {
4583
- const raw = await readJson(path13.join(dir, "api-evidence.json"));
5300
+ const raw = await readJson(path15.join(dir, "api-evidence.json"));
4584
5301
  if (!Array.isArray(raw)) return void 0;
4585
5302
  return raw.map((r) => {
4586
5303
  const rec = asRecord(r);
@@ -4622,7 +5339,7 @@ function casesFromApi(report, verdicts) {
4622
5339
  });
4623
5340
  }
4624
5341
  async function readCairnRun(dir, deps = {}) {
4625
- const raw = await readJson(path13.join(dir, "report.json"));
5342
+ const raw = await readJson(path15.join(dir, "report.json"));
4626
5343
  if (raw === void 0) throw new CairnRunNotFoundError(dir);
4627
5344
  const report = asRecord(raw);
4628
5345
  if (report["schemaVersion"] !== SUPPORTED_ARTIFACT_VERSION) {
@@ -4652,20 +5369,20 @@ async function readCairnRun(dir, deps = {}) {
4652
5369
  return {
4653
5370
  schemaVersion: INGEST_SCHEMA_VERSION,
4654
5371
  run: {
4655
- runId: str(report["runId"], path13.basename(dir)),
5372
+ runId: str(report["runId"], path15.basename(dir)),
4656
5373
  mode,
4657
5374
  target: str(report["url"], "unknown")
4658
5375
  },
4659
5376
  cases
4660
5377
  };
4661
5378
  }
4662
- 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;
4663
5380
  var init_cairn_artifact = __esm({
4664
5381
  "src/cli/commands/cairn-artifact.ts"() {
4665
5382
  "use strict";
4666
5383
  init_cjs_shims();
4667
- fs14 = __toESM(require("fs/promises"), 1);
4668
- path13 = __toESM(require("path"), 1);
5384
+ fs16 = __toESM(require("fs/promises"), 1);
5385
+ path15 = __toESM(require("path"), 1);
4669
5386
  SUPPORTED_ARTIFACT_VERSION = 1;
4670
5387
  INGEST_SCHEMA_VERSION = 1;
4671
5388
  NoCairnRunsError = class extends Error {
@@ -4832,7 +5549,7 @@ __export(cases_exports, {
4832
5549
  function gitDirty(file) {
4833
5550
  try {
4834
5551
  const out = (0, import_node_child_process2.execFileSync)("git", ["status", "--porcelain", "--", file], {
4835
- cwd: path14.dirname(file),
5552
+ cwd: path16.dirname(file),
4836
5553
  stdio: ["ignore", "pipe", "ignore"],
4837
5554
  encoding: "utf8"
4838
5555
  });
@@ -4858,9 +5575,9 @@ async function handlePull(deps = {}) {
4858
5575
  const cwd = deps.cwd ?? process.cwd();
4859
5576
  const apiUrl = resolveApiUrl(deps.apiUrl);
4860
5577
  const token = tokenOf(deps);
4861
- const file = path14.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4862
- if (fs15.existsSync(file) && !deps.force && (deps.isDirty ?? gitDirty)(file))
4863
- 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);
4864
5581
  const doFetch = deps.fetchImpl ?? fetch;
4865
5582
  const endpoint = deps.suiteId !== void 0 ? `${apiUrl}/v1/suites/${encodeURIComponent(deps.suiteId)}/markdown` : `${apiUrl}/v1/test-cases/markdown`;
4866
5583
  let res;
@@ -4875,18 +5592,18 @@ async function handlePull(deps = {}) {
4875
5592
  throw new SyncHttpError(res.status, detail);
4876
5593
  }
4877
5594
  const text2 = await res.text();
4878
- fs15.mkdirSync(path14.dirname(file), { recursive: true });
4879
- fs15.writeFileSync(file, text2, "utf8");
5595
+ fs17.mkdirSync(path16.dirname(file), { recursive: true });
5596
+ fs17.writeFileSync(file, text2, "utf8");
4880
5597
  return { file, cases: Number(res.headers.get("x-plune-cases") ?? "0") };
4881
5598
  }
4882
5599
  async function handlePush(deps = {}) {
4883
5600
  const cwd = deps.cwd ?? process.cwd();
4884
5601
  const apiUrl = resolveApiUrl(deps.apiUrl);
4885
5602
  const token = tokenOf(deps);
4886
- const file = path14.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
5603
+ const file = path16.resolve(cwd, deps.file ?? DEFAULT_CASES_FILE);
4887
5604
  let text2;
4888
5605
  try {
4889
- text2 = fs15.readFileSync(file, "utf8");
5606
+ text2 = fs17.readFileSync(file, "utf8");
4890
5607
  } catch {
4891
5608
  throw new SyncFileError(
4892
5609
  `No document to push: ${file}. Run "plune pull" first (or pass a file).`
@@ -4937,18 +5654,18 @@ function reportCasesFailure(err, write) {
4937
5654
  }
4938
5655
  return null;
4939
5656
  }
4940
- var import_node_child_process2, fs15, path14, DEFAULT_CASES_FILE, DirtyFileError;
5657
+ var import_node_child_process2, fs17, path16, DEFAULT_CASES_FILE, DirtyFileError;
4941
5658
  var init_cases = __esm({
4942
5659
  "src/cli/commands/cases.ts"() {
4943
5660
  "use strict";
4944
5661
  init_cjs_shims();
4945
5662
  import_node_child_process2 = require("child_process");
4946
- fs15 = __toESM(require("fs"), 1);
4947
- path14 = __toESM(require("path"), 1);
5663
+ fs17 = __toESM(require("fs"), 1);
5664
+ path16 = __toESM(require("path"), 1);
4948
5665
  init_api_url();
4949
5666
  init_credentials();
4950
5667
  init_sync();
4951
- DEFAULT_CASES_FILE = path14.join("plune", "cases.md");
5668
+ DEFAULT_CASES_FILE = path16.join("plune", "cases.md");
4952
5669
  DirtyFileError = class extends Error {
4953
5670
  constructor(file) {
4954
5671
  super(`${file} has uncommitted changes; commit them or pass --force to overwrite.`);
@@ -5029,11 +5746,11 @@ module.exports = __toCommonJS(cli_exports);
5029
5746
  init_cjs_shims();
5030
5747
  init_api_url();
5031
5748
  var import_commander = require("commander");
5032
- var import_node_fs5 = require("fs");
5749
+ var import_node_fs6 = require("fs");
5033
5750
  var import_node_path4 = require("path");
5034
5751
  var import_node_url = require("url");
5035
5752
  var __dirname = (0, import_node_path4.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
5036
- 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"));
5037
5754
  function toFormat(s) {
5038
5755
  return s === "json" || s === "markdown" ? s : "console";
5039
5756
  }
@@ -5042,7 +5759,7 @@ function shouldColor(outputPath) {
5042
5759
  }
5043
5760
  function writeReport(text2, outputPath) {
5044
5761
  if (outputPath !== void 0) {
5045
- (0, import_node_fs5.writeFileSync)(outputPath, text2);
5762
+ (0, import_node_fs6.writeFileSync)(outputPath, text2);
5046
5763
  } else {
5047
5764
  process.stdout.write(text2 + "\n");
5048
5765
  }
@@ -5233,11 +5950,15 @@ function createProgram() {
5233
5950
  await failRunCommand(err, verboseOf(command));
5234
5951
  }
5235
5952
  });
5236
- 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(
5237
5958
  async (file, options, command) => {
5238
5959
  const [
5239
5960
  { handleRunImport: handleRunImport2 },
5240
- { IMPORT_FORMATS: IMPORT_FORMATS2, UnknownFormatError: UnknownFormatError2, XmlParseError: XmlParseError2, JsonReportError: JsonReportError2 }
5961
+ { IMPORT_FORMATS: IMPORT_FORMATS2, UnknownFormatError: UnknownFormatError2, XmlParseError: XmlParseError2, JsonReportError: JsonReportError2, AllureReportError: AllureReportError2 }
5241
5962
  ] = await Promise.all([
5242
5963
  Promise.resolve().then(() => (init_run_import(), run_import_exports)),
5243
5964
  Promise.resolve().then(() => (init_importers(), importers_exports))
@@ -5253,14 +5974,15 @@ function createProgram() {
5253
5974
  return;
5254
5975
  }
5255
5976
  try {
5256
- await handleRunImport2({
5977
+ const imported = await handleRunImport2({
5257
5978
  file,
5258
5979
  ...format !== void 0 ? { format } : {},
5259
5980
  ...options.key !== void 0 ? { key: options.key } : {},
5260
5981
  ...options.create ? { create: true } : {}
5261
5982
  });
5983
+ if (options.failOnMissingAttachments && imported.missingAttachments.length > 0) process.exitCode = 1;
5262
5984
  } catch (err) {
5263
- if (err instanceof UnknownFormatError2 || err instanceof XmlParseError2 || err instanceof JsonReportError2) {
5985
+ if (err instanceof UnknownFormatError2 || err instanceof XmlParseError2 || err instanceof JsonReportError2 || err instanceof AllureReportError2) {
5264
5986
  process.stderr.write(err.message + "\n");
5265
5987
  process.exit(2);
5266
5988
  return;
@@ -5339,10 +6061,10 @@ function createProgram() {
5339
6061
  const { handleLogin: handleLogin2, reportLoginFailure: reportLoginFailure2 } = await Promise.resolve().then(() => (init_login(), login_exports));
5340
6062
  try {
5341
6063
  const token = options.token ?? await readTokenFromStdin();
5342
- const { path: path15, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
6064
+ const { path: path17, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
5343
6065
  process.stdout.write(
5344
- verified ? `Logged in. Token checked against the API and saved to ${path15}
5345
- ` : `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).
5346
6068
  `
5347
6069
  );
5348
6070
  } catch (err) {
@@ -5359,9 +6081,9 @@ function createProgram() {
5359
6081
  const verbose = command.optsWithGlobals().verbose === true;
5360
6082
  const { handleLogout: handleLogout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
5361
6083
  try {
5362
- const { removed, path: path15 } = handleLogout2();
6084
+ const { removed, path: path17 } = handleLogout2();
5363
6085
  process.stdout.write(
5364
- removed ? `Logged out. Removed ${path15}
6086
+ removed ? `Logged out. Removed ${path17}
5365
6087
  ` : "Not logged in \u2014 nothing to remove.\n"
5366
6088
  );
5367
6089
  } catch (err) {
@@ -5490,7 +6212,7 @@ function isMainModule() {
5490
6212
  const entry = process.argv[1];
5491
6213
  if (entry === void 0) return false;
5492
6214
  try {
5493
- 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));
5494
6216
  } catch {
5495
6217
  return false;
5496
6218
  }