@plune-ai/cli 0.15.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.
@@ -34,6 +34,7 @@ __export(reporter_core_exports, {
34
34
  RESULT_KEY_MAX: () => RESULT_KEY_MAX,
35
35
  UNSUPPORTED_VARS: () => UNSUPPORTED_VARS,
36
36
  appendBatch: () => appendBatch,
37
+ ciCommit: () => ciCommit,
37
38
  createClient: () => createClient,
38
39
  errorContextOf: () => errorContextOf,
39
40
  failureOf: () => failureOf,
@@ -48,8 +49,9 @@ module.exports = __toCommonJS(reporter_core_exports);
48
49
 
49
50
  // src/reporter-core/session.ts
50
51
  var import_node_crypto = require("crypto");
51
- var import_node_fs2 = require("fs");
52
- var import_node_path2 = require("path");
52
+ var import_node_fs3 = require("fs");
53
+ var import_node_path3 = require("path");
54
+ var import_node_util2 = require("util");
53
55
 
54
56
  // src/cli/api-url.ts
55
57
  var DEFAULT_API_URL = "https://beta-api.plune.ai";
@@ -247,6 +249,159 @@ function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cw
247
249
  const where = flag(env, "CI") || flag(env, "GITHUB_ACTIONS") ? "ci" : "local";
248
250
  return `${path.basename(cwd)} \xB7 ${stamp} \xB7 ${where}`;
249
251
  }
252
+ var PROVIDERS = [
253
+ { is: "GITHUB_ACTIONS", sha: "GITHUB_SHA", branch: ["GITHUB_HEAD_REF", "GITHUB_REF_NAME"] },
254
+ { is: "GITLAB_CI", sha: "CI_COMMIT_SHA", branch: ["CI_COMMIT_REF_NAME"] },
255
+ { is: "JENKINS_URL", sha: "GIT_COMMIT", branch: ["GIT_BRANCH"] }
256
+ ];
257
+ function ciCommit(reported, env = process.env) {
258
+ const said = typeof reported === "object" && reported !== null ? reported : {};
259
+ const text = (raw) => typeof raw === "string" && raw.trim() !== "" ? raw.trim() : void 0;
260
+ const provider = PROVIDERS.find((p) => value(env, p.is) !== void 0);
261
+ const job = {
262
+ sha: provider === void 0 ? void 0 : value(env, provider.sha),
263
+ branch: provider?.branch.map((name) => value(env, name)).find((v) => v !== void 0)
264
+ };
265
+ const sha = text(said["commitHash"]);
266
+ const sameCommit = sha === void 0 || sha === job.sha;
267
+ const commit = sha ?? job.sha;
268
+ const branch = text(said["branch"]) ?? (sameCommit ? job.branch : void 0);
269
+ return {
270
+ ...commit !== void 0 ? { sha: commit } : {},
271
+ ...branch !== void 0 ? { branch } : {}
272
+ };
273
+ }
274
+
275
+ // src/reporter-core/failure-detail.ts
276
+ var import_node_fs2 = require("fs");
277
+ var import_node_os2 = require("os");
278
+ var import_node_path2 = require("path");
279
+ var import_node_util = require("util");
280
+ function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
281
+ const error = chosen(attempt);
282
+ const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
283
+ const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
284
+ const steps = chainOf(attempt.steps);
285
+ const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
286
+ 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 !== "");
287
+ const ciUrl = webLink(attempt.buildHref);
288
+ const failure = {
289
+ ...headline !== void 0 ? { headline } : {},
290
+ ...steps.length > 0 ? { steps } : {},
291
+ ...location !== void 0 ? { location } : {},
292
+ ...artifacts.length > 0 ? { artifacts } : {},
293
+ ...ciUrl !== void 0 ? { ciUrl } : {}
294
+ };
295
+ return Object.keys(failure).length === 0 ? void 0 : failure;
296
+ }
297
+ function repoRootOf(dir, stopAt) {
298
+ for (let here = (0, import_node_path2.resolve)(dir); ; here = (0, import_node_path2.dirname)(here)) {
299
+ if ((0, import_node_fs2.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
300
+ if (here === stopAt || (0, import_node_path2.dirname)(here) === here) return void 0;
301
+ }
302
+ }
303
+ function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
304
+ const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
305
+ return cut(machineless(text, attempt.repoRoot, home));
306
+ }
307
+ var mediaTypeOf = (contentType) => contentType?.split(";")[0]?.trim().toLowerCase() || void 0;
308
+ var TEXT_TYPES = /* @__PURE__ */ new Set(["application/json", "text/plain"]);
309
+ var isTextType = (contentType) => TEXT_TYPES.has(mediaTypeOf(contentType) ?? "");
310
+ function filesOf(attempt) {
311
+ return attempt.attachments.filter((a) => a.path !== void 0 || a.body !== void 0 && isTextType(a.contentType));
312
+ }
313
+ function machineless(raw, repoRoot, home) {
314
+ let text = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
315
+ const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
316
+ if (root !== void 0) text = text.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
317
+ const own = pathIn(home);
318
+ if (own !== void 0) text = text.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
319
+ if (repoRoot === void 0) text = text.replace(ANY_HOME, "~");
320
+ return text;
321
+ }
322
+ var PATH_START = "(?<![\\w.~%/\\\\-])";
323
+ var TEXT_LIMIT = 524288;
324
+ var HEADLINE_LIMIT = 8 * 1024;
325
+ var jsonBytes = (text) => Buffer.byteLength(JSON.stringify(text)) - 2;
326
+ var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:(?:file|webpack|webpack-internal):(?:\\*\/){2,3})?(?:\\*\/home\\*\/|\\*\/Users\\*\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
327
+ function pathIn(path3) {
328
+ const parts = path3.split(/[\\/]+/);
329
+ while (parts.length > 1 && parts.at(-1) === "") parts.pop();
330
+ if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
331
+ const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
332
+ const escaped = /^\/(?!\/)/.test(path3) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
333
+ const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
334
+ return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
335
+ }
336
+ var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
337
+ function cut(text) {
338
+ if (jsonBytes(text) <= TEXT_LIMIT) return text;
339
+ const lines = text.split("\n");
340
+ const size = lines.map((line) => jsonBytes(line) + 2);
341
+ const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
342
+ let used = 0;
343
+ let head = 0;
344
+ let tail = lines.length;
345
+ while (head < tail && used + size[head] <= budget / 2) used += size[head++];
346
+ while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
347
+ return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
348
+ }
349
+ var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
350
+ var METADATA_TYPE = "application/plune.metadata+json";
351
+ var REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
352
+ var FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
353
+ var FRAME_MAX = 8192;
354
+ var firstLine = (text) => (0, import_node_util.stripVTControlCharacters)(text).split("\n").map((line) => line.trim()).find((line) => line !== "");
355
+ function chosen({ status, errors }) {
356
+ if (status === "timedOut") {
357
+ const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
358
+ if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
359
+ }
360
+ return errors[0];
361
+ }
362
+ function chainOf(steps) {
363
+ const chain = [];
364
+ for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
365
+ chain.push(failed.title);
366
+ }
367
+ return chain;
368
+ }
369
+ function placeOf(error, testFile) {
370
+ const own = normal(testFile);
371
+ let first;
372
+ for (const line of (0, import_node_util.stripVTControlCharacters)(error.text).split("\n")) {
373
+ if (line.length > FRAME_MAX) continue;
374
+ const frame = FRAME.exec(line);
375
+ if (frame === null) continue;
376
+ const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
377
+ if (normal(place.file) === own) return place;
378
+ if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
379
+ }
380
+ return error.location ?? first;
381
+ }
382
+ function fileOf(raw) {
383
+ if (!raw.startsWith("file://")) return raw;
384
+ let path3 = raw.slice("file://".length);
385
+ try {
386
+ path3 = decodeURIComponent(path3);
387
+ } catch {
388
+ }
389
+ return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
390
+ }
391
+ var normal = (path3) => import_node_path2.posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
392
+ function fromRoot(place, root) {
393
+ if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
394
+ const base = normal(root).replace(/\/+$/, "");
395
+ const file = normal(place.file);
396
+ if (!file.startsWith(`${base}/`)) return void 0;
397
+ const relative = file.slice(base.length + 1);
398
+ if (!REPO_RELATIVE.test(relative)) return void 0;
399
+ const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
400
+ return column === void 0 ? { file: relative, line: place.line } : { file: relative, line: place.line, column };
401
+ }
402
+ function webLink(href) {
403
+ return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
404
+ }
250
405
 
