@plune-ai/cli 0.17.0 → 0.18.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.
@@ -31,6 +31,8 @@ function loadToken() {
31
31
  }
32
32
 
33
33
  // src/reporter-core/client.ts
34
+ import { createReadStream, openAsBlob } from "fs";
35
+ import { pipeline } from "stream";
34
36
  import { request as httpRequest } from "http";
35
37
  import { request as httpsRequest } from "https";
36
38
  var RETRY_DELAYS_MS = [300, 900];
@@ -64,13 +66,49 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
64
66
  req.end(init.body);
65
67
  });
66
68
  }
69
+ var PUT_DELAYS_MS = [500, 1500, 4e3];
70
+ var withoutLength = (headers) => Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-length"));
71
+ function nodePut(url, headers, source, idleMs) {
72
+ return new Promise((resolve2, reject) => {
73
+ const target = new URL(url);
74
+ const send = target.protocol === "https:" ? httpsRequest : httpRequest;
75
+ const length = "body" in source ? source.body.length : source.size;
76
+ let answered;
77
+ const settle = (err) => answered !== void 0 ? resolve2(answered) : reject(err);
78
+ const req = send(
79
+ target,
80
+ { method: "PUT", headers: { ...withoutLength(headers), "content-length": String(length) } },
81
+ (res) => {
82
+ const retry = res.headers["retry-after"];
83
+ answered = { status: res.statusCode ?? 0, retryAfter: Array.isArray(retry) ? retry[0] ?? null : retry ?? null };
84
+ res.on("error", settle);
85
+ res.on("close", () => {
86
+ resolve2(answered);
87
+ req.destroy();
88
+ });
89
+ res.resume();
90
+ }
91
+ );
92
+ req.setTimeout(idleMs, () => req.destroy(Object.assign(new Error("idle"), { code: "ETIMEDOUT" })));
93
+ req.on("error", settle);
94
+ if ("body" in source) req.end(source.body);
95
+ else pipeline(createReadStream(source.path), req, (err) => err ? settle(err) : void 0);
96
+ });
97
+ }
98
+ var codeOfError = (err) => {
99
+ const code = err?.code;
100
+ return typeof code === "string" && /^[A-Z0-9_]{2,40}$/.test(code) ? code : "network";
101
+ };
67
102
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
68
103
  async function detailOf(res) {
69
104
  try {
70
105
  const body = await res.json();
71
- return typeof body.error === "string" ? body.error : "";
106
+ return {
107
+ detail: typeof body.error === "string" ? body.error : "",
108
+ ...typeof body.code === "string" && /^[a-z0-9_]{1,60}$/.test(body.code) ? { code: body.code } : {}
109
+ };
72
110
  } catch {
73
- return "";
111
+ return { detail: "" };
74
112
  }
75
113
  }
76
114
  function retryAfterMs(res) {
@@ -121,10 +159,16 @@ function createClient(opts) {
121
159
  return { ok: false, kind: "other", status: res.status, detail: "the answer was not JSON" };
122
160
  }
123
161
  }
124
- const detail = scrub(await detailOf(res));
162
+ const said = await detailOf(res);
125
163
  const kind = classify(res.status);
126
- last = { kind, status: res.status, detail };
164
+ last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
127
165
  if (kind !== "unavailable") break;
166
+ if (res.status === 429 && retryAfterMs(res) === null) {
167
+ last.kind = "invalid";
168
+ last.detail = "quota exceeded (no Retry-After)";
169
+ if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
170
+ break;
171
+ }
128
172
  if (attempt < RETRY_DELAYS_MS.length) {
129
173
  await wait(retryAfterMs(res) ?? RETRY_DELAYS_MS[attempt]);
130
174
  }
@@ -134,7 +178,36 @@ function createClient(opts) {
134
178
  return {
135
179
  post: (path3, body) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }),
136
180
  upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }),
