@plune-ai/cli 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -165,6 +165,11 @@ JPEG or WebP, up to 2 MB each, 20 per result — is uploaded to that result, a p
165
165
  run in Plune shows each test with what it looked at. `plune run import` finds them where the report
166
166
  says they are, so run it where the report was written; the summary counts any it could not find.
167
167
 
168
+ JSON and plain text come the same way — the answer of an API, a log — whether a test attached them
169
+ with a `path` or as a `body` (a report holds a body itself, so `plune run import` needs no file for
170
+ it). Only `application/json` and `text/plain`, as the attachment names them; UTF-8 without NUL
171
+ bytes, up to 512 KiB each and 10 per result. The summary counts them apart from the screenshots.
172
+
168
173
  ### Several jobs, one run
169
174
 
170
175
  Two suites, or a sharded matrix, report into a single run when every job shares a key **and** knows
package/dist/cli.cjs CHANGED
@@ -2456,11 +2456,149 @@ var init_fallback = __esm({
2456
2456
  }
2457
2457
  });
2458
2458
 
2459
+ // src/reporter-core/failure-detail.ts
2460
+ function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
2461
+ const error = chosen(attempt);
2462
+ const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
2463
+ const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
2464
+ const steps = chainOf(attempt.steps);
2465
+ const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
2466
+ 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 !== "");
2467
+ const ciUrl = webLink(attempt.buildHref);
2468
+ const failure = {
2469
+ ...headline !== void 0 ? { headline } : {},
2470
+ ...steps.length > 0 ? { steps } : {},
2471
+ ...location !== void 0 ? { location } : {},
2472
+ ...artifacts.length > 0 ? { artifacts } : {},
2473
+ ...ciUrl !== void 0 ? { ciUrl } : {}
2474
+ };
2475
+ return Object.keys(failure).length === 0 ? void 0 : failure;
2476
+ }
2477
+ function repoRootOf(dir, stopAt) {
2478
+ for (let here = (0, import_node_path2.resolve)(dir); ; here = (0, import_node_path2.dirname)(here)) {
2479
+ if ((0, import_node_fs2.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
2480
+ if (here === stopAt || (0, import_node_path2.dirname)(here) === here) return void 0;
2481
+ }
2482
+ }
2483
+ function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
2484
+ const text2 = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
2485
+ return cut(machineless(text2, attempt.repoRoot, home));
2486
+ }
2487
+ function filesOf(attempt) {
2488
+ return attempt.attachments.filter((a) => a.path !== void 0 || a.body !== void 0 && isTextType(a.contentType));
2489
+ }
2490
+ function machineless(raw, repoRoot, home) {
2491
+ let text2 = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
2492
+ const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
2493
+ if (root !== void 0) text2 = text2.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
2494
+ const own = pathIn(home);
2495
+ if (own !== void 0) text2 = text2.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
2496
+ if (repoRoot === void 0) text2 = text2.replace(ANY_HOME, "~");
2497
+ return text2;
2498
+ }
2499
+ function pathIn(path15) {
2500
+ const parts = path15.split(/[\\/]+/);
2501
+ while (parts.length > 1 && parts.at(-1) === "") parts.pop();
2502
+ if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
2503
+ const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
2504
+ const escaped = /^\/(?!\/)/.test(path15) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
2505
+ const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
2506
+ return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
2507
+ }
2508
+ function cut(text2) {
2509
+ if (jsonBytes(text2) <= TEXT_LIMIT) return text2;
2510
+ const lines = text2.split("\n");
2511
+ const size = lines.map((line) => jsonBytes(line) + 2);
2512
+ const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
2513
+ let used = 0;
2514
+ let head = 0;
2515
+ let tail = lines.length;
2516
+ while (head < tail && used + size[head] <= budget / 2) used += size[head++];
2517
+ while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
2518
+ return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
2519
+ }
2520
+ function chosen({ status, errors }) {
2521
+ if (status === "timedOut") {
2522
+ const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
2523
+ if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
2524
+ }
2525
+ return errors[0];
2526
+ }
2527
+ function chainOf(steps) {
2528
+ const chain = [];
2529
+ for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
2530
+ chain.push(failed.title);
2531
+ }
2532
+ return chain;
2533
+ }
2534
+ function placeOf(error, testFile) {
2535
+ const own = normal(testFile);
2536
+ let first;
2537
+ for (const line of (0, import_node_util.stripVTControlCharacters)(error.text).split("\n")) {
2538
+ if (line.length > FRAME_MAX) continue;
2539
+ const frame = FRAME.exec(line);
2540
+ if (frame === null) continue;
2541
+ const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
2542
+ if (normal(place.file) === own) return place;
2543
+ if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
2544
+ }
2545
+ return error.location ?? first;
2546
+ }
2547
+ function fileOf(raw) {
2548
+ if (!raw.startsWith("file://")) return raw;
2549
+ let path15 = raw.slice("file://".length);
2550
+ try {
2551
+ path15 = decodeURIComponent(path15);
2552
+ } catch {
2553
+ }
2554
+ return /^\/[A-Za-z]:\//.test(path15) ? path15.slice(1) : path15;
2555
+ }
2556
+ function fromRoot(place, root) {
2557
+ if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
2558
+ const base = normal(root).replace(/\/+$/, "");
2559
+ const file = normal(place.file);
2560
+ if (!file.startsWith(`${base}/`)) return void 0;
2561
+ const relative2 = file.slice(base.length + 1);
2562
+ if (!REPO_RELATIVE.test(relative2)) return void 0;
2563
+ const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
2564
+ return column === void 0 ? { file: relative2, line: place.line } : { file: relative2, line: place.line, column };
2565
+ }
2566
+ function webLink(href) {
2567
+ return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
2568
+ }
2569
+ var import_node_fs2, import_node_os2, import_node_path2, import_node_util, mediaTypeOf, TEXT_TYPES, isTextType, PATH_START, TEXT_LIMIT, HEADLINE_LIMIT, jsonBytes, ANY_HOME, fileNameOf, omitted, METADATA_TYPE, REPO_RELATIVE, FRAME, FRAME_MAX, firstLine, normal;
2570
+ var init_failure_detail = __esm({
2571
+ "src/reporter-core/failure-detail.ts"() {
2572
+ "use strict";
2573
+ init_cjs_shims();
2574
+ import_node_fs2 = require("fs");
2575
+ import_node_os2 = require("os");
2576
+ import_node_path2 = require("path");
2577
+ import_node_util = require("util");
2578
+ mediaTypeOf = (contentType) => contentType?.split(";")[0]?.trim().toLowerCase() || void 0;
2579
+ TEXT_TYPES = /* @__PURE__ */ new Set(["application/json", "text/plain"]);
2580
+ isTextType = (contentType) => TEXT_TYPES.has(mediaTypeOf(contentType) ?? "");
2581
+ PATH_START = "(?<![\\w.~%/\\\\-])";
2582
+ TEXT_LIMIT = 524288;
2583
+ HEADLINE_LIMIT = 8 * 1024;
2584
+ jsonBytes = (text2) => Buffer.byteLength(JSON.stringify(text2)) - 2;
2585
+ ANY_HOME = /(?<=^|[\s'"`(=,[])(?:(?:file|webpack|webpack-internal):(?:\\*\/){2,3})?(?:\\*\/home\\*\/|\\*\/Users\\*\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
2586
+ fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
2587
+ omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
2588
+ METADATA_TYPE = "application/plune.metadata+json";
2589
+ REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
2590
+ FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
2591
+ FRAME_MAX = 8192;
2592
+ firstLine = (text2) => (0, import_node_util.stripVTControlCharacters)(text2).split("\n").map((line) => line.trim()).find((line) => line !== "");
2593
+ normal = (path15) => import_node_path2.posix.normalize(path15.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
2594
+ }
2595
+ });
2596
+
2459
2597
  // src/reporter-core/session.ts
2460
2598
  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);
2599
+ const own = file.name.split(/[\\/]/).pop()?.trim() || (file.path === void 0 ? "" : (0, import_node_path3.basename)(file.path));
2600
+ const fileExt = file.path === void 0 ? "" : (0, import_node_path3.extname)(file.path);
2601
+ const ownExt = (0, import_node_path3.extname)(own);
2464
2602
  const typed = ownExt !== "" && (ownExt.toLowerCase() === fileExt.toLowerCase() || TYPE_BY_EXTENSION[ownExt.toLowerCase()] !== void 0);
2465
2603
  const ext = typed ? ownExt : fileExt;
2466
2604
  let stem = typed ? own.slice(0, -ownExt.length) : own;
@@ -2470,6 +2608,35 @@ function nameOf(file) {
2470
2608
  }
2471
2609
  return `${stem}${ext}`;
2472
2610
  }
2611
+ function kindOf(type) {
2612
+ if (type.startsWith("image/")) return "screenshot";
2613
+ return TEXT_TYPES.has(type) ? "text" : void 0;
2614
+ }
2615
+ function textTypeOf(contentType, type) {
2616
+ const charset = /;\s*charset\s*=\s*"?([^\s";]+)"?/i.exec(contentType)?.[1];
2617
+ return charset?.toLowerCase() === "utf-8" ? `${type}; charset=utf-8` : type;
2618
+ }
2619
+ function textRefusal(bytes) {
2620
+ if (bytes.length > TEXT_BYTES) return "textSize";
2621
+ if (bytes.length === 0) return "empty";
2622
+ if (bytes.includes(0)) return "binary";
2623
+ try {
2624
+ UTF8.decode(bytes);
2625
+ } catch {
2626
+ return "encoding";
2627
+ }
2628
+ return void 0;
2629
+ }
2630
+ function onDisk(result) {
2631
+ if (!("files" in result) || result.files === void 0) return result;
2632
+ const { files, ...rest } = result;
2633
+ const kept = files.filter((f) => f.path !== void 0).map(({ body: _body, ...file }) => file);
2634
+ return kept.length === 0 ? rest : { ...rest, files: kept };
2635
+ }
2636
+ function tally(counts, noun) {
2637
+ if (counts.uploaded + counts.skipped + counts.failed === 0) return void 0;
2638
+ return `${counts.uploaded} ${noun}(s) uploaded` + (counts.skipped > 0 ? `, ${counts.skipped} skipped` : "") + (counts.failed > 0 ? `, ${counts.failed} failed to upload` : "");
2639
+ }
2473
2640
  function boundKey(key) {
2474
2641
  if (key.value.length <= KEY_MAX) return key;
2475
2642
  const digest = (0, import_node_crypto2.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
@@ -2543,7 +2710,8 @@ async function startRun(passed, declared) {
2543
2710
  created: 0,
2544
2711
  unoffered: 0,
2545
2712
  deferred: 0,
2546
- screenshots: { uploaded: 0, skipped: 0, failed: 0 }
2713
+ screenshots: { uploaded: 0, skipped: 0, failed: 0 },
2714
+ textFiles: { uploaded: 0, skipped: 0, failed: 0 }
2547
2715
  };
2548
2716
  const resolved = /* @__PURE__ */ new Map();
2549
2717
  let buffer = [];
@@ -2554,6 +2722,7 @@ async function startRun(passed, declared) {
2554
2722
  let done = false;
2555
2723
  const shortened = /* @__PURE__ */ new Set();
2556
2724
  const skipsSaid = /* @__PURE__ */ new Set();
2725
+ const countsOf = (kind) => kind === "text" ? stats.textFiles : stats.screenshots;
2557
2726
  function bound(keys) {
2558
2727
  return keys.map((key) => {
2559
2728
  const fit = boundKey(key);
@@ -2581,7 +2750,7 @@ async function startRun(passed, declared) {
2581
2750
  externalKey,
2582
2751
  session: sessionMarker,
2583
2752
  ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
2584
- results
2753
+ results: results.map(onDisk)
2585
2754
  });
2586
2755
  stats.deferred += results.length;
2587
2756
  }
@@ -2689,57 +2858,74 @@ async function startRun(passed, declared) {
2689
2858
  stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
2690
2859
  }
2691
2860
  }
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]}.`);
2861
+ function skip(kind, name, why) {
2862
+ countsOf(kind).skipped += 1;
2863
+ if (skipsSaid.has(`${kind}/${why}`)) return;
2864
+ skipsSaid.add(`${kind}/${why}`);
2865
+ log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
2697
2866
  }
2698
- async function uploadOne(shot) {
2867
+ async function uploadOne(job) {
2868
+ const { kind } = job;
2869
+ const { label, bytes: ceiling, size } = KINDS[kind];
2699
2870
  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");
2871
+ if (job.body !== void 0) bytes = job.body;
2872
+ else if (job.path === void 0) return skip(kind, job.name, "missing");
2873
+ else {
2874
+ try {
2875
+ if ((await import_node_fs3.promises.stat(job.path)).size > ceiling) return skip(kind, job.name, size);
2876
+ bytes = await import_node_fs3.promises.readFile(job.path);
2877
+ } catch {
2878
+ return skip(kind, job.name, "missing");
2879
+ }
2705
2880
  }
2881
+ const refusal = kind === "text" ? textRefusal(bytes) : void 0;
2882
+ if (refusal !== void 0) return skip(kind, job.name, refusal);
2706
2883
  const out = await client.upload(
2707
- `/v1/results/${encodeURIComponent(shot.id)}/files?name=${encodeURIComponent(shot.name)}`,
2884
+ `/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
2708
2885
  bytes,
2709
- shot.type
2886
+ job.type
2710
2887
  );
