@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.
@@ -1,7 +1,8 @@
1
1
  // src/reporter-core/session.ts
2
- import { createHash } from "crypto";
2
+ import { createHash, randomUUID } from "crypto";
3
3
  import { promises as disk } from "fs";
4
4
  import { basename as basename2, extname } from "path";
5
+ import { TextDecoder } from "util";
5
6
 
6
7
  // src/cli/api-url.ts
7
8
  var DEFAULT_API_URL = "https://beta-api.plune.ai";
@@ -199,6 +200,159 @@ function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cw
199
200
  const where = flag(env, "CI") || flag(env, "GITHUB_ACTIONS") ? "ci" : "local";
200
201
  return `${path.basename(cwd)} \xB7 ${stamp} \xB7 ${where}`;
201
202
  }
203
+ var PROVIDERS = [
204
+ { is: "GITHUB_ACTIONS", sha: "GITHUB_SHA", branch: ["GITHUB_HEAD_REF", "GITHUB_REF_NAME"] },
205
+ { is: "GITLAB_CI", sha: "CI_COMMIT_SHA", branch: ["CI_COMMIT_REF_NAME"] },
206
+ { is: "JENKINS_URL", sha: "GIT_COMMIT", branch: ["GIT_BRANCH"] }
207
+ ];
208
+ function ciCommit(reported, env = process.env) {
209
+ const said = typeof reported === "object" && reported !== null ? reported : {};
210
+ const text = (raw) => typeof raw === "string" && raw.trim() !== "" ? raw.trim() : void 0;
211
+ const provider = PROVIDERS.find((p) => value(env, p.is) !== void 0);
212
+ const job = {
213
+ sha: provider === void 0 ? void 0 : value(env, provider.sha),
214
+ branch: provider?.branch.map((name) => value(env, name)).find((v) => v !== void 0)
215
+ };
216
+ const sha = text(said["commitHash"]);
217
+ const sameCommit = sha === void 0 || sha === job.sha;
218
+ const commit = sha ?? job.sha;
219
+ const branch = text(said["branch"]) ?? (sameCommit ? job.branch : void 0);
220
+ return {
221
+ ...commit !== void 0 ? { sha: commit } : {},
222
+ ...branch !== void 0 ? { branch } : {}
223
+ };
224
+ }
225
+
226
+ // src/reporter-core/failure-detail.ts
227
+ import { existsSync as existsSync2 } from "fs";
228
+ import { homedir as homedir2 } from "os";
229
+ import { dirname as dirname2, join as join2, posix, resolve } from "path";
230
+ import { stripVTControlCharacters } from "util";
231
+ function failureOf(attempt, home = homedir2()) {
232
+ const error = chosen(attempt);
233
+ const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
234
+ const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
235
+ const steps = chainOf(attempt.steps);
236
+ const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
237
+ 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 !== "");
238
+ const ciUrl = webLink(attempt.buildHref);
239
+ const failure = {
240
+ ...headline !== void 0 ? { headline } : {},
241
+ ...steps.length > 0 ? { steps } : {},
242
+ ...location !== void 0 ? { location } : {},
243
+ ...artifacts.length > 0 ? { artifacts } : {},
244
+ ...ciUrl !== void 0 ? { ciUrl } : {}
245
+ };
246
+ return Object.keys(failure).length === 0 ? void 0 : failure;
247
+ }
248
+ function repoRootOf(dir, stopAt) {
249
+ for (let here = resolve(dir); ; here = dirname2(here)) {
250
+ if (existsSync2(join2(here, ".git"))) return here;
251
+ if (here === stopAt || dirname2(here) === here) return void 0;
252
+ }
253
+ }
254
+ function errorContextOf(attempt, home = homedir2()) {
255
+ const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
256
+ return cut(machineless(text, attempt.repoRoot, home));
257
+ }
258
+ var mediaTypeOf = (contentType) => contentType?.split(";")[0]?.trim().toLowerCase() || void 0;
259
+ var TEXT_TYPES = /* @__PURE__ */ new Set(["application/json", "text/plain"]);
260
+ var isTextType = (contentType) => TEXT_TYPES.has(mediaTypeOf(contentType) ?? "");
261
+ function filesOf(attempt) {
262
+ return attempt.attachments.filter((a) => a.path !== void 0 || a.body !== void 0 && isTextType(a.contentType));
263
+ }
264
+ function machineless(raw, repoRoot, home) {
265
+ let text = stripVTControlCharacters(raw).replaceAll("\x1B", "");
266
+ const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
267
+ if (root !== void 0) text = text.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
268
+ const own = pathIn(home);
269
+ if (own !== void 0) text = text.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
270
+ if (repoRoot === void 0) text = text.replace(ANY_HOME, "~");
271
+ return text;
272
+ }
273
+ var PATH_START = "(?<![\\w.~%/\\\\-])";
274
+ var TEXT_LIMIT = 524288;
275
+ var HEADLINE_LIMIT = 8 * 1024;
276
+ var jsonBytes = (text) => Buffer.byteLength(JSON.stringify(text)) - 2;
277
+ var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:(?:file|webpack|webpack-internal):(?:\\*\/){2,3})?(?:\\*\/home\\*\/|\\*\/Users\\*\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
278
+ function pathIn(path3) {
279
+ const parts = path3.split(/[\\/]+/);
280
+ while (parts.length > 1 && parts.at(-1) === "") parts.pop();
281
+ if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
282
+ const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
283
+ const escaped = /^\/(?!\/)/.test(path3) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
284
+ const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
285
+ return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
286
+ }
287
+ var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
288
+ function cut(text) {
289
+ if (jsonBytes(text) <= TEXT_LIMIT) return text;
290
+ const lines = text.split("\n");
291
+ const size = lines.map((line) => jsonBytes(line) + 2);
292
+ const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
293
+ let used = 0;
294
+ let head = 0;
295
+ let tail = lines.length;
296
+ while (head < tail && used + size[head] <= budget / 2) used += size[head++];
297
+ while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
298
+ return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
299
+ }
300
+ var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
301
+ var METADATA_TYPE = "application/plune.metadata+json";
302
+ var REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
303
+ var FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
304
+ var FRAME_MAX = 8192;
305
+ var firstLine = (text) => stripVTControlCharacters(text).split("\n").map((line) => line.trim()).find((line) => line !== "");
306
+ function chosen({ status, errors }) {
307
+ if (status === "timedOut") {
308
+ const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
309
+ if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
310
+ }
311
+ return errors[0];
312
+ }
313
+ function chainOf(steps) {
314
+ const chain = [];
315
+ for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
316
+ chain.push(failed.title);
317
+ }
318
+ return chain;
319
+ }
320
+ function placeOf(error, testFile) {
321
+ const own = normal(testFile);
322
+ let first;
323
+ for (const line of stripVTControlCharacters(error.text).split("\n")) {
324
+ if (line.length > FRAME_MAX) continue;
325
+ const frame = FRAME.exec(line);
326
+ if (frame === null) continue;
327
+ const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
328
+ if (normal(place.file) === own) return place;
329
+ if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
330
+ }
331
+ return error.location ?? first;
332
+ }
333
+ function fileOf(raw) {
334
+ if (!raw.startsWith("file://")) return raw;
335
+ let path3 = raw.slice("file://".length);
336
+ try {
337
+ path3 = decodeURIComponent(path3);
338
+ } catch {
339
+ }
340
+ return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
341
+ }
342
+ var normal = (path3) => posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
343
+ function fromRoot(place, root) {
344
+ if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
345
+ const base = normal(root).replace(/\/+$/, "");
346
+ const file = normal(place.file);
347
+ if (!file.startsWith(`${base}/`)) return void 0;
348
+ const relative = file.slice(base.length + 1);
349
+ if (!REPO_RELATIVE.test(relative)) return void 0;
350
+ const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
351
+ return column === void 0 ? { file: relative, line: place.line } : { file: relative, line: place.line, column };
352
+ }
353
+ function webLink(href) {
354
+ return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
355
+ }
202
356
 