137
- del: (path3) => send("DELETE", path3)
181
+ del: (path3) => send("DELETE", path3),
182
+ async put(url, headers, source) {
183
+ let last = { ok: false, status: null, code: "network" };
184
+ for (let attempt = 0; attempt <= PUT_DELAYS_MS.length; attempt += 1) {
185
+ try {
186
+ let status;
187
+ let retryAfter;
188
+ if (opts.fetchImpl === void 0) ({ status, retryAfter } = await nodePut(url, headers, source, IDLE_MS));
189
+ else {
190
+ const body = "body" in source ? source.body : await openAsBlob(source.path);
191
+ const res = await opts.fetchImpl(url, { method: "PUT", headers: withoutLength(headers), body });
192
+ await res.arrayBuffer().catch(() => void 0);
193
+ status = res.status;
194
+ retryAfter = res.headers.get("retry-after");
195
+ }
196
+ if (status >= 200 && status <= 299) return { ok: true };
197
+ last = { ok: false, status, code: `bucket_${status}` };
198
+ if (status < 500 && status !== 429) return last;
199
+ const seconds = retryAfter === null ? NaN : Number(retryAfter.trim());
200
+ if (attempt < PUT_DELAYS_MS.length) {
201
+ await wait(Number.isFinite(seconds) && seconds >= 0 ? Math.min(seconds, 60) * 1e3 : PUT_DELAYS_MS[attempt]);
202
+ }
203
+ continue;
204
+ } catch (err) {
205
+ last = { ok: false, status: null, code: codeOfError(err) };
206
+ }
207
+ if (attempt < PUT_DELAYS_MS.length) await wait(PUT_DELAYS_MS[attempt]);
208
+ }
209
+ return last;
210
+ }
138
211
  };
139
212
  }
140
213
 
@@ -255,6 +328,7 @@ function errorContextOf(attempt, home = homedir2()) {
255
328
  const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
256
329
  return cut(machineless(text, attempt.repoRoot, home));
257
330
  }
331
+ var printable = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
258
332
  var mediaTypeOf = (contentType) => contentType?.split(";")[0]?.trim().toLowerCase() || void 0;
259
333
  var TEXT_TYPES = /* @__PURE__ */ new Set(["application/json", "text/plain"]);
260
334
  var isTextType = (contentType) => TEXT_TYPES.has(mediaTypeOf(contentType) ?? "");
@@ -393,6 +467,7 @@ var TYPE_BY_EXTENSION = {
393
467
  ".jpeg": "image/jpeg",
394
468
  ".webp": "image/webp"
395
469
  };
470
+ var METADATA_TYPE2 = "application/plune.metadata+json";
396
471
  var UPLOADS_AT_ONCE = 4;
397
472
  var NAME_MAX = 200;
398
473
  function nameOf(file) {
@@ -418,15 +493,26 @@ var SKIPPED = {
418
493
  textSize: "Plune takes a text file of at most 512 KiB",
419
494
  empty: "the file is empty",
420
495
  binary: "the file holds a NUL byte, and Plune takes UTF-8 text without one",
421
- encoding: "Plune takes UTF-8 text only"
496
+ encoding: "Plune takes UTF-8 text only",
497
+ noStorage: "no storage bucket is connected to this project"
422
498
  };
499
+ var BUCKET_FOUND = "plune: storage bucket found for this project \u2014 screenshots, text, trace and video go straight to it over short-lived links.";
423
500
  var KINDS = {
424
501
  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" }
502
+ text: { label: "text file", bytes: TEXT_BYTES, room: TEXTS_PER_RESULT, limit: "textLimit", size: "textSize" },
503
+ // Only the label is used: a file of this kind has no home in the database, so no ceiling of its own is known here.
504
+ file: { label: "file", bytes: 0, room: 0, limit: "limit", size: "size" }
426
505
  };
427
506
  function kindOf(type) {
428
507
  if (type.startsWith("image/")) return "screenshot";
429
- return TEXT_TYPES.has(type) ? "text" : void 0;
508
+ return TEXT_TYPES.has(type) ? "text" : "file";
509
+ }
510
+ var codeOf = (out) => out.code ?? (out.status === null ? "network" : `http_${out.status}`);
511
+ function linkOf(item) {
512
+ const link = item;
513
+ if (typeof link?.id !== "string" || typeof link.url !== "string") return void 0;
514
+ const headers = typeof link.headers === "object" && link.headers !== null ? link.headers : {};
515
+ return { id: link.id, url: link.url, headers };
430
516
  }
431
517
  function textTypeOf(contentType, type) {
432
518
  const charset = /;\s*charset\s*=\s*"?([^\s";]+)"?/i.exec(contentType)?.[1];