2888
+ const counts = countsOf(kind);
2711
2889
  if (out.ok) {
2712
- stats.screenshots.uploaded += 1;
2890
+ counts.uploaded += 1;
2713
2891
  return;
2714
2892
  }
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.`);
2893
+ counts.failed += 1;
2894
+ if (counts.failed === 1) {
2895
+ log(`plune: could not upload a ${label} (${out.detail || out.kind}) \u2014 its result is reported without it.`);
2718
2896
  }
2719
2897
  }
2720
2898
  async function upload(items, filesByKey) {
2721
2899
  if (filesByKey.size === 0 || !Array.isArray(items)) return;
2722
- const shots = [];
2900
+ const jobs = [];
2723
2901
  for (const item of items) {
2724
2902
  if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
2725
- let room = SCREENSHOTS_PER_RESULT;
2903
+ const room = { screenshot: KINDS.screenshot.room, text: KINDS.text.room };
2726
2904
  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;
2905
+ const type = mediaTypeOf(file.contentType) ?? (file.path === void 0 ? void 0 : TYPE_BY_EXTENSION[(0, import_node_path3.extname)(file.path).toLowerCase()]);
2906
+ const kind = type === void 0 ? void 0 : kindOf(type);
2907
+ if (type === void 0 || kind === void 0 || kind === "screenshot" && file.path === void 0) continue;
2729
2908
  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");
2909
+ if (file.name.startsWith("_")) skip(kind, name, "hidden");
2910
+ else if (kind === "screenshot" && !SCREENSHOT_TYPES.has(type)) skip(kind, name, "type");
2911
+ else if (room[kind] === 0) skip(kind, name, KINDS[kind].limit);
2733
2912
  else {
2734
- room -= 1;
2735
- shots.push({ id: item.id, name, type, path: file.path });
2913
+ room[kind] -= 1;
2914
+ jobs.push({
2915
+ id: item.id,
2916
+ kind,
2917
+ name,
2918
+ type: kind === "text" ? textTypeOf(file.contentType ?? "", type) : type,
2919
+ ...file.body !== void 0 ? { body: file.body } : {},
2920
+ ...file.path !== void 0 ? { path: file.path } : {}
2921
+ });
2736
2922
  }
2737
2923
  }
2738
2924
  }
2739
2925
  let next = 0;
2740
2926
  await Promise.all(
2741
- Array.from({ length: Math.min(UPLOADS_AT_ONCE, shots.length) }, async () => {
2742
- while (next < shots.length) await uploadOne(shots[next++]);
2927
+ Array.from({ length: Math.min(UPLOADS_AT_ONCE, jobs.length) }, async () => {
2928
+ while (next < jobs.length) await uploadOne(jobs[next++]);
2743
2929
  })
2744
2930
  );
2745
2931
  }
@@ -2817,11 +3003,8 @@ async function startRun(passed, declared) {
2817
3003
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
2818
3004
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
2819
3005
  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) {
2822
- parts.push(
2823
- `${shots.uploaded} screenshot(s) uploaded` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} failed to upload` : "")
2824
- );
3006
+ for (const said of [tally(stats.screenshots, "screenshot"), tally(stats.textFiles, "text file")]) {
3007
+ if (said !== void 0) parts.push(said);
2825
3008
  }