251
406
  // src/reporter-core/fallback.ts
252
407
  var fs = __toESM(require("fs"), 1);
@@ -258,6 +413,8 @@ function appendBatch(file, batch) {
258
413
  ts: (/* @__PURE__ */ new Date()).toISOString(),
259
414
  runId: batch.runId,
260
415
  externalKey: batch.externalKey,
416
+ ...batch.session !== void 0 ? { session: batch.session } : {},
417
+ ...batch.meta !== void 0 ? { meta: batch.meta } : {},
261
418
  results: batch.results
262
419
  };
263
420
  fs.appendFileSync(file, `${JSON.stringify(line)}
@@ -276,6 +433,9 @@ var COUNTED = ["accepted", "duplicate", "conflict", "rejected"];
276
433
  var SCREENSHOT_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
277
434
  var SCREENSHOT_BYTES = 2 * 1024 * 1024;
278
435
  var SCREENSHOTS_PER_RESULT = 20;
436
+ var TEXT_BYTES = 512 * 1024;
437
+ var TEXTS_PER_RESULT = 10;
438
+ var UTF8 = new import_node_util2.TextDecoder("utf-8", { fatal: true });
279
439
  var TYPE_BY_EXTENSION = {
280
440
  ".png": "image/png",
281
441
  ".jpg": "image/jpeg",
@@ -285,9 +445,9 @@ var TYPE_BY_EXTENSION = {
285
445
  var UPLOADS_AT_ONCE = 4;
286
446
  var NAME_MAX = 200;
287
447
  function nameOf(file) {
288
- const own = file.name.split(/[\\/]/).pop()?.trim() || (0, import_node_path2.basename)(file.path);
289
- const fileExt = (0, import_node_path2.extname)(file.path);
290
- const ownExt = (0, import_node_path2.extname)(own);
448
+ const own = file.name.split(/[\\/]/).pop()?.trim() || (file.path === void 0 ? "" : (0, import_node_path3.basename)(file.path));
449
+ const fileExt = file.path === void 0 ? "" : (0, import_node_path3.extname)(file.path);
450
+ const ownExt = (0, import_node_path3.extname)(own);
291
451
  const typed = ownExt !== "" && (ownExt.toLowerCase() === fileExt.toLowerCase() || TYPE_BY_EXTENSION[ownExt.toLowerCase()] !== void 0);
292
452
  const ext = typed ? ownExt : fileExt;
293
453
  let stem = typed ? own.slice(0, -ownExt.length) : own;
@@ -302,8 +462,46 @@ var SKIPPED = {
302
462
  type: "Plune takes PNG, JPEG and WebP",
303
463
  limit: `a result holds at most ${SCREENSHOTS_PER_RESULT}`,
304
464
  missing: "the file cannot be read on this machine",
305
- size: "Plune takes a file of at most 2 MB"
465
+ size: "Plune takes a file of at most 2 MB",
466
+ textLimit: `a result holds at most ${TEXTS_PER_RESULT} text files`,
467
+ textSize: "Plune takes a text file of at most 512 KiB",
468
+ empty: "the file is empty",
469
+ binary: "the file holds a NUL byte, and Plune takes UTF-8 text without one",
470
+ encoding: "Plune takes UTF-8 text only"
471
+ };
472
+ var KINDS = {
473
+ screenshot: { label: "screenshot", bytes: SCREENSHOT_BYTES, room: SCREENSHOTS_PER_RESULT, limit: "limit", size: "size" },
474
+ text: { label: "text file", bytes: TEXT_BYTES, room: TEXTS_PER_RESULT, limit: "textLimit", size: "textSize" }
306
475
  };
476
+ function kindOf(type) {
477
+ if (type.startsWith("image/")) return "screenshot";
478
+ return TEXT_TYPES.has(type) ? "text" : void 0;
479
+ }
480
+ function textTypeOf(contentType, type) {
481
+ const charset = /;\s*charset\s*=\s*"?([^\s";]+)"?/i.exec(contentType)?.[1];
482
+ return charset?.toLowerCase() === "utf-8" ? `${type}; charset=utf-8` : type;
483
+ }
484
+ function textRefusal(bytes) {
485
+ if (bytes.length > TEXT_BYTES) return "textSize";
486
+ if (bytes.length === 0) return "empty";
487
+ if (bytes.includes(0)) return "binary";
488
+ try {
489
+ UTF8.decode(bytes);
490
+ } catch {
491
+ return "encoding";
492
+ }
493
+ return void 0;
494
+ }
495
+ function onDisk(result) {
496
+ if (!("files" in result) || result.files === void 0) return result;
497
+ const { files, ...rest } = result;
498
+ const kept = files.filter((f) => f.path !== void 0).map(({ body: _body, ...file }) => file);
499
+ return kept.length === 0 ? rest : { ...rest, files: kept };
500
+ }
501
+ function tally(counts, noun) {
502
+ if (counts.uploaded + counts.skipped + counts.failed === 0) return void 0;
503
+ return `${counts.uploaded} ${noun}(s) uploaded` + (counts.skipped > 0 ? `, ${counts.skipped} skipped` : "") + (counts.failed > 0 ? `, ${counts.failed} failed to upload` : "");
504
+ }
307
505
  function boundKey(key) {
308
506
  if (key.value.length <= KEY_MAX) return key;
309
507
  const digest = (0, import_node_crypto.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
@@ -311,6 +509,12 @@ function boundKey(key) {
311
509
  if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
312
510
  return { ...key, value: `${head}#${digest}` };
313
511
  }
