@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.
- package/README.md +119 -4
- package/dist/cli.cjs +933 -268
- package/dist/index.cjs +135 -65
- package/dist/index.d.cts +198 -1
- package/dist/index.d.ts +198 -1
- package/dist/index.js +131 -64
- package/dist/reporter-core.cjs +244 -40
- package/dist/reporter-core.d.cts +35 -0
- package/dist/reporter-core.d.ts +35 -0
- package/dist/reporter-core.js +240 -36
- package/package.json +2 -1
package/dist/reporter-core.js
CHANGED
|
@@ -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
|
|
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
|
|
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" :
|
|
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
|
|
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 (
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
712
|
-
if (
|
|
713
|
-
|
|
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
|
-
|
|
725
|
-
|
|
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
|
-
|
|
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
|
-
|
|
746
|
-
|
|
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
|
-
|
|
825
|
-
|
|
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.
|
|
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",
|