203
357
  // src/reporter-core/fallback.ts
204
358
  import * as fs from "fs";
@@ -210,6 +364,8 @@ function appendBatch(file, batch) {
210
364
  ts: (/* @__PURE__ */ new Date()).toISOString(),
211
365
  runId: batch.runId,
212
366
  externalKey: batch.externalKey,
367
+ ...batch.session !== void 0 ? { session: batch.session } : {},
368
+ ...batch.meta !== void 0 ? { meta: batch.meta } : {},
213
369
  results: batch.results
214
370
  };
215
371
  fs.appendFileSync(file, `${JSON.stringify(line)}
@@ -228,6 +384,9 @@ var COUNTED = ["accepted", "duplicate", "conflict", "rejected"];
228
384
  var SCREENSHOT_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
229
385
  var SCREENSHOT_BYTES = 2 * 1024 * 1024;
230
386
  var SCREENSHOTS_PER_RESULT = 20;
387
+ var TEXT_BYTES = 512 * 1024;
388
+ var TEXTS_PER_RESULT = 10;
389
+ var UTF8 = new TextDecoder("utf-8", { fatal: true });
231
390
  var TYPE_BY_EXTENSION = {
232
391
  ".png": "image/png",
233
392
  ".jpg": "image/jpeg",
@@ -237,8 +396,8 @@ var TYPE_BY_EXTENSION = {
237
396
  var UPLOADS_AT_ONCE = 4;
238
397
  var NAME_MAX = 200;
239
398
  function nameOf(file) {
240
- const own = file.name.split(/[\\/]/).pop()?.trim() || basename2(file.path);
241
- const fileExt = extname(file.path);
399
+ const own = file.name.split(/[\\/]/).pop()?.trim() || (file.path === void 0 ? "" : basename2(file.path));
400
+ const fileExt = file.path === void 0 ? "" : extname(file.path);
242
401
  const ownExt = extname(own);
243
402
  const typed = ownExt !== "" && (ownExt.toLowerCase() === fileExt.toLowerCase() || TYPE_BY_EXTENSION[ownExt.toLowerCase()] !== void 0);
244
403
  const ext = typed ? ownExt : fileExt;
@@ -254,8 +413,46 @@ var SKIPPED = {
254
413
  type: "Plune takes PNG, JPEG and WebP",
255
414
  limit: `a result holds at most ${SCREENSHOTS_PER_RESULT}`,
256
415
  missing: "the file cannot be read on this machine",
257
- size: "Plune takes a file of at most 2 MB"
416
+ size: "Plune takes a file of at most 2 MB",
417
+ textLimit: `a result holds at most ${TEXTS_PER_RESULT} text files`,
418
+ textSize: "Plune takes a text file of at most 512 KiB",
419
+ empty: "the file is empty",
420
+ binary: "the file holds a NUL byte, and Plune takes UTF-8 text without one",
421
+ encoding: "Plune takes UTF-8 text only"
422
+ };
423
+ var KINDS = {
424
+ screenshot: { label: "screenshot", bytes: SCREENSHOT_BYTES, room: SCREENSHOTS_PER_RESULT, limit: "limit", size: "size" },
425
+ text: { label: "text file", bytes: TEXT_BYTES, room: TEXTS_PER_RESULT, limit: "textLimit", size: "textSize" }
258
426
  };
427
+ function kindOf(type) {
428
+ if (type.startsWith("image/")) return "screenshot";
429
+ return TEXT_TYPES.has(type) ? "text" : void 0;
430
+ }
431
+ function textTypeOf(contentType, type) {
432
+ const charset = /;\s*charset\s*=\s*"?([^\s";]+)"?/i.exec(contentType)?.[1];
433
+ return charset?.toLowerCase() === "utf-8" ? `${type}; charset=utf-8` : type;
434
+ }
435
+ function textRefusal(bytes) {
436
+ if (bytes.length > TEXT_BYTES) return "textSize";
437
+ if (bytes.length === 0) return "empty";
438
+ if (bytes.includes(0)) return "binary";
439
+ try {
440
+ UTF8.decode(bytes);
441
+ } catch {
442
+ return "encoding";
443
+ }
444
+ return void 0;
445
+ }
446
+ function onDisk(result) {
447
+ if (!("files" in result) || result.files === void 0) return result;
448
+ const { files, ...rest } = result;
449
+ const kept = files.filter((f) => f.path !== void 0).map(({ body: _body, ...file }) => file);
450
+ return kept.length === 0 ? rest : { ...rest, files: kept };
451
+ }
452
+ function tally(counts, noun) {
453
+ if (counts.uploaded + counts.skipped + counts.failed === 0) return void 0;
454
+ return `${counts.uploaded} ${noun}(s) uploaded` + (counts.skipped > 0 ? `, ${counts.skipped} skipped` : "") + (counts.failed > 0 ? `, ${counts.failed} failed to upload` : "");
455
+ }
259
456
  function boundKey(key) {
260
457
  if (key.value.length <= KEY_MAX) return key;
261
458
  const digest = createHash("sha256").update(key.value).digest("hex").slice(0, 16);
@@ -263,6 +460,12 @@ function boundKey(key) {
263
460
  if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
264
461
  return { ...key, value: `${head}#${digest}` };
265
462
  }