512
+ function whyUnoffered(out) {
513
+ if (out.ok || out.status !== 429) return "other";
514
+ if (/review queue quota reached/i.test(out.detail)) return "queue";
515
+ if (/test case quota reached/i.test(out.detail)) return "cases";
516
+ return "other";
517
+ }
314
518
  function boundTitle(title) {
315
519
  return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
316
520
  }
@@ -354,6 +558,7 @@ async function startRun(passed, declared) {
354
558
  `));
355
559
  const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
356
560
  const externalKey = cfg.externalKey ?? null;
561
+ const sessionMarker = (0, import_node_crypto.randomUUID)();
357
562
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
358
563
  const offerDiscovered = cfg.offerDiscovered ?? false;
359
564
  const token = cfg.token ?? loadToken() ?? "";
@@ -372,7 +577,8 @@ async function startRun(passed, declared) {
372
577
  created: 0,
373
578
  unoffered: 0,
374
579
  deferred: 0,
375
- screenshots: { uploaded: 0, skipped: 0, failed: 0 }
580
+ screenshots: { uploaded: 0, skipped: 0, failed: 0 },
581
+ textFiles: { uploaded: 0, skipped: 0, failed: 0 }
376
582
  };
377
583
  const resolved = /* @__PURE__ */ new Map();
378
584
  let buffer = [];
@@ -383,6 +589,7 @@ async function startRun(passed, declared) {
383
589
  let done = false;
384
590
  const shortened = /* @__PURE__ */ new Set();
385
591
  const skipsSaid = /* @__PURE__ */ new Set();
592
+ const countsOf = (kind) => kind === "text" ? stats.textFiles : stats.screenshots;
386
593
  function bound(keys) {
387
594
  return keys.map((key) => {
388
595
  const fit = boundKey(key);
@@ -405,7 +612,13 @@ async function startRun(passed, declared) {
405
612
  log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
406
613
  }
407
614
  function defer(results) {
408
- appendBatch(fallbackPath, { runId, externalKey, results });
615
+ appendBatch(fallbackPath, {
616
+ runId,
617
+ externalKey,
618
+ session: sessionMarker,
619
+ ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
620
+ results: results.map(onDisk)
621
+ });
409
622
  stats.deferred += results.length;
410
623
  }
411
624
  async function resolveKeys(values) {
@@ -502,6 +715,7 @@ async function startRun(passed, declared) {
502
715
  });
503
716
  if (!out.ok || !Array.isArray(out.body?.results)) {
504
717
  stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
718
+ stats.unofferedWhy = whyUnoffered(out);
505
719
  log(
506
720
  `plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.ok ? "the answer had no results" : out.detail || out.kind}).`
507
721
  );
@@ -511,57 +725,74 @@ async function startRun(passed, declared) {
511
725
  stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
512
726
  }
513
727
  }
514
- function skip(name, why) {
515
- stats.screenshots.skipped += 1;
516
- if (skipsSaid.has(why)) return;
517
- skipsSaid.add(why);
518
- log(`plune: screenshot "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
728
+ function skip(kind, name, why) {
729
+ countsOf(kind).skipped += 1;
730
+ if (skipsSaid.has(`${kind}/${why}`)) return;
731
+ skipsSaid.add(`${kind}/${why}`);
732
+ log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
519
733
  }
