@plune-ai/cli 0.13.0 → 0.14.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.
@@ -28,7 +28,39 @@ function loadToken() {
28
28
  }
29
29
 
30
30
  // src/reporter-core/client.ts
31
+ import { request as httpRequest } from "http";
32
+ import { request as httpsRequest } from "https";
31
33
  var RETRY_DELAYS_MS = [300, 900];
34
+ var IDLE_MS = 3e5;
35
+ function nodeTransport(url, init, idleMs = IDLE_MS) {
36
+ return new Promise((resolve2, reject) => {
37
+ const target = new URL(url);
38
+ const send = target.protocol === "https:" ? httpsRequest : httpRequest;
39
+ const req = send(target, { method: init.method, headers: init.headers }, (res) => {
40
+ const chunks = [];
41
+ res.on("data", (chunk2) => chunks.push(chunk2));
42
+ res.on("error", reject);
43
+ res.on("end", () => {
44
+ const status = res.statusCode ?? 0;
45
+ const text = Buffer.concat(chunks).toString("utf8");
46
+ resolve2({
47
+ ok: status >= 200 && status <= 299,
48
+ status,
49
+ headers: {
50
+ get: (name) => {
51
+ const value2 = res.headers[name.toLowerCase()];
52
+ return value2 === void 0 ? null : Array.isArray(value2) ? value2.join(", ") : value2;
53
+ }
54
+ },
55
+ json: async () => JSON.parse(text)
56
+ });
57
+ });
58
+ });
59
+ req.setTimeout(idleMs, () => req.destroy(new Error(`no answer from ${target.origin} in ${idleMs} ms`)));
60
+ req.on("error", reject);
61
+ req.end(init.body);
62
+ });
63
+ }
32
64
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
33
65
  async function detailOf(res) {
34
66
  try {
@@ -52,7 +84,7 @@ function classify(status) {
52
84
  return "other";
53
85
  }
54
86
  function createClient(opts) {
55
- const doFetch = opts.fetchImpl ?? fetch;
87
+ const doFetch = opts.fetchImpl ?? nodeTransport;
56
88
  const wait = opts.wait ?? sleep;
57
89
  const base = opts.apiUrl.replace(/\/+$/, "");
58
90
  const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
@@ -195,6 +227,25 @@ function boundKey(key) {
195
227
  function boundTitle(title) {
196
228
  return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
197
229
  }
230
+ var BATCH_BYTES = 8 * 1024 * 1024;
231
+ var ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ results: [] }));
232
+ function packed(results) {
233
+ const out = [];
234
+ let part = [];
235
+ let bytes = ENVELOPE_BYTES;
236
+ for (const result of results) {
237
+ const size = Buffer.byteLength(JSON.stringify(result));
238
+ if (part.length > 0 && bytes + 1 + size > BATCH_BYTES) {
239
+ out.push(part);
240
+ part = [];
241
+ bytes = ENVELOPE_BYTES;
242
+ }
243
+ bytes += (part.length > 0 ? 1 : 0) + size;
244
+ part.push(result);
245
+ }
246
+ if (part.length > 0) out.push(part);
247
+ return out;
248
+ }
198
249
  function chunk(items, size) {
199
250
  const out = [];
200
251
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
@@ -396,27 +447,32 @@ async function startRun(passed, declared) {
396
447
  progress();
397
448
  return;
398
449
  }
399
- const out = await client.post(
400
- `/v1/runs/${runId}/results`,
401
- { results: submissions }
402
- );
403
- if (out.ok) {
404
- stats.accepted += out.body.counts.accepted ?? 0;
405
- stats.duplicate += out.body.counts.duplicate ?? 0;
406
- stats.conflict += out.body.counts.conflict ?? 0;
407
- stats.rejected += out.body.counts.rejected ?? 0;
408
- progress();
409
- return;
410
- }
411
- if (out.kind === "auth") {
412
- log('plune: the API token was refused \u2014 run "plune login" with a fresh one.');
413
- offline = true;
414
- } else if (out.kind === "conflict") {
415
- log(`plune: this run is closed \u2014 ${out.detail}`);
416
- } else {
417
- log(`plune: could not send results (${out.detail || out.kind}).`);
450
+ for (const part of packed(submissions)) {
451
+ if (offline) {
452
+ defer(part);
453
+ continue;
454
+ }
455
+ const out = await client.post(`/v1/runs/${runId}/results`, {
456
+ results: part
457
+ });
458
+ if (out.ok) {
459
+ stats.accepted += out.body.counts.accepted ?? 0;
460
+ stats.duplicate += out.body.counts.duplicate ?? 0;
461
+ stats.conflict += out.body.counts.conflict ?? 0;
462
+ stats.rejected += out.body.counts.rejected ?? 0;
463
+ progress();
464
+ continue;
465
+ }
466
+ if (out.kind === "auth") {
467
+ log('plune: the API token was refused \u2014 run "plune login" with a fresh one.');
468
+ offline = true;
469
+ } else if (out.kind === "conflict") {
470
+ log(`plune: this run is closed \u2014 ${out.detail}`);
471
+ } else {
472
+ log(`plune: could not send results (${out.detail || out.kind}).`);
473
+ }
474
+ defer(part);
418
475
  }
419
- defer(submissions);
420
476
  }
421
477
  function progress() {
422
478
  const total = expected?.length ?? 0;
@@ -432,9 +488,12 @@ async function startRun(passed, declared) {
432
488
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
433
489
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
434
490
  if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
435
- if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
491
+ if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
436
492
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
437
493
  log(`plune: ${parts.join(" \xB7 ")}`);
494
+ if (stats.deferred > 0 && process.env["GITHUB_ACTIONS"] === "true") {
495
+ log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
496
+ }
438
497
  }
439
498
  return {
440
499
  get runId() {
@@ -494,13 +553,133 @@ function resultKey(testId, retry) {
494
553
  const plain = `${testId}#${retry}`;
495
554
  return plain.length <= RESULT_KEY_MAX ? plain : `${sha256(testId).slice(0, HASH_CHARS)}#${retry}`;
496
555
  }
556
+
557
+ // src/reporter-core/failure-detail.ts
558
+ import { existsSync as existsSync2 } from "fs";
559
+ import { homedir as homedir2 } from "os";
560
+ import { dirname as dirname3, join as join3, posix, resolve } from "path";
561
+ import { stripVTControlCharacters } from "util";
562
+ function failureOf(attempt) {
563
+ const error = chosen(attempt);
564
+ const headline = error === void 0 ? void 0 : firstLine(error.text);
565
+ const steps = chainOf(attempt.steps);
566
+ const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
567
+ const artifacts = attempt.attachments.filter((a) => a.path !== void 0 && a.name !== "" && !a.name.startsWith("_") && a.contentType !== METADATA_TYPE).map(({ name, contentType }) => contentType === void 0 ? { name } : { name, contentType });
568
+ const ciUrl = webLink(attempt.buildHref);
569
+ const failure = {
570
+ ...headline !== void 0 ? { headline } : {},
571
+ ...steps.length > 0 ? { steps } : {},
572
+ ...location !== void 0 ? { location } : {},
573
+ ...artifacts.length > 0 ? { artifacts } : {},
574
+ ...ciUrl !== void 0 ? { ciUrl } : {}
575
+ };
576
+ return Object.keys(failure).length === 0 ? void 0 : failure;
577
+ }
578
+ function repoRootOf(dir, stopAt) {
579
+ for (let here = resolve(dir); ; here = dirname3(here)) {
580
+ if (existsSync2(join3(here, ".git"))) return here;
581
+ if (here === stopAt || dirname3(here) === here) return void 0;
582
+ }
583
+ }
584
+ function errorContextOf(attempt, home = homedir2()) {
585
+ let text = stripVTControlCharacters(
586
+ attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n")
587
+ ).replaceAll("\x1B", "");
588
+ const root = attempt.repoRoot === void 0 ? void 0 : pathIn(attempt.repoRoot);
589
+ if (root !== void 0) text = text.replace(new RegExp(`${root.source}[\\\\/]`, root.flags), "");
590
+ const own = pathIn(home);
591
+ if (own !== void 0) text = text.replace(new RegExp(`${own.source}(?![\\w.-])`, own.flags), "~");
592
+ if (attempt.repoRoot === void 0) text = text.replace(ANY_HOME, "~");
593
+ return cut(text);
594
+ }
595
+ var TEXT_LIMIT = 524288;
596
+ var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:file:\/\/\/?)?(?:\/home\/|\/Users\/|[A-Za-z]:[\\/]Users[\\/])[^\\/\s'"`:*?<>|]+/g;
597
+ function pathIn(path3) {
598
+ const parts = path3.split(/[\\/]+/);
599
+ while (parts.length > 1 && parts.at(-1) === "") parts.pop();
600
+ if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
601
+ const escaped = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]");
602
+ return new RegExp(`(?:file:\\/\\/\\/?)?${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
603
+ }
604
+ function cut(text) {
605
+ if (Buffer.byteLength(text) <= TEXT_LIMIT) return text;
606
+ const lines = text.split("\n");
607
+ const size = lines.map((line) => Buffer.byteLength(line) + 1);
608
+ const budget = TEXT_LIMIT - Buffer.byteLength(omitted(lines.length));
609
+ let used = 0;
610
+ let head = 0;
611
+ let tail = lines.length;
612
+ while (head < tail && used + size[head] <= budget / 2) used += size[head++];
613
+ while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
614
+ return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
615
+ }
616
+ var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
617
+ var METADATA_TYPE = "application/plune.metadata+json";
618
+ var REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
619
+ var FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
620
+ var firstLine = (text) => stripVTControlCharacters(text).split("\n").map((line) => line.trim()).find((line) => line !== "");
621
+ function chosen({ status, errors }) {
622
+ if (status === "timedOut") {
623
+ const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
624
+ if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
625
+ }
626
+ return errors[0];
627
+ }
628
+ function chainOf(steps) {
629
+ const chain = [];
630
+ for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
631
+ chain.push(failed.title);
632
+ }
633
+ return chain;
634
+ }
635
+ function placeOf(error, testFile) {
636
+ const own = normal(testFile);
637
+ let first;
638
+ for (const line of stripVTControlCharacters(error.text).split("\n")) {
639
+ const frame = FRAME.exec(line);
640
+ if (frame === null) continue;
641
+ const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
642
+ if (normal(place.file) === own) return place;
643
+ if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
644
+ }
645
+ return error.location ?? first;
646
+ }
647
+ function fileOf(raw) {
648
+ if (!raw.startsWith("file://")) return raw;
649
+ const path3 = decodeURIComponent(raw.slice("file://".length));
650
+ return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
651
+ }
652
+ var normal = (path3) => posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
653
+ function fromRoot(place, root) {
654
+ if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
655
+ const base = normal(root).replace(/\/+$/, "");
656
+ const file = normal(place.file);
657
+ if (!file.startsWith(`${base}/`)) return void 0;
658
+ const relative = file.slice(base.length + 1);
659
+ if (!REPO_RELATIVE.test(relative)) return void 0;
660
+ const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
661
+ return column === void 0 ? { file: relative, line: place.line } : { file: relative, line: place.line, column };
662
+ }
663
+ function webLink(href) {
664
+ if (href === void 0) return void 0;
665
+ try {
666
+ const { protocol } = new URL(href);
667
+ return protocol === "http:" || protocol === "https:" ? href : void 0;
668
+ } catch {
669
+ return void 0;
670
+ }
671
+ }
497
672
  export {
498
673
  DEFAULT_FALLBACK_PATH,
499
674
  RESULT_KEY_MAX,
500
675
  UNSUPPORTED_VARS,
501
676
  appendBatch,
502
677
  createClient,
678
+ errorContextOf,
679
+ failureOf,
503
680
  readEnv,
681
+ repoRootOf,
504
682
  resultKey,
505
- startRun
683
+ startRun,
684
+ webLink
506
685
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",