2826
3009
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
2827
3010
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
@@ -2874,18 +3057,20 @@ async function startRun(passed, declared) {
2874
3057
  }
2875
3058
  };
2876
3059
  }
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;
3060
+ var import_node_crypto2, import_node_fs3, import_node_path3, import_node_util2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TEXT_BYTES, TEXTS_PER_RESULT, UTF8, TYPE_BY_EXTENSION, UPLOADS_AT_ONCE, NAME_MAX, SKIPPED, KINDS, BATCH_BYTES, ENVELOPE_BYTES;
2878
3061
  var init_session = __esm({
2879
3062
  "src/reporter-core/session.ts"() {
2880
3063
  "use strict";
2881
3064
  init_cjs_shims();
2882
3065
  import_node_crypto2 = require("crypto");
2883
- import_node_fs2 = require("fs");
2884
- import_node_path2 = require("path");
3066
+ import_node_fs3 = require("fs");
3067
+ import_node_path3 = require("path");
3068
+ import_node_util2 = require("util");
2885
3069
  init_api_url();
2886
3070
  init_credentials();
2887
3071
  init_client();
2888
3072
  init_env2();
3073
+ init_failure_detail();
2889
3074
  init_fallback();
2890
3075
  RESOLVE_CHUNK = 500;
2891
3076
  BATCH_MAX = 500;
@@ -2898,6 +3083,9 @@ var init_session = __esm({
2898
3083
  SCREENSHOT_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
2899
3084
  SCREENSHOT_BYTES = 2 * 1024 * 1024;
2900
3085
  SCREENSHOTS_PER_RESULT = 20;
3086
+ TEXT_BYTES = 512 * 1024;
3087
+ TEXTS_PER_RESULT = 10;
3088
+ UTF8 = new import_node_util2.TextDecoder("utf-8", { fatal: true });
2901
3089
  TYPE_BY_EXTENSION = {
2902
3090
  ".png": "image/png",
2903
3091
  ".jpg": "image/jpeg",
@@ -2911,7 +3099,16 @@ var init_session = __esm({
2911
3099
  type: "Plune takes PNG, JPEG and WebP",
2912
3100
  limit: `a result holds at most ${SCREENSHOTS_PER_RESULT}`,
2913
3101
  missing: "the file cannot be read on this machine",
2914
- size: "Plune takes a file of at most 2 MB"
3102
+ size: "Plune takes a file of at most 2 MB",
3103
+ textLimit: `a result holds at most ${TEXTS_PER_RESULT} text files`,
3104
+ textSize: "Plune takes a text file of at most 512 KiB",
3105
+ empty: "the file is empty",
3106
+ binary: "the file holds a NUL byte, and Plune takes UTF-8 text without one",
3107
+ encoding: "Plune takes UTF-8 text only"
3108
+ };
3109
+ KINDS = {
3110
+ screenshot: { label: "screenshot", bytes: SCREENSHOT_BYTES, room: SCREENSHOTS_PER_RESULT, limit: "limit", size: "size" },
3111
+ text: { label: "text file", bytes: TEXT_BYTES, room: TEXTS_PER_RESULT, limit: "textLimit", size: "textSize" }
2915
3112
  };
2916
3113
  BATCH_BYTES = 8 * 1024 * 1024;
2917
3114
  ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ results: [] }));
@@ -3397,141 +3594,6 @@ var init_junit = __esm({
3397
3594
  }
3398
3595
  });
3399
3596
 
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
3597
  // src/importers/playwright-json.ts
3536
3598
  function statedIdOf(test, title) {
3537
3599
  const annotation = test.annotations?.find((a) => a.type === "PluneId")?.description;
@@ -3547,10 +3609,13 @@ function attemptOf(result, file, ctx) {
3547
3609
  ...typeof at?.file === "string" && typeof at.line === "number" ? { location: { file: at.file, line: at.line, ...typeof at.column === "number" ? { column: at.column } : {} } } : {}
3548
3610
  })),
3549
3611
  steps: steps(result.steps),
3550
- attachments: (result.attachments ?? []).map(({ name, contentType, path: path15 }) => ({
3612
+ attachments: (result.attachments ?? []).map(({ name, contentType, path: path15, body }) => ({
3551
3613
  name: name ?? "",
3552
3614
  ...contentType !== void 0 ? { contentType } : {},
3553
- ...path15 !== void 0 ? { path: path15 } : {}
3615
+ ...path15 !== void 0 ? { path: path15 } : {},
3616
+ // Decoded only where the platform would take it (plune-ai/plune#928): a report of screenshots kept as
3617
+ // bodies is megabytes of base64 that nothing here will read.
3618
+ ...typeof body === "string" && isTextType(contentType) ? { body: Buffer.from(body, "base64") } : {}
3554
3619
  })),
3555
3620
  ...ctx.buildHref !== void 0 ? { buildHref: ctx.buildHref } : {},
3556
3621
  // Spec files are posix and relative to the root dir; the core reads either slash.
@@ -3767,6 +3832,7 @@ async function handleRunImport(options) {
3767
3832
  ...stats.unofferedWhy !== void 0 ? { unofferedWhy: stats.unofferedWhy } : {},
3768
3833
  deferred: stats.deferred,
3769
3834
  screenshots: { ...stats.screenshots },
3835
+ textFiles: { ...stats.textFiles },
3770
3836
  unlocatable: results.filter((r) => r.specRef === void 0).length,
3771
3837
  offering: options.create === true
3772
3838
  };
@@ -3790,11 +3856,15 @@ function describe(result, apiUrl) {
3790
3856
  if (result.conflict > 0 || result.rejected > 0) {
3791
3857
  lines.push(`${result.conflict} conflicted and ${result.rejected} were refused.`);
3792
3858
  }
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
- );
3859
+ for (const [counts, noun] of [
3860
+ [result.screenshots, "screenshot"],
3861
+ [result.textFiles, "text file"]
3862
+ ]) {
3863
+ if (counts.uploaded + counts.skipped + counts.failed > 0) {
3864
+ lines.push(
3865
+ `${counts.uploaded} ${noun}(s) uploaded to their results` + (counts.skipped > 0 ? `, ${counts.skipped} skipped` : "") + (counts.failed > 0 ? `, ${counts.failed} could not be uploaded` : "") + "."
3866
+ );
3867
+ }
3798
3868
  }
3799
3869
  if (result.deferred > 0) {
3800
3870
  lines.push(