@plune-ai/cli 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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("***");
@@ -80,7 +112,11 @@ function createClient(opts) {
80
112
  }
81
113
  if (res.ok) {
82
114
  if (res.status === 204) return { ok: true, status: res.status, body: void 0 };
83
- return { ok: true, status: res.status, body: await res.json() };
115
+ try {
116
+ return { ok: true, status: res.status, body: await res.json() };
117
+ } catch {
118
+ return { ok: false, kind: "other", status: res.status, detail: "the answer was not JSON" };
119
+ }
84
120
  }
85
121
  const detail = scrub(await detailOf(res));
86
122
  const kind = classify(res.status);
@@ -195,6 +231,25 @@ function boundKey(key) {
195
231
  function boundTitle(title) {
196
232
  return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
197
233
  }
234
+ var BATCH_BYTES = 8 * 1024 * 1024;
235
+ var ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ results: [] }));
236
+ function packed(results) {
237
+ const out = [];
238
+ let part = [];
239
+ let bytes = ENVELOPE_BYTES;
240
+ for (const result of results) {
241
+ const size = Buffer.byteLength(JSON.stringify(result));
242
+ if (part.length > 0 && bytes + 1 + size > BATCH_BYTES) {
243
+ out.push(part);
244
+ part = [];
245
+ bytes = ENVELOPE_BYTES;
246
+ }
247
+ bytes += (part.length > 0 ? 1 : 0) + size;
248
+ part.push(result);
249
+ }
250
+ if (part.length > 0) out.push(part);
251
+ return out;
252
+ }
198
253
  function chunk(items, size) {
199
254
  const out = [];
200
255
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
@@ -396,27 +451,35 @@ async function startRun(passed, declared) {
396
451
  progress();
397
452
  return;
398
453
  }
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}).`);
454
+ for (const part of packed(submissions)) {
455
+ if (offline) {
456
+ defer(part);
457
+ continue;
458
+ }
459
+ const out = await client.post(`/v1/runs/${runId}/results`, {
460
+ results: part
461
+ });
462
+ const counts = out.ok ? out.body?.counts : void 0;
463
+ if (counts !== void 0) {
464
+ stats.accepted += counts.accepted ?? 0;
465
+ stats.duplicate += counts.duplicate ?? 0;
466
+ stats.conflict += counts.conflict ?? 0;
467
+ stats.rejected += counts.rejected ?? 0;
468
+ progress();
469
+ continue;
470
+ }
471
+ if (out.ok) {
472
+ log("plune: could not send results (the answer carried no counts).");
473
+ } else if (out.kind === "auth") {
474
+ log('plune: the API token was refused \u2014 run "plune login" with a fresh one.');
475
+ offline = true;
476
+ } else if (out.kind === "conflict") {
477
+ log(`plune: this run is closed \u2014 ${out.detail}`);
478
+ } else {
479
+ log(`plune: could not send results (${out.detail || out.kind}).`);
480
+ }
481
+ defer(part);
418
482
  }
419
- defer(submissions);
420
483
  }
421
484
  function progress() {
422
485
  const total = expected?.length ?? 0;
@@ -432,9 +495,12 @@ async function startRun(passed, declared) {
432
495
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
433
496
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
434
497
  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}`);
498
+ if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
436
499
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
437
500
  log(`plune: ${parts.join(" \xB7 ")}`);
501
+ if (stats.deferred > 0 && process.env["GITHUB_ACTIONS"] === "true") {
502
+ log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
503
+ }
438
504
  }