463
+ function whyUnoffered(out) {
464
+ if (out.ok || out.status !== 429) return "other";
465
+ if (/review queue quota reached/i.test(out.detail)) return "queue";
466
+ if (/test case quota reached/i.test(out.detail)) return "cases";
467
+ return "other";
468
+ }
266
469
  function boundTitle(title) {
267
470
  return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
268
471
  }
@@ -306,6 +509,7 @@ async function startRun(passed, declared) {
306
509
  `));
307
510
  const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
308
511
  const externalKey = cfg.externalKey ?? null;
512
+ const sessionMarker = randomUUID();
309
513
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
310
514
  const offerDiscovered = cfg.offerDiscovered ?? false;
311
515
  const token = cfg.token ?? loadToken() ?? "";
@@ -324,7 +528,8 @@ async function startRun(passed, declared) {
324
528
  created: 0,
325
529
  unoffered: 0,
326
530
  deferred: 0,
327
- screenshots: { uploaded: 0, skipped: 0, failed: 0 }
531
+ screenshots: { uploaded: 0, skipped: 0, failed: 0 },
532
+ textFiles: { uploaded: 0, skipped: 0, failed: 0 }
328
533
  };
329
534
  const resolved = /* @__PURE__ */ new Map();
330
535
  let buffer = [];
@@ -335,6 +540,7 @@ async function startRun(passed, declared) {
335
540
  let done = false;
336
541
  const shortened = /* @__PURE__ */ new Set();
337
542
  const skipsSaid = /* @__PURE__ */ new Set();
543
+ const countsOf = (kind) => kind === "text" ? stats.textFiles : stats.screenshots;
338
544
  function bound(keys) {
339
545
  return keys.map((key) => {
340
546
  const fit = boundKey(key);
@@ -357,7 +563,13 @@ async function startRun(passed, declared) {
357
563
  log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
358
564
  }
359
565
  function defer(results) {
360
- appendBatch(fallbackPath, { runId, externalKey, results });
566
+ appendBatch(fallbackPath, {
567
+ runId,
568
+ externalKey,
569
+ session: sessionMarker,
570
+ ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
571
+ results: results.map(onDisk)
572
+ });
361
573
  stats.deferred += results.length;
362
574
  }
363
575
  async function resolveKeys(values) {
@@ -454,6 +666,7 @@ async function startRun(passed, declared) {
454
666
  });
455
667
  if (!out.ok || !Array.isArray(out.body?.results)) {
456
668
  stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
669
+ stats.unofferedWhy = whyUnoffered(out);
457
670
  log(
458
671
  `plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.ok ? "the answer had no results" : out.detail || out.kind}).`
459
672
  );
@@ -463,57 +676,74 @@ async function startRun(passed, declared) {
463
676
  stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
464
677
  }
465
678
  }
466
- function skip(name, why) {
467
- stats.screenshots.skipped += 1;
468
- if (skipsSaid.has(why)) return;
469
- skipsSaid.add(why);
470
- log(`plune: screenshot "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
679
+ function skip(kind, name, why) {
680
+ countsOf(kind).skipped += 1;
681
+ if (skipsSaid.has(`${kind}/${why}`)) return;
682
+ skipsSaid.add(`${kind}/${why}`);
683
+ log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
471
684
  }