520
- async function uploadOne(shot) {
734
+ async function uploadOne(job) {
735
+ const { kind } = job;
736
+ const { label, bytes: ceiling, size } = KINDS[kind];
521
737
  let bytes;
522
- try {
523
- if ((await import_node_fs2.promises.stat(shot.path)).size > SCREENSHOT_BYTES) return skip(shot.name, "size");
524
- bytes = await import_node_fs2.promises.readFile(shot.path);
525
- } catch {
526
- return skip(shot.name, "missing");
738
+ if (job.body !== void 0) bytes = job.body;
739
+ else if (job.path === void 0) return skip(kind, job.name, "missing");
740
+ else {
741
+ try {
742
+ if ((await import_node_fs3.promises.stat(job.path)).size > ceiling) return skip(kind, job.name, size);
743
+ bytes = await import_node_fs3.promises.readFile(job.path);
744
+ } catch {
745
+ return skip(kind, job.name, "missing");
746
+ }
527
747
  }
748
+ const refusal = kind === "text" ? textRefusal(bytes) : void 0;
749
+ if (refusal !== void 0) return skip(kind, job.name, refusal);
528
750
  const out = await client.upload(
529
- `/v1/results/${encodeURIComponent(shot.id)}/files?name=${encodeURIComponent(shot.name)}`,
751
+ `/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
530
752
  bytes,
531
- shot.type
753
+ job.type
532
754
  );
755
+ const counts = countsOf(kind);
533
756
  if (out.ok) {
534
- stats.screenshots.uploaded += 1;
757
+ counts.uploaded += 1;
535
758
  return;
536
759
  }
537
- stats.screenshots.failed += 1;
538
- if (stats.screenshots.failed === 1) {
539
- log(`plune: could not upload a screenshot (${out.detail || out.kind}) \u2014 its result is reported without it.`);
760
+ counts.failed += 1;
761
+ if (counts.failed === 1) {
762
+ log(`plune: could not upload a ${label} (${out.detail || out.kind}) \u2014 its result is reported without it.`);
540
763
  }
541
764
  }
542
765
  async function upload(items, filesByKey) {
543
766
  if (filesByKey.size === 0 || !Array.isArray(items)) return;
544
- const shots = [];
767
+ const jobs = [];
545
768
  for (const item of items) {
546
769
  if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
547
- let room = SCREENSHOTS_PER_RESULT;
770
+ const room = { screenshot: KINDS.screenshot.room, text: KINDS.text.room };
548
771
  for (const file of filesByKey.get(item.resultKey) ?? []) {
549
- const type = file.contentType?.split(";")[0]?.trim().toLowerCase() || TYPE_BY_EXTENSION[(0, import_node_path2.extname)(file.path).toLowerCase()];
550
- if (!type?.startsWith("image/")) continue;
772
+ const type = mediaTypeOf(file.contentType) ?? (file.path === void 0 ? void 0 : TYPE_BY_EXTENSION[(0, import_node_path3.extname)(file.path).toLowerCase()]);
773
+ const kind = type === void 0 ? void 0 : kindOf(type);
774
+ if (type === void 0 || kind === void 0 || kind === "screenshot" && file.path === void 0) continue;
551
775
  const name = nameOf(file);
552
- if (file.name.startsWith("_")) skip(name, "hidden");
553
- else if (!SCREENSHOT_TYPES.has(type)) skip(name, "type");
554
- else if (room === 0) skip(name, "limit");
776
+ if (file.name.startsWith("_")) skip(kind, name, "hidden");
777
+ else if (kind === "screenshot" && !SCREENSHOT_TYPES.has(type)) skip(kind, name, "type");
778
+ else if (room[kind] === 0) skip(kind, name, KINDS[kind].limit);
555
779
  else {
556
- room -= 1;
557
- shots.push({ id: item.id, name, type, path: file.path });
780
+ room[kind] -= 1;
781
+ jobs.push({
782
+ id: item.id,
783
+ kind,
784
+ name,
785
+ type: kind === "text" ? textTypeOf(file.contentType ?? "", type) : type,
786
+ ...file.body !== void 0 ? { body: file.body } : {},
787
+ ...file.path !== void 0 ? { path: file.path } : {}
788
+ });
558
789
  }
559
790
  }
560
791
  }
561
792
  let next = 0;
562
793
  await Promise.all(
563
- Array.from({ length: Math.min(UPLOADS_AT_ONCE, shots.length) }, async () => {
564
- while (next < shots.length) await uploadOne(shots[next++]);
794
+ Array.from({ length: Math.min(UPLOADS_AT_ONCE, jobs.length) }, async () => {
795
+ while (next < jobs.length) await uploadOne(jobs[next++]);
565
796
  })
566
797
  );
567
798
  }
@@ -639,11 +870,8 @@ async function startRun(passed, declared) {
639
870
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
640
871
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
641
872
  if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
642
- const shots = stats.screenshots;
643
- if (shots.uploaded + shots.skipped + shots.failed > 0) {
644
- parts.push(
645
- `${shots.uploaded} screenshot(s) uploaded` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} failed to upload` : "")
646
- );
873
+ for (const said of [tally(stats.screenshots, "screenshot"), tally(stats.textFiles, "text file")]) {
874
+ if (said !== void 0) parts.push(said);
647
875
  }
648
876
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
649
877
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
@@ -710,140 +938,13 @@ function resultKey(testId, retry) {
710
938
  const plain = `${testId}#${retry}`;
711
939
  return plain.length <= RESULT_KEY_MAX ? plain : `${sha256(testId).slice(0, HASH_CHARS)}#${retry}`;
712
940
  }
713
-
714
- // src/reporter-core/failure-detail.ts
715
- var import_node_fs3 = require("fs");
716
- var import_node_os2 = require("os");
717
- var import_node_path3 = require("path");
718
- var import_node_util = require("util");
719
- function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
720
- const error = chosen(attempt);
721
- const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
722
- const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
723
- const steps = chainOf(attempt.steps);
724
- const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
725
- 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 !== "");
726
- const ciUrl = webLink(attempt.buildHref);
727
- const failure = {
728
- ...headline !== void 0 ? { headline } : {},
729
- ...steps.length > 0 ? { steps } : {},
730
- ...location !== void 0 ? { location } : {},
731
- ...artifacts.length > 0 ? { artifacts } : {},
732
- ...ciUrl !== void 0 ? { ciUrl } : {}
733
- };
734
- return Object.keys(failure).length === 0 ? void 0 : failure;
735
- }
736
- function repoRootOf(dir, stopAt) {
737
- for (let here = (0, import_node_path3.resolve)(dir); ; here = (0, import_node_path3.dirname)(here)) {
738
- if ((0, import_node_fs3.existsSync)((0, import_node_path3.join)(here, ".git"))) return here;
739
- if (here === stopAt || (0, import_node_path3.dirname)(here) === here) return void 0;
740
- }
741
- }
742
- function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
743
- const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
744
- return cut(machineless(text, attempt.repoRoot, home));
745
- }
746
- function filesOf(attempt) {
747
- return attempt.attachments.filter((a) => a.path !== void 0);
748
- }
749
- function machineless(raw, repoRoot, home) {
750
- let text = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
751
- const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
752
- if (root !== void 0) text = text.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
753
- const own = pathIn(home);
754
- if (own !== void 0) text = text.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
755
- if (repoRoot === void 0) text = text.replace(ANY_HOME, "~");
756
- return text;
757
- }
758
- var PATH_START = "(?<![\\w.~%/\\\\-])";
759
- var TEXT_LIMIT = 524288;
760
- var HEADLINE_LIMIT = 8 * 1024;
761
- var jsonBytes = (text) => Buffer.byteLength(JSON.stringify(text)) - 2;
762
- var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:(?:file|webpack|webpack-internal):(?:\\*\/){2,3})?(?:\\*\/home\\*\/|\\*\/Users\\*\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
763
- function pathIn(path3) {
764
- const parts = path3.split(/[\\/]+/);
765
- while (parts.length > 1 && parts.at(-1) === "") parts.pop();
766
- if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
767
- const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
768
- const escaped = /^\/(?!\/)/.test(path3) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
769
- const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
770
- return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
771
- }
772
- var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
773
- function cut(text) {
774
- if (jsonBytes(text) <= TEXT_LIMIT) return text;
775
- const lines = text.split("\n");
776
- const size = lines.map((line) => jsonBytes(line) + 2);
777
- const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
778
- let used = 0;
779
- let head = 0;
780
- let tail = lines.length;
781
- while (head < tail && used + size[head] <= budget / 2) used += size[head++];
782
- while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
783
- return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
784
- }
785
- var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
786
- var METADATA_TYPE = "application/plune.metadata+json";
787
- var REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
788
- var FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
789
- var FRAME_MAX = 8192;
790
- var firstLine = (text) => (0, import_node_util.stripVTControlCharacters)(text).split("\n").map((line) => line.trim()).find((line) => line !== "");
791
- function chosen({ status, errors }) {
792
- if (status === "timedOut") {
793
- const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
794
- if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
795
- }
796
- return errors[0];
797
- }
798
- function chainOf(steps) {
799
- const chain = [];
800
- for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
801
- chain.push(failed.title);
802
- }
803
- return chain;
804
- }
805
- function placeOf(error, testFile) {
806
- const own = normal(testFile);
807
- let first;
808
- for (const line of (0, import_node_util.stripVTControlCharacters)(error.text).split("\n")) {
809
- if (line.length > FRAME_MAX) continue;
810
- const frame = FRAME.exec(line);
811
- if (frame === null) continue;
812
- const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
813
- if (normal(place.file) === own) return place;
814
- if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
815
- }
816
- return error.location ?? first;
817
- }
818
- function fileOf(raw) {
819
- if (!raw.startsWith("file://")) return raw;
820
- let path3 = raw.slice("file://".length);
821
- try {
822
- path3 = decodeURIComponent(path3);
823
- } catch {
824
- }
825
- return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
826
- }
827
- var normal = (path3) => import_node_path3.posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
828
- function fromRoot(place, root) {
829
- if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
830
- const base = normal(root).replace(/\/+$/, "");
831
- const file = normal(place.file);
832
- if (!file.startsWith(`${base}/`)) return void 0;
833
- const relative = file.slice(base.length + 1);
834
- if (!REPO_RELATIVE.test(relative)) return void 0;
835
- const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
836
- return column === void 0 ? { file: relative, line: place.line } : { file: relative, line: place.line, column };
837
- }
838
- function webLink(href) {
839
- return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
840
- }
841
941
  // Annotate the CommonJS export names for ESM import in node:
842
942
  0 && (module.exports = {
843
943
  DEFAULT_FALLBACK_PATH,
844
944
  RESULT_KEY_MAX,
845
945
  UNSUPPORTED_VARS,
846
946
  appendBatch,
947
+ ciCommit,
847
948
  createClient,
848
949
  errorContextOf,
849
950
  failureOf,