439
505
  return {
440
506
  get runId() {
@@ -494,13 +560,140 @@ function resultKey(testId, retry) {
494
560
  const plain = `${testId}#${retry}`;
495
561
  return plain.length <= RESULT_KEY_MAX ? plain : `${sha256(testId).slice(0, HASH_CHARS)}#${retry}`;
496
562
  }
563
+
564
+ // src/reporter-core/failure-detail.ts
565
+ import { existsSync as existsSync2 } from "fs";
566
+ import { homedir as homedir2 } from "os";
567
+ import { dirname as dirname3, join as join3, posix, resolve } from "path";
568
+ import { stripVTControlCharacters } from "util";
569
+ function failureOf(attempt, home = homedir2()) {
570
+ const error = chosen(attempt);
571
+ const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
572
+ const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
573
+ const steps = chainOf(attempt.steps);
574
+ const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
575
+ 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 !== "");
576
+ const ciUrl = webLink(attempt.buildHref);
577
+ const failure = {
578
+ ...headline !== void 0 ? { headline } : {},
579
+ ...steps.length > 0 ? { steps } : {},
580
+ ...location !== void 0 ? { location } : {},
581
+ ...artifacts.length > 0 ? { artifacts } : {},
582
+ ...ciUrl !== void 0 ? { ciUrl } : {}
583
+ };
584
+ return Object.keys(failure).length === 0 ? void 0 : failure;
585
+ }
586
+ function repoRootOf(dir, stopAt) {
587
+ for (let here = resolve(dir); ; here = dirname3(here)) {
588
+ if (existsSync2(join3(here, ".git"))) return here;
589
+ if (here === stopAt || dirname3(here) === here) return void 0;
590
+ }
591
+ }
592
+ function errorContextOf(attempt, home = homedir2()) {
593
+ const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
594
+ return cut(machineless(text, attempt.repoRoot, home));
595
+ }
596
+ function machineless(raw, repoRoot, home) {
597
+ let text = stripVTControlCharacters(raw).replaceAll("\x1B", "");
598
+ const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
599
+ if (root !== void 0) text = text.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
600
+ const own = pathIn(home);
601
+ if (own !== void 0) text = text.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
602
+ if (repoRoot === void 0) text = text.replace(ANY_HOME, "~");
603
+ return text;
604
+ }
605
+ var PATH_START = "(?<![\\w.~%/\\\\-])";
606
+ var TEXT_LIMIT = 524288;
607
+ var HEADLINE_LIMIT = 8 * 1024;
608
+ var jsonBytes = (text) => Buffer.byteLength(JSON.stringify(text)) - 2;
609
+ var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:file:\/\/\/?)?(?:\/home\/|\/Users\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
610
+ function pathIn(path3) {
611
+ const parts = path3.split(/[\\/]+/);
612
+ while (parts.length > 1 && parts.at(-1) === "") parts.pop();
613
+ if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
614
+ const escaped = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
615
+ return new RegExp(`(?:file:\\/\\/\\/?)?${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
616
+ }
617
+ var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
618
+ function cut(text) {
619
+ if (jsonBytes(text) <= TEXT_LIMIT) return text;
620
+ const lines = text.split("\n");
621
+ const size = lines.map((line) => jsonBytes(line) + 2);
622
+ const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
623
+ let used = 0;
624
+ let head = 0;
625
+ let tail = lines.length;
626
+ while (head < tail && used + size[head] <= budget / 2) used += size[head++];
627
+ while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
628
+ return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
629
+ }
630
+ var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
631
+ var METADATA_TYPE = "application/plune.metadata+json";
632
+ var REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
633
+ var FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
634
+ var FRAME_MAX = 8192;
635
+ var firstLine = (text) => stripVTControlCharacters(text).split("\n").map((line) => line.trim()).find((line) => line !== "");
636
+ function chosen({ status, errors }) {
637
+ if (status === "timedOut") {
638
+ const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
639
+ if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
640
+ }
641
+ return errors[0];
642
+ }
643
+ function chainOf(steps) {
644
+ const chain = [];
645
+ for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
646
+ chain.push(failed.title);
647
+ }
648
+ return chain;
649
+ }
650
+ function placeOf(error, testFile) {
651
+ const own = normal(testFile);
652
+ let first;
653
+ for (const line of stripVTControlCharacters(error.text).split("\n")) {
654
+ if (line.length > FRAME_MAX) continue;
655
+ const frame = FRAME.exec(line);
656
+ if (frame === null) continue;
657
+ const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
658
+ if (normal(place.file) === own) return place;
659
+ if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
660
+ }
661
+ return error.location ?? first;
662
+ }
663
+ function fileOf(raw) {
664
+ if (!raw.startsWith("file://")) return raw;
665
+ let path3 = raw.slice("file://".length);
666
+ try {
667
+ path3 = decodeURIComponent(path3);
668
+ } catch {
669
+ }
670
+ return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
671
+ }
672
+ var normal = (path3) => posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
673
+ function fromRoot(place, root) {
674
+ if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
675
+ const base = normal(root).replace(/\/+$/, "");
676
+ const file = normal(place.file);
677
+ if (!file.startsWith(`${base}/`)) return void 0;
678
+ const relative = file.slice(base.length + 1);
679
+ if (!REPO_RELATIVE.test(relative)) return void 0;
680
+ const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
681
+ return column === void 0 ? { file: relative, line: place.line } : { file: relative, line: place.line, column };
682
+ }
683
+ function webLink(href) {
684
+ return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
685
+ }
497
686
  export {
498
687
  DEFAULT_FALLBACK_PATH,
499
688
  RESULT_KEY_MAX,
500
689
  UNSUPPORTED_VARS,
501
690
  appendBatch,
502
691
  createClient,
692
+ errorContextOf,
693
+ failureOf,
503
694
  readEnv,
695
+ repoRootOf,
504
696
  resultKey,
505
- startRun
697
+ startRun,
698
+ webLink
506
699
  };
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.1",
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",