472
- async function uploadOne(shot) {
685
+ async function uploadOne(job) {
686
+ const { kind } = job;
687
+ const { label, bytes: ceiling, size } = KINDS[kind];
473
688
  let bytes;
474
- try {
475
- if ((await disk.stat(shot.path)).size > SCREENSHOT_BYTES) return skip(shot.name, "size");
476
- bytes = await disk.readFile(shot.path);
477
- } catch {
478
- return skip(shot.name, "missing");
689
+ if (job.body !== void 0) bytes = job.body;
690
+ else if (job.path === void 0) return skip(kind, job.name, "missing");
691
+ else {
692
+ try {
693
+ if ((await disk.stat(job.path)).size > ceiling) return skip(kind, job.name, size);
694
+ bytes = await disk.readFile(job.path);
695
+ } catch {
696
+ return skip(kind, job.name, "missing");
697
+ }
479
698
  }
699
+ const refusal = kind === "text" ? textRefusal(bytes) : void 0;
700
+ if (refusal !== void 0) return skip(kind, job.name, refusal);
480
701
  const out = await client.upload(
481
- `/v1/results/${encodeURIComponent(shot.id)}/files?name=${encodeURIComponent(shot.name)}`,
702
+ `/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
482
703
  bytes,
483
- shot.type
704
+ job.type
484
705
  );
706
+ const counts = countsOf(kind);
485
707
  if (out.ok) {
486
- stats.screenshots.uploaded += 1;
708
+ counts.uploaded += 1;
487
709
  return;
488
710
  }
489
- stats.screenshots.failed += 1;
490
- if (stats.screenshots.failed === 1) {
491
- log(`plune: could not upload a screenshot (${out.detail || out.kind}) \u2014 its result is reported without it.`);
711
+ counts.failed += 1;
712
+ if (counts.failed === 1) {
713
+ log(`plune: could not upload a ${label} (${out.detail || out.kind}) \u2014 its result is reported without it.`);
492
714
  }
493
715
  }
494
716
  async function upload(items, filesByKey) {
495
717
  if (filesByKey.size === 0 || !Array.isArray(items)) return;
496
- const shots = [];
718
+ const jobs = [];
497
719
  for (const item of items) {
498
720
  if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
499
- let room = SCREENSHOTS_PER_RESULT;
721
+ const room = { screenshot: KINDS.screenshot.room, text: KINDS.text.room };
500
722
  for (const file of filesByKey.get(item.resultKey) ?? []) {
501
- const type = file.contentType?.split(";")[0]?.trim().toLowerCase() || TYPE_BY_EXTENSION[extname(file.path).toLowerCase()];
502
- if (!type?.startsWith("image/")) continue;
723
+ const type = mediaTypeOf(file.contentType) ?? (file.path === void 0 ? void 0 : TYPE_BY_EXTENSION[extname(file.path).toLowerCase()]);
724
+ const kind = type === void 0 ? void 0 : kindOf(type);
725
+ if (type === void 0 || kind === void 0 || kind === "screenshot" && file.path === void 0) continue;
503
726
  const name = nameOf(file);
504
- if (file.name.startsWith("_")) skip(name, "hidden");
505
- else if (!SCREENSHOT_TYPES.has(type)) skip(name, "type");
506
- else if (room === 0) skip(name, "limit");
727
+ if (file.name.startsWith("_")) skip(kind, name, "hidden");
728
+ else if (kind === "screenshot" && !SCREENSHOT_TYPES.has(type)) skip(kind, name, "type");
729
+ else if (room[kind] === 0) skip(kind, name, KINDS[kind].limit);
507
730
  else {
508
- room -= 1;
509
- shots.push({ id: item.id, name, type, path: file.path });
731
+ room[kind] -= 1;
732
+ jobs.push({
733
+ id: item.id,
734
+ kind,
735
+ name,
736
+ type: kind === "text" ? textTypeOf(file.contentType ?? "", type) : type,
737
+ ...file.body !== void 0 ? { body: file.body } : {},
738
+ ...file.path !== void 0 ? { path: file.path } : {}
739
+ });
510
740
  }
511
741
  }
512
742
  }
513
743
  let next = 0;
514
744
  await Promise.all(
515
- Array.from({ length: Math.min(UPLOADS_AT_ONCE, shots.length) }, async () => {
516
- while (next < shots.length) await uploadOne(shots[next++]);
745
+ Array.from({ length: Math.min(UPLOADS_AT_ONCE, jobs.length) }, async () => {
746
+ while (next < jobs.length) await uploadOne(jobs[next++]);
517
747
  })
518
748
  );
519
749
  }
@@ -591,11 +821,8 @@ async function startRun(passed, declared) {
591
821
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
592
822
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
593
823
  if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
594
- const shots = stats.screenshots;
595
- if (shots.uploaded + shots.skipped + shots.failed > 0) {
596
- parts.push(
597
- `${shots.uploaded} screenshot(s) uploaded` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} failed to upload` : "")
598
- );
824
+ for (const said of [tally(stats.screenshots, "screenshot"), tally(stats.textFiles, "text file")]) {
825
+ if (said !== void 0) parts.push(said);
599
826
  }
600
827
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
601
828
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
@@ -662,139 +889,12 @@ function resultKey(testId, retry) {
662
889
  const plain = `${testId}#${retry}`;
663
890
  return plain.length <= RESULT_KEY_MAX ? plain : `${sha256(testId).slice(0, HASH_CHARS)}#${retry}`;
664
891
  }
665
-
666
- // src/reporter-core/failure-detail.ts
667
- import { existsSync as existsSync2 } from "fs";
668
- import { homedir as homedir2 } from "os";
669
- import { dirname as dirname3, join as join3, posix, resolve } from "path";
670
- import { stripVTControlCharacters } from "util";
671
- function failureOf(attempt, home = homedir2()) {
672
- const error = chosen(attempt);
673
- const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
674
- const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
675
- const steps = chainOf(attempt.steps);
676
- const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
677
- 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 !== "");
678
- const ciUrl = webLink(attempt.buildHref);
679
- const failure = {
680
- ...headline !== void 0 ? { headline } : {},
681
- ...steps.length > 0 ? { steps } : {},
682
- ...location !== void 0 ? { location } : {},
683
- ...artifacts.length > 0 ? { artifacts } : {},
684
- ...ciUrl !== void 0 ? { ciUrl } : {}
685
- };
686
- return Object.keys(failure).length === 0 ? void 0 : failure;
687
- }
688
- function repoRootOf(dir, stopAt) {
689
- for (let here = resolve(dir); ; here = dirname3(here)) {
690
- if (existsSync2(join3(here, ".git"))) return here;
691
- if (here === stopAt || dirname3(here) === here) return void 0;
692
- }
693
- }
694
- function errorContextOf(attempt, home = homedir2()) {
695
- const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
696
- return cut(machineless(text, attempt.repoRoot, home));
697
- }
698
- function filesOf(attempt) {
699
- return attempt.attachments.filter((a) => a.path !== void 0);
700
- }
701
- function machineless(raw, repoRoot, home) {
702
- let text = stripVTControlCharacters(raw).replaceAll("\x1B", "");
703
- const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
704
- if (root !== void 0) text = text.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
705
- const own = pathIn(home);
706
- if (own !== void 0) text = text.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
707
- if (repoRoot === void 0) text = text.replace(ANY_HOME, "~");
708
- return text;
709
- }
710
- var PATH_START = "(?<![\\w.~%/\\\\-])";
711
- var TEXT_LIMIT = 524288;
712
- var HEADLINE_LIMIT = 8 * 1024;
713
- var jsonBytes = (text) => Buffer.byteLength(JSON.stringify(text)) - 2;
714
- var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:(?:file|webpack|webpack-internal):(?:\\*\/){2,3})?(?:\\*\/home\\*\/|\\*\/Users\\*\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
715
- function pathIn(path3) {
716
- const parts = path3.split(/[\\/]+/);
717
- while (parts.length > 1 && parts.at(-1) === "") parts.pop();
718
- if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
719
- const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
720
- const escaped = /^\/(?!\/)/.test(path3) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
721
- const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
722
- return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
723
- }
724
- var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
725
- function cut(text) {
726
- if (jsonBytes(text) <= TEXT_LIMIT) return text;
727
- const lines = text.split("\n");
728
- const size = lines.map((line) => jsonBytes(line) + 2);
729
- const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
730
- let used = 0;
731
- let head = 0;
732
- let tail = lines.length;
733
- while (head < tail && used + size[head] <= budget / 2) used += size[head++];
734
- while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
735
- return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
736
- }
737
- var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
738
- var METADATA_TYPE = "application/plune.metadata+json";
739
- var REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
740
- var FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
741
- var FRAME_MAX = 8192;
742
- var firstLine = (text) => stripVTControlCharacters(text).split("\n").map((line) => line.trim()).find((line) => line !== "");
743
- function chosen({ status, errors }) {
744
- if (status === "timedOut") {
745
- const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
746
- if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
747
- }
748
- return errors[0];
749
- }
750
- function chainOf(steps) {
751
- const chain = [];
752
- for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
753
- chain.push(failed.title);
754
- }
755
- return chain;
756
- }
757
- function placeOf(error, testFile) {
758
- const own = normal(testFile);
759
- let first;
760
- for (const line of stripVTControlCharacters(error.text).split("\n")) {
761
- if (line.length > FRAME_MAX) continue;
762
- const frame = FRAME.exec(line);
763
- if (frame === null) continue;
764
- const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
765
- if (normal(place.file) === own) return place;
766
- if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
767
- }
768
- return error.location ?? first;
769
- }
770
- function fileOf(raw) {
771
- if (!raw.startsWith("file://")) return raw;
772
- let path3 = raw.slice("file://".length);
773
- try {
774
- path3 = decodeURIComponent(path3);
775
- } catch {
776
- }
777
- return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
778
- }
779
- var normal = (path3) => posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
780
- function fromRoot(place, root) {
781
- if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
782
- const base = normal(root).replace(/\/+$/, "");
783
- const file = normal(place.file);
784
- if (!file.startsWith(`${base}/`)) return void 0;
785
- const relative = file.slice(base.length + 1);
786
- if (!REPO_RELATIVE.test(relative)) return void 0;
787
- const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
788
- return column === void 0 ? { file: relative, line: place.line } : { file: relative, line: place.line, column };
789
- }
790
- function webLink(href) {
791
- return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
792
- }
793
892
  export {
794
893
  DEFAULT_FALLBACK_PATH,
795
894
  RESULT_KEY_MAX,
796
895
  UNSUPPORTED_VARS,
797
896
  appendBatch,
897
+ ciCommit,
798
898
  createClient,
799
899
  errorContextOf,
800
900
  failureOf,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "AI-powered assertion testing for LLM apps — write assertions, run them against any provider, get a pass/fail report (local, CI, or diff).",
5
5
  "keywords": [
6
6
  "ai",