@@ -529,7 +615,8 @@ async function startRun(passed, declared) {
529
615
  unoffered: 0,
530
616
  deferred: 0,
531
617
  screenshots: { uploaded: 0, skipped: 0, failed: 0 },
532
- textFiles: { uploaded: 0, skipped: 0, failed: 0 }
618
+ textFiles: { uploaded: 0, skipped: 0, failed: 0 },
619
+ otherFiles: { uploaded: 0, skipped: 0, failed: 0 }
533
620
  };
534
621
  const resolved = /* @__PURE__ */ new Map();
535
622
  let buffer = [];
@@ -540,7 +627,13 @@ async function startRun(passed, declared) {
540
627
  let done = false;
541
628
  const shortened = /* @__PURE__ */ new Set();
542
629
  const skipsSaid = /* @__PURE__ */ new Set();
543
- const countsOf = (kind) => kind === "text" ? stats.textFiles : stats.screenshots;
630
+ const countsOf = (kind) => kind === "text" ? stats.textFiles : kind === "file" ? stats.otherFiles : stats.screenshots;
631
+ let bucketSeen = false;
632
+ let noStorage = 0;
633
+ let uploadsMissing = false;
634
+ const failedSaid = /* @__PURE__ */ new Set();
635
+ const codesSaid = /* @__PURE__ */ new Set();
636
+ const rooms = /* @__PURE__ */ new Map();
544
637
  function bound(keys) {
545
638
  return keys.map((key) => {
546
639
  const fit = boundKey(key);
@@ -548,7 +641,7 @@ async function startRun(passed, declared) {
548
641
  shortened.add(key.value);
549
642
  if (shortened.size === 1) {
550
643
  log(
551
- `plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 "${key.value.slice(0, 80)}\u2026". Give the test a shorter name; until then this is its identity.`
644
+ `plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 ${JSON.stringify(printable(key.value.slice(0, 80)))}\u2026 Give the test a shorter name; until then this is its identity.`
552
645
  );
553
646
  }
554
647
  }
@@ -678,19 +771,48 @@ async function startRun(passed, declared) {
678
771
  }
679
772
  function skip(kind, name, why) {
680
773
  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]}.`);
774
+ if (why === "noStorage") noStorage += 1;
775
+ const said = why === "noStorage" ? why : `${kind}/${why}`;
776
+ if (skipsSaid.has(said)) return;
777
+ skipsSaid.add(said);
778
+ if (why === "noStorage") log(`plune: "${name}" not stored \u2014 ${SKIPPED[why]}.`);
779
+ else log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
780
+ }
781
+ function ask(job, size) {
782
+ return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
783
+ files: [{ name: job.name, contentType: job.type, size }]
784
+ });
785
+ }
786
+ function failed(job, why) {
787
+ countsOf(job.kind).failed += 1;
788
+ if (!failedSaid.has(job.kind)) {
789
+ failedSaid.add(job.kind);
790
+ log(`plune: could not upload a ${KINDS[job.kind].label} (${why}) \u2014 its result is reported without it.`);
791
+ }
792
+ }
793
+ function failedToBucket(job, code) {
794
+ countsOf(job.kind).failed += 1;
795
+ if (codesSaid.has(code)) return;
796
+ codesSaid.add(code);
797
+ log(`plune: "${job.name}" not uploaded to your bucket (${code}) \u2014 its result is reported without it.`);
684
798
  }
685
- async function uploadOne(job) {
799
+ function refusedHere(job, size) {
800
+ if (job.kind === "screenshot") {
801
+ if (!SCREENSHOT_TYPES.has(job.type)) return "type";
802
+ if (size > SCREENSHOT_BYTES) return "size";
803
+ }
804
+ if (job.kind === "text" && size > TEXT_BYTES) return "textSize";
805
+ return void 0;
806
+ }
807
+ async function uploadDirect(job, size) {
686
808
  const { kind } = job;
687
- const { label, bytes: ceiling, size } = KINDS[kind];
809
+ if (kind === "file") return skip(kind, job.name, "noStorage");
810
+ const { bytes: ceiling, size: tooBig } = KINDS[kind];
811
+ if (size > ceiling) return skip(kind, job.name, tooBig);
688
812
  let bytes;
689
813
  if (job.body !== void 0) bytes = job.body;
690
- else if (job.path === void 0) return skip(kind, job.name, "missing");
691
814
  else {
692
815
  try {
693
- if ((await disk.stat(job.path)).size > ceiling) return skip(kind, job.name, size);
694
816
  bytes = await disk.readFile(job.path);
695
817
  } catch {
696
818
  return skip(kind, job.name, "missing");
@@ -703,32 +825,96 @@ async function startRun(passed, declared) {
703
825
  bytes,
704
826
  job.type
705
827
  );
706
- const counts = countsOf(kind);
707
- if (out.ok) {
708
- counts.uploaded += 1;
709
- return;
828
+ if (out.ok) countsOf(kind).uploaded += 1;
829
+ else failed(job, out.detail || out.kind);
830
+ }
831
+ async function uploadToBucket(job, size, first) {
832
+ const source = job.body !== void 0 ? { body: job.body } : { path: job.path, size };
833
+ let link = first;
834
+ for (let renewed = false; ; ) {
835
+ const put = await client.put(link.url, link.headers, source);
836
+ if (put.ok) break;
837
+ if (put.status !== 403 || renewed) return failedToBucket(job, put.code);
838
+ renewed = true;
839
+ const again = await ask(job, size);
840
+ if (!again.ok) return failedToBucket(job, codeOf(again));
841
+ const fresh = linkOf(again.body?.files?.[0]);
842
+ if (fresh === void 0) return failedToBucket(job, "bad_answer");
843
+ link = fresh;
844
+ }
845
+ const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {});
846
+ if (!done2.ok) return failedToBucket(job, codeOf(done2));
847
+ countsOf(job.kind).uploaded += 1;
848
+ }
849
+ let free = UPLOADS_AT_ONCE;
850
+ const waiting = [];
851
+ async function gated(work) {
852
+ if (free === 0) await new Promise((resolve2) => waiting.push(resolve2));
853
+ else free -= 1;
854
+ try {
855
+ return await work();
856
+ } finally {
857
+ const next = waiting.shift();
858
+ if (next !== void 0) next();
859
+ else free += 1;
860
+ }
861
+ }
862
+ async function plan(job) {
863
+ const { kind } = job;
864
+ let size;
865
+ if (job.body !== void 0) size = job.body.length;
866
+ else if (job.path === void 0) return void skip(kind, job.name, "missing");
867
+ else {
868
+ try {
869
+ size = (await disk.stat(job.path)).size;
870
+ } catch {
871
+ return void skip(kind, job.name, "missing");
872
+ }
873
+ }
874
+ if (size === 0) return void skip(kind, job.name, "empty");
875
+ const direct = () => {
876
+ if (kind === "file") return void skip(kind, job.name, "noStorage");
877
+ const roomKey = `${job.id}/${kind}`;
878
+ const left = rooms.get(roomKey) ?? KINDS[kind].room;
879
+ if (left === 0) return void skip(kind, job.name, KINDS[kind].limit);
880
+ rooms.set(roomKey, left - 1);
881
+ return () => uploadDirect(job, size);
882
+ };
883
+ if (uploadsMissing) return direct();
884
+ const out = await gated(() => ask(job, size));
885
+ if (!out.ok) {
886
+ if (out.status === 404 && out.detail === "" && out.code === void 0) {
887
+ uploadsMissing = true;
888
+ return direct();
889
+ }
890
+ if (out.code !== void 0) failedToBucket(job, out.code);
891
+ else failed(job, out.detail || out.kind);
892
+ return void 0;
710
893
  }
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.`);
894
+ const item = out.body?.files?.[0];
895
+ if (item?.skipped === "no_storage") return void skip(kind, job.name, refusedHere(job, size) ?? "noStorage");
896
+ if (item?.direct === true) return direct();
897
+ const link = linkOf(item);
898
+ if (link === void 0) return void failed(job, "bad_answer");
899
+ if (!bucketSeen) {
900
+ bucketSeen = true;
901
+ log(BUCKET_FOUND);
714
902
  }
903
+ return () => uploadToBucket(job, size, link);
715
904
  }
716
905
  async function upload(items, filesByKey) {
717
906
  if (filesByKey.size === 0 || !Array.isArray(items)) return;
718
907
  const jobs = [];
719
908
  for (const item of items) {
720
909
  if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
721
- const room = { screenshot: KINDS.screenshot.room, text: KINDS.text.room };
722
910
  for (const file of filesByKey.get(item.resultKey) ?? []) {
723
911
  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;
912
+ if (type === void 0 || type === METADATA_TYPE2) continue;
913
+ const kind = kindOf(type);
914
+ if (kind !== "text" && file.path === void 0) continue;
726
915
  const name = nameOf(file);
727
916
  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);
730
917
  else {
731
- room[kind] -= 1;
732
918
  jobs.push({
733
919
  id: item.id,
734
920
  kind,
@@ -740,10 +926,16 @@ async function startRun(passed, declared) {
740
926
  }
741
927
  }
742
928
  }
743
- let next = 0;
929
+ const byResult = /* @__PURE__ */ new Map();
930
+ for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
744
931
  await Promise.all(
745
- Array.from({ length: Math.min(UPLOADS_AT_ONCE, jobs.length) }, async () => {
746
- while (next < jobs.length) await uploadOne(jobs[next++]);
932
+ [...byResult.values()].map(async (group) => {
933
+ const work = [];
934
+ for (const job of group) {
935
+ const act = await plan(job);
936
+ if (act !== void 0) work.push(gated(act));
937
+ }
938
+ await Promise.all(work);
747
939
  })
748
940
  );
749
941
  }
@@ -821,8 +1013,20 @@ async function startRun(passed, declared) {
821
1013
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
822
1014
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
823
1015
  if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
824
- for (const said of [tally(stats.screenshots, "screenshot"), tally(stats.textFiles, "text file")]) {
825
- if (said !== void 0) parts.push(said);
1016
+ const kinds = [stats.screenshots, stats.textFiles, stats.otherFiles];
1017
+ const sum = (what) => kinds.reduce((n, counts) => n + counts[what], 0);
1018
+ if (bucketSeen) {
1019
+ const other = sum("skipped") - noStorage;
1020
+ parts.push(
1021
+ `${sum("uploaded")} file(s) uploaded to your bucket` + (noStorage > 0 ? `, ${noStorage} skipped (no bucket connected)` : "") + (other > 0 ? `, ${other} skipped` : "") + (sum("failed") > 0 ? `, ${sum("failed")} failed to upload` : "")
1022
+ );
1023
+ } else {
1024
+ for (const said of [tally(stats.screenshots, "screenshot"), tally(stats.textFiles, "text file")]) {
1025
+ if (said !== void 0) parts.push(said);
1026
+ }
1027
+ const other = stats.otherFiles;
1028
+ if (other.skipped > 0) parts.push(`${other.skipped} file(s) not stored (no bucket connected)`);
1029
+ if (other.failed > 0) parts.push(`${other.failed} file(s) failed to upload`);
826
1030
  }
827
1031
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
828
1032
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.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",
@@ -53,6 +53,7 @@
53
53
  "format": "prettier --write .",
54
54
  "format:check": "prettier --check .",
55
55
  "typecheck": "tsc --noEmit",
56
+ "typecheck:gates": "tsc -p tsconfig.check.json --noEmit",
56
57
  "typecheck:playwright": "pnpm --filter @plune-ai/playwright typecheck",
57
58
  "test:playwright": "pnpm --filter @plune-ai/playwright test",
58
59
  "test:e2e:playwright": "pnpm --filter @plune-ai/playwright test:e2e",