@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.cjs
CHANGED
|
@@ -49,7 +49,7 @@ module.exports = __toCommonJS(reporter_core_exports);
|
|
|
49
49
|
|
|
50
50
|
// src/reporter-core/session.ts
|
|
51
51
|
var import_node_crypto = require("crypto");
|
|
52
|
-
var
|
|
52
|
+
var import_node_fs4 = require("fs");
|
|
53
53
|
var import_node_path3 = require("path");
|
|
54
54
|
var import_node_util2 = require("util");
|
|
55
55
|
|
|
@@ -80,6 +80,8 @@ function loadToken() {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
// src/reporter-core/client.ts
|
|
83
|
+
var import_node_fs2 = require("fs");
|
|
84
|
+
var import_node_stream = require("stream");
|
|
83
85
|
var import_node_http = require("http");
|
|
84
86
|
var import_node_https = require("https");
|
|
85
87
|
var RETRY_DELAYS_MS = [300, 900];
|
|
@@ -113,13 +115,49 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
|
|
|
113
115
|
req.end(init.body);
|
|
114
116
|
});
|
|
115
117
|
}
|
|
118
|
+
var PUT_DELAYS_MS = [500, 1500, 4e3];
|
|
119
|
+
var withoutLength = (headers) => Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-length"));
|
|
120
|
+
function nodePut(url, headers, source, idleMs) {
|
|
121
|
+
return new Promise((resolve2, reject) => {
|
|
122
|
+
const target = new URL(url);
|
|
123
|
+
const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
|
|
124
|
+
const length = "body" in source ? source.body.length : source.size;
|
|
125
|
+
let answered;
|
|
126
|
+
const settle = (err) => answered !== void 0 ? resolve2(answered) : reject(err);
|
|
127
|
+
const req = send(
|
|
128
|
+
target,
|
|
129
|
+
{ method: "PUT", headers: { ...withoutLength(headers), "content-length": String(length) } },
|
|
130
|
+
(res) => {
|
|
131
|
+
const retry = res.headers["retry-after"];
|
|
132
|
+
answered = { status: res.statusCode ?? 0, retryAfter: Array.isArray(retry) ? retry[0] ?? null : retry ?? null };
|
|
133
|
+
res.on("error", settle);
|
|
134
|
+
res.on("close", () => {
|
|
135
|
+
resolve2(answered);
|
|
136
|
+
req.destroy();
|
|
137
|
+
});
|
|
138
|
+
res.resume();
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
req.setTimeout(idleMs, () => req.destroy(Object.assign(new Error("idle"), { code: "ETIMEDOUT" })));
|
|
142
|
+
req.on("error", settle);
|
|
143
|
+
if ("body" in source) req.end(source.body);
|
|
144
|
+
else (0, import_node_stream.pipeline)((0, import_node_fs2.createReadStream)(source.path), req, (err) => err ? settle(err) : void 0);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
var codeOfError = (err) => {
|
|
148
|
+
const code = err?.code;
|
|
149
|
+
return typeof code === "string" && /^[A-Z0-9_]{2,40}$/.test(code) ? code : "network";
|
|
150
|
+
};
|
|
116
151
|
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
117
152
|
async function detailOf(res) {
|
|
118
153
|
try {
|
|
119
154
|
const body = await res.json();
|
|
120
|
-
return
|
|
155
|
+
return {
|
|
156
|
+
detail: typeof body.error === "string" ? body.error : "",
|
|
157
|
+
...typeof body.code === "string" && /^[a-z0-9_]{1,60}$/.test(body.code) ? { code: body.code } : {}
|
|
158
|
+
};
|
|
121
159
|
} catch {
|
|
122
|
-
return "";
|
|
160
|
+
return { detail: "" };
|
|
123
161
|
}
|
|
124
162
|
}
|
|
125
163
|
function retryAfterMs(res) {
|
|
@@ -170,10 +208,16 @@ function createClient(opts) {
|
|
|
170
208
|
return { ok: false, kind: "other", status: res.status, detail: "the answer was not JSON" };
|
|
171
209
|
}
|
|
172
210
|
}
|
|
173
|
-
const
|
|
211
|
+
const said = await detailOf(res);
|
|
174
212
|
const kind = classify(res.status);
|
|
175
|
-
last = { kind, status: res.status, detail };
|
|
213
|
+
last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
|
|
176
214
|
if (kind !== "unavailable") break;
|
|
215
|
+
if (res.status === 429 && retryAfterMs(res) === null) {
|
|
216
|
+
last.kind = "invalid";
|
|
217
|
+
last.detail = "quota exceeded (no Retry-After)";
|
|
218
|
+
if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
177
221
|
if (attempt < RETRY_DELAYS_MS.length) {
|
|
178
222
|
await wait(retryAfterMs(res) ?? RETRY_DELAYS_MS[attempt]);
|
|
179
223
|
}
|
|
@@ -183,7 +227,36 @@ function createClient(opts) {
|
|
|
183
227
|
return {
|
|
184
228
|
post: (path3, body) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }),
|
|
185
229
|
upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }),
|
|
186
|
-
del: (path3) => send("DELETE", path3)
|
|
230
|
+
del: (path3) => send("DELETE", path3),
|
|
231
|
+
async put(url, headers, source) {
|
|
232
|
+
let last = { ok: false, status: null, code: "network" };
|
|
233
|
+
for (let attempt = 0; attempt <= PUT_DELAYS_MS.length; attempt += 1) {
|
|
234
|
+
try {
|
|
235
|
+
let status;
|
|
236
|
+
let retryAfter;
|
|
237
|
+
if (opts.fetchImpl === void 0) ({ status, retryAfter } = await nodePut(url, headers, source, IDLE_MS));
|
|
238
|
+
else {
|
|
239
|
+
const body = "body" in source ? source.body : await (0, import_node_fs2.openAsBlob)(source.path);
|
|
240
|
+
const res = await opts.fetchImpl(url, { method: "PUT", headers: withoutLength(headers), body });
|
|
241
|
+
await res.arrayBuffer().catch(() => void 0);
|
|
242
|
+
status = res.status;
|
|
243
|
+
retryAfter = res.headers.get("retry-after");
|
|
244
|
+
}
|
|
245
|
+
if (status >= 200 && status <= 299) return { ok: true };
|
|
246
|
+
last = { ok: false, status, code: `bucket_${status}` };
|
|
247
|
+
if (status < 500 && status !== 429) return last;
|
|
248
|
+
const seconds = retryAfter === null ? NaN : Number(retryAfter.trim());
|
|
249
|
+
if (attempt < PUT_DELAYS_MS.length) {
|
|
250
|
+
await wait(Number.isFinite(seconds) && seconds >= 0 ? Math.min(seconds, 60) * 1e3 : PUT_DELAYS_MS[attempt]);
|
|
251
|
+
}
|
|
252
|
+
continue;
|
|
253
|
+
} catch (err) {
|
|
254
|
+
last = { ok: false, status: null, code: codeOfError(err) };
|
|
255
|
+
}
|
|
256
|
+
if (attempt < PUT_DELAYS_MS.length) await wait(PUT_DELAYS_MS[attempt]);
|
|
257
|
+
}
|
|
258
|
+
return last;
|
|
259
|
+
}
|
|
187
260
|
};
|
|
188
261
|
}
|
|
189
262
|
|
|
@@ -273,7 +346,7 @@ function ciCommit(reported, env = process.env) {
|
|
|
273
346
|
}
|
|
274
347
|
|
|
275
348
|
// src/reporter-core/failure-detail.ts
|
|
276
|
-
var
|
|
349
|
+
var import_node_fs3 = require("fs");
|
|
277
350
|
var import_node_os2 = require("os");
|
|
278
351
|
var import_node_path2 = require("path");
|
|
279
352
|
var import_node_util = require("util");
|
|
@@ -296,7 +369,7 @@ function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
|
|
|
296
369
|
}
|
|
297
370
|
function repoRootOf(dir, stopAt) {
|
|
298
371
|
for (let here = (0, import_node_path2.resolve)(dir); ; here = (0, import_node_path2.dirname)(here)) {
|
|
299
|
-
if ((0,
|
|
372
|
+
if ((0, import_node_fs3.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
|
|
300
373
|
if (here === stopAt || (0, import_node_path2.dirname)(here) === here) return void 0;
|
|
301
374
|
}
|
|
302
375
|
}
|
|
@@ -304,6 +377,7 @@ function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
|
|
|
304
377
|
const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
|
|
305
378
|
return cut(machineless(text, attempt.repoRoot, home));
|
|
306
379
|
}
|
|
380
|
+
var printable = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
|
|
307
381
|
var mediaTypeOf = (contentType) => contentType?.split(";")[0]?.trim().toLowerCase() || void 0;
|
|
308
382
|
var TEXT_TYPES = /* @__PURE__ */ new Set(["application/json", "text/plain"]);
|
|
309
383
|
var isTextType = (contentType) => TEXT_TYPES.has(mediaTypeOf(contentType) ?? "");
|
|
@@ -442,6 +516,7 @@ var TYPE_BY_EXTENSION = {
|
|
|
442
516
|
".jpeg": "image/jpeg",
|
|
443
517
|
".webp": "image/webp"
|
|
444
518
|
};
|
|
519
|
+
var METADATA_TYPE2 = "application/plune.metadata+json";
|
|
445
520
|
var UPLOADS_AT_ONCE = 4;
|
|
446
521
|
var NAME_MAX = 200;
|
|
447
522
|
function nameOf(file) {
|
|
@@ -467,15 +542,26 @@ var SKIPPED = {
|
|
|
467
542
|
textSize: "Plune takes a text file of at most 512 KiB",
|
|
468
543
|
empty: "the file is empty",
|
|
469
544
|
binary: "the file holds a NUL byte, and Plune takes UTF-8 text without one",
|
|
470
|
-
encoding: "Plune takes UTF-8 text only"
|
|
545
|
+
encoding: "Plune takes UTF-8 text only",
|
|
546
|
+
noStorage: "no storage bucket is connected to this project"
|
|
471
547
|
};
|
|
548
|
+
var BUCKET_FOUND = "plune: storage bucket found for this project \u2014 screenshots, text, trace and video go straight to it over short-lived links.";
|
|
472
549
|
var KINDS = {
|
|
473
550
|
screenshot: { label: "screenshot", bytes: SCREENSHOT_BYTES, room: SCREENSHOTS_PER_RESULT, limit: "limit", size: "size" },
|
|
474
|
-
text: { label: "text file", bytes: TEXT_BYTES, room: TEXTS_PER_RESULT, limit: "textLimit", size: "textSize" }
|
|
551
|
+
text: { label: "text file", bytes: TEXT_BYTES, room: TEXTS_PER_RESULT, limit: "textLimit", size: "textSize" },
|
|
552
|
+
// 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.
|
|
553
|
+
file: { label: "file", bytes: 0, room: 0, limit: "limit", size: "size" }
|
|
475
554
|
};
|
|
476
555
|
function kindOf(type) {
|
|
477
556
|
if (type.startsWith("image/")) return "screenshot";
|
|
478
|
-
return TEXT_TYPES.has(type) ? "text" :
|
|
557
|
+
return TEXT_TYPES.has(type) ? "text" : "file";
|
|
558
|
+
}
|
|
559
|
+
var codeOf = (out) => out.code ?? (out.status === null ? "network" : `http_${out.status}`);
|
|
560
|
+
function linkOf(item) {
|
|
561
|
+
const link = item;
|
|
562
|
+
if (typeof link?.id !== "string" || typeof link.url !== "string") return void 0;
|
|
563
|
+
const headers = typeof link.headers === "object" && link.headers !== null ? link.headers : {};
|
|
564
|
+
return { id: link.id, url: link.url, headers };
|
|
479
565
|
}
|
|
480
566
|
function textTypeOf(contentType, type) {
|
|
481
567
|
const charset = /;\s*charset\s*=\s*"?([^\s";]+)"?/i.exec(contentType)?.[1];
|
|
@@ -578,7 +664,8 @@ async function startRun(passed, declared) {
|
|
|
578
664
|
unoffered: 0,
|
|
579
665
|
deferred: 0,
|
|
580
666
|
screenshots: { uploaded: 0, skipped: 0, failed: 0 },
|
|
581
|
-
textFiles: { uploaded: 0, skipped: 0, failed: 0 }
|
|
667
|
+
textFiles: { uploaded: 0, skipped: 0, failed: 0 },
|
|
668
|
+
otherFiles: { uploaded: 0, skipped: 0, failed: 0 }
|
|
582
669
|
};
|
|
583
670
|
const resolved = /* @__PURE__ */ new Map();
|
|
584
671
|
let buffer = [];
|
|
@@ -589,7 +676,13 @@ async function startRun(passed, declared) {
|
|
|
589
676
|
let done = false;
|
|
590
677
|
const shortened = /* @__PURE__ */ new Set();
|
|
591
678
|
const skipsSaid = /* @__PURE__ */ new Set();
|
|
592
|
-
const countsOf = (kind) => kind === "text" ? stats.textFiles : stats.screenshots;
|
|
679
|
+
const countsOf = (kind) => kind === "text" ? stats.textFiles : kind === "file" ? stats.otherFiles : stats.screenshots;
|
|
680
|
+
let bucketSeen = false;
|
|
681
|
+
let noStorage = 0;
|
|
682
|
+
let uploadsMissing = false;
|
|
683
|
+
const failedSaid = /* @__PURE__ */ new Set();
|
|
684
|
+
const codesSaid = /* @__PURE__ */ new Set();
|
|
685
|
+
const rooms = /* @__PURE__ */ new Map();
|
|
593
686
|
function bound(keys) {
|
|
594
687
|
return keys.map((key) => {
|
|
595
688
|
const fit = boundKey(key);
|
|
@@ -597,7 +690,7 @@ async function startRun(passed, declared) {
|
|
|
597
690
|
shortened.add(key.value);
|
|
598
691
|
if (shortened.size === 1) {
|
|
599
692
|
log(
|
|
600
|
-
`plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014
|
|
693
|
+
`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.`
|
|
601
694
|
);
|
|
602
695
|
}
|
|
603
696
|
}
|
|
@@ -727,20 +820,49 @@ async function startRun(passed, declared) {
|
|
|
727
820
|
}
|
|
728
821
|
function skip(kind, name, why) {
|
|
729
822
|
countsOf(kind).skipped += 1;
|
|
730
|
-
if (
|
|
731
|
-
|
|
732
|
-
|
|
823
|
+
if (why === "noStorage") noStorage += 1;
|
|
824
|
+
const said = why === "noStorage" ? why : `${kind}/${why}`;
|
|
825
|
+
if (skipsSaid.has(said)) return;
|
|
826
|
+
skipsSaid.add(said);
|
|
827
|
+
if (why === "noStorage") log(`plune: "${name}" not stored \u2014 ${SKIPPED[why]}.`);
|
|
828
|
+
else log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
|
|
733
829
|
}
|
|
734
|
-
|
|
830
|
+
function ask(job, size) {
|
|
831
|
+
return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
|
|
832
|
+
files: [{ name: job.name, contentType: job.type, size }]
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
function failed(job, why) {
|
|
836
|
+
countsOf(job.kind).failed += 1;
|
|
837
|
+
if (!failedSaid.has(job.kind)) {
|
|
838
|
+
failedSaid.add(job.kind);
|
|
839
|
+
log(`plune: could not upload a ${KINDS[job.kind].label} (${why}) \u2014 its result is reported without it.`);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
function failedToBucket(job, code) {
|
|
843
|
+
countsOf(job.kind).failed += 1;
|
|
844
|
+
if (codesSaid.has(code)) return;
|
|
845
|
+
codesSaid.add(code);
|
|
846
|
+
log(`plune: "${job.name}" not uploaded to your bucket (${code}) \u2014 its result is reported without it.`);
|
|
847
|
+
}
|
|
848
|
+
function refusedHere(job, size) {
|
|
849
|
+
if (job.kind === "screenshot") {
|
|
850
|
+
if (!SCREENSHOT_TYPES.has(job.type)) return "type";
|
|
851
|
+
if (size > SCREENSHOT_BYTES) return "size";
|
|
852
|
+
}
|
|
853
|
+
if (job.kind === "text" && size > TEXT_BYTES) return "textSize";
|
|
854
|
+
return void 0;
|
|
855
|
+
}
|
|
856
|
+
async function uploadDirect(job, size) {
|
|
735
857
|
const { kind } = job;
|
|
736
|
-
|
|
858
|
+
if (kind === "file") return skip(kind, job.name, "noStorage");
|
|
859
|
+
const { bytes: ceiling, size: tooBig } = KINDS[kind];
|
|
860
|
+
if (size > ceiling) return skip(kind, job.name, tooBig);
|
|
737
861
|
let bytes;
|
|
738
862
|
if (job.body !== void 0) bytes = job.body;
|
|
739
|
-
else if (job.path === void 0) return skip(kind, job.name, "missing");
|
|
740
863
|
else {
|
|
741
864
|
try {
|
|
742
|
-
|
|
743
|
-
bytes = await import_node_fs3.promises.readFile(job.path);
|
|
865
|
+
bytes = await import_node_fs4.promises.readFile(job.path);
|
|
744
866
|
} catch {
|
|
745
867
|
return skip(kind, job.name, "missing");
|
|
746
868
|
}
|
|
@@ -752,32 +874,96 @@ async function startRun(passed, declared) {
|
|
|
752
874
|
bytes,
|
|
753
875
|
job.type
|
|
754
876
|
);
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
877
|
+
if (out.ok) countsOf(kind).uploaded += 1;
|
|
878
|
+
else failed(job, out.detail || out.kind);
|
|
879
|
+
}
|
|
880
|
+
async function uploadToBucket(job, size, first) {
|
|
881
|
+
const source = job.body !== void 0 ? { body: job.body } : { path: job.path, size };
|
|
882
|
+
let link = first;
|
|
883
|
+
for (let renewed = false; ; ) {
|
|
884
|
+
const put = await client.put(link.url, link.headers, source);
|
|
885
|
+
if (put.ok) break;
|
|
886
|
+
if (put.status !== 403 || renewed) return failedToBucket(job, put.code);
|
|
887
|
+
renewed = true;
|
|
888
|
+
const again = await ask(job, size);
|
|
889
|
+
if (!again.ok) return failedToBucket(job, codeOf(again));
|
|
890
|
+
const fresh = linkOf(again.body?.files?.[0]);
|
|
891
|
+
if (fresh === void 0) return failedToBucket(job, "bad_answer");
|
|
892
|
+
link = fresh;
|
|
893
|
+
}
|
|
894
|
+
const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {});
|
|
895
|
+
if (!done2.ok) return failedToBucket(job, codeOf(done2));
|
|
896
|
+
countsOf(job.kind).uploaded += 1;
|
|
897
|
+
}
|
|
898
|
+
let free = UPLOADS_AT_ONCE;
|
|
899
|
+
const waiting = [];
|
|
900
|
+
async function gated(work) {
|
|
901
|
+
if (free === 0) await new Promise((resolve2) => waiting.push(resolve2));
|
|
902
|
+
else free -= 1;
|
|
903
|
+
try {
|
|
904
|
+
return await work();
|
|
905
|
+
} finally {
|
|
906
|
+
const next = waiting.shift();
|
|
907
|
+
if (next !== void 0) next();
|
|
908
|
+
else free += 1;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
async function plan(job) {
|
|
912
|
+
const { kind } = job;
|
|
913
|
+
let size;
|
|
914
|
+
if (job.body !== void 0) size = job.body.length;
|
|
915
|
+
else if (job.path === void 0) return void skip(kind, job.name, "missing");
|
|
916
|
+
else {
|
|
917
|
+
try {
|
|
918
|
+
size = (await import_node_fs4.promises.stat(job.path)).size;
|
|
919
|
+
} catch {
|
|
920
|
+
return void skip(kind, job.name, "missing");
|
|
921
|
+
}
|
|
759
922
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
923
|
+
if (size === 0) return void skip(kind, job.name, "empty");
|
|
924
|
+
const direct = () => {
|
|
925
|
+
if (kind === "file") return void skip(kind, job.name, "noStorage");
|
|
926
|
+
const roomKey = `${job.id}/${kind}`;
|
|
927
|
+
const left = rooms.get(roomKey) ?? KINDS[kind].room;
|
|
928
|
+
if (left === 0) return void skip(kind, job.name, KINDS[kind].limit);
|
|
929
|
+
rooms.set(roomKey, left - 1);
|
|
930
|
+
return () => uploadDirect(job, size);
|
|
931
|
+
};
|
|
932
|
+
if (uploadsMissing) return direct();
|
|
933
|
+
const out = await gated(() => ask(job, size));
|
|
934
|
+
if (!out.ok) {
|
|
935
|
+
if (out.status === 404 && out.detail === "" && out.code === void 0) {
|
|
936
|
+
uploadsMissing = true;
|
|
937
|
+
return direct();
|
|
938
|
+
}
|
|
939
|
+
if (out.code !== void 0) failedToBucket(job, out.code);
|
|
940
|
+
else failed(job, out.detail || out.kind);
|
|
941
|
+
return void 0;
|
|
763
942
|
}
|
|
943
|
+
const item = out.body?.files?.[0];
|
|
944
|
+
if (item?.skipped === "no_storage") return void skip(kind, job.name, refusedHere(job, size) ?? "noStorage");
|
|
945
|
+
if (item?.direct === true) return direct();
|
|
946
|
+
const link = linkOf(item);
|
|
947
|
+
if (link === void 0) return void failed(job, "bad_answer");
|
|
948
|
+
if (!bucketSeen) {
|
|
949
|
+
bucketSeen = true;
|
|
950
|
+
log(BUCKET_FOUND);
|
|
951
|
+
}
|
|
952
|
+
return () => uploadToBucket(job, size, link);
|
|
764
953
|
}
|
|
765
954
|
async function upload(items, filesByKey) {
|
|
766
955
|
if (filesByKey.size === 0 || !Array.isArray(items)) return;
|
|
767
956
|
const jobs = [];
|
|
768
957
|
for (const item of items) {
|
|
769
958
|
if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
|
|
770
|
-
const room = { screenshot: KINDS.screenshot.room, text: KINDS.text.room };
|
|
771
959
|
for (const file of filesByKey.get(item.resultKey) ?? []) {
|
|
772
960
|
const type = mediaTypeOf(file.contentType) ?? (file.path === void 0 ? void 0 : TYPE_BY_EXTENSION[(0, import_node_path3.extname)(file.path).toLowerCase()]);
|
|
773
|
-
|
|
774
|
-
|
|
961
|
+
if (type === void 0 || type === METADATA_TYPE2) continue;
|
|
962
|
+
const kind = kindOf(type);
|
|
963
|
+
if (kind !== "text" && file.path === void 0) continue;
|
|
775
964
|
const name = nameOf(file);
|
|
776
965
|
if (file.name.startsWith("_")) skip(kind, name, "hidden");
|
|
777
|
-
else if (kind === "screenshot" && !SCREENSHOT_TYPES.has(type)) skip(kind, name, "type");
|
|
778
|
-
else if (room[kind] === 0) skip(kind, name, KINDS[kind].limit);
|
|
779
966
|
else {
|
|
780
|
-
room[kind] -= 1;
|
|
781
967
|
jobs.push({
|
|
782
968
|
id: item.id,
|
|
783
969
|
kind,
|
|
@@ -789,10 +975,16 @@ async function startRun(passed, declared) {
|
|
|
789
975
|
}
|
|
790
976
|
}
|
|
791
977
|
}
|
|
792
|
-
|
|
978
|
+
const byResult = /* @__PURE__ */ new Map();
|
|
979
|
+
for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
|
|
793
980
|
await Promise.all(
|
|
794
|
-
|
|
795
|
-
|
|
981
|
+
[...byResult.values()].map(async (group) => {
|
|
982
|
+
const work = [];
|
|
983
|
+
for (const job of group) {
|
|
984
|
+
const act = await plan(job);
|
|
985
|
+
if (act !== void 0) work.push(gated(act));
|
|
986
|
+
}
|
|
987
|
+
await Promise.all(work);
|
|
796
988
|
})
|
|
797
989
|
);
|
|
798
990
|
}
|
|
@@ -870,8 +1062,20 @@ async function startRun(passed, declared) {
|
|
|
870
1062
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
871
1063
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
872
1064
|
if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
|
|
873
|
-
|
|
874
|
-
|
|
1065
|
+
const kinds = [stats.screenshots, stats.textFiles, stats.otherFiles];
|
|
1066
|
+
const sum = (what) => kinds.reduce((n, counts) => n + counts[what], 0);
|
|
1067
|
+
if (bucketSeen) {
|
|
1068
|
+
const other = sum("skipped") - noStorage;
|
|
1069
|
+
parts.push(
|
|
1070
|
+
`${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` : "")
|
|
1071
|
+
);
|
|
1072
|
+
} else {
|
|
1073
|
+
for (const said of [tally(stats.screenshots, "screenshot"), tally(stats.textFiles, "text file")]) {
|
|
1074
|
+
if (said !== void 0) parts.push(said);
|
|
1075
|
+
}
|
|
1076
|
+
const other = stats.otherFiles;
|
|
1077
|
+
if (other.skipped > 0) parts.push(`${other.skipped} file(s) not stored (no bucket connected)`);
|
|
1078
|
+
if (other.failed > 0) parts.push(`${other.failed} file(s) failed to upload`);
|
|
875
1079
|
}
|
|
876
1080
|
if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
|
|
877
1081
|
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
package/dist/reporter-core.d.cts
CHANGED
|
@@ -287,6 +287,15 @@ interface RunStats extends SubmitCounts {
|
|
|
287
287
|
skipped: number;
|
|
288
288
|
failed: number;
|
|
289
289
|
};
|
|
290
|
+
/**
|
|
291
|
+
* Every other file (a trace, a video, a page): only a project's bucket takes them (plune-ai/plune#946), so
|
|
292
|
+
* without one they are counted as skipped. With one, the summary adds all three kinds up.
|
|
293
|
+
*/
|
|
294
|
+
otherFiles: {
|
|
295
|
+
uploaded: number;
|
|
296
|
+
skipped: number;
|
|
297
|
+
failed: number;
|
|
298
|
+
};
|
|
290
299
|
}
|
|
291
300
|
/** Everything the core needs to talk to a deployment. */
|
|
292
301
|
interface ReporterConfig {
|
|
@@ -532,6 +541,26 @@ type ClientOutcome<T> = {
|
|
|
532
541
|
kind: ClientFailureKind;
|
|
533
542
|
status: number | null;
|
|
534
543
|
detail: string;
|
|
544
|
+
code?: string;
|
|
545
|
+
};
|
|
546
|
+
/** What a PUT to a bucket carries: a file on disk, streamed at a length known beforehand, or bytes in memory. */
|
|
547
|
+
type PutSource = {
|
|
548
|
+
path: string;
|
|
549
|
+
size: number;
|
|
550
|
+
} | {
|
|
551
|
+
body: Buffer;
|
|
552
|
+
};
|
|
553
|
+
/**
|
|
554
|
+
* How a PUT ended. Never a URL, never a message: the link is a write key to someone's bucket, and the
|
|
555
|
+
* errors of the transports (`getaddrinfo ENOTFOUND <host>`, `no answer from <origin>`) name the host. All
|
|
556
|
+
* that survives is a status and a short code.
|
|
557
|
+
*/
|
|
558
|
+
type PutOutcome = {
|
|
559
|
+
ok: true;
|
|
560
|
+
} | {
|
|
561
|
+
ok: false;
|
|
562
|
+
status: number | null;
|
|
563
|
+
code: string;
|
|
535
564
|
};
|
|
536
565
|
interface ClientOptions {
|
|
537
566
|
apiUrl: string;
|
|
@@ -554,6 +583,12 @@ interface PlatformClient {
|
|
|
554
583
|
* a proxy error page that echoes the request would put a token in a CI log.
|
|
555
584
|
*/
|
|
556
585
|
del<T>(path: string): Promise<ClientOutcome<T>>;
|
|
586
|
+
/**
|
|
587
|
+
* The bytes to a short-lived link of the customer's bucket (plune-ai/plune#946): exactly the headers the
|
|
588
|
+
* link came with, no token of ours. Network failures and 5xx wait and try again, up to three times; a 403
|
|
589
|
+
* (the link has expired) comes back at once, for the caller to ask for a new one.
|
|
590
|
+
*/
|
|
591
|
+
put(url: string, headers: Record<string, string>, source: PutSource): Promise<PutOutcome>;
|
|
557
592
|
}
|
|
558
593
|
declare function createClient(opts: ClientOptions): PlatformClient;
|
|
559
594
|
|
package/dist/reporter-core.d.ts
CHANGED
|
@@ -287,6 +287,15 @@ interface RunStats extends SubmitCounts {
|
|
|
287
287
|
skipped: number;
|
|
288
288
|
failed: number;
|
|
289
289
|
};
|
|
290
|
+
/**
|
|
291
|
+
* Every other file (a trace, a video, a page): only a project's bucket takes them (plune-ai/plune#946), so
|
|
292
|
+
* without one they are counted as skipped. With one, the summary adds all three kinds up.
|
|
293
|
+
*/
|
|
294
|
+
otherFiles: {
|
|
295
|
+
uploaded: number;
|
|
296
|
+
skipped: number;
|
|
297
|
+
failed: number;
|
|
298
|
+
};
|
|
290
299
|
}
|
|
291
300
|
/** Everything the core needs to talk to a deployment. */
|
|
292
301
|
interface ReporterConfig {
|
|
@@ -532,6 +541,26 @@ type ClientOutcome<T> = {
|
|
|
532
541
|
kind: ClientFailureKind;
|
|
533
542
|
status: number | null;
|
|
534
543
|
detail: string;
|
|
544
|
+
code?: string;
|
|
545
|
+
};
|
|
546
|
+
/** What a PUT to a bucket carries: a file on disk, streamed at a length known beforehand, or bytes in memory. */
|
|
547
|
+
type PutSource = {
|
|
548
|
+
path: string;
|
|
549
|
+
size: number;
|
|
550
|
+
} | {
|
|
551
|
+
body: Buffer;
|
|
552
|
+
};
|
|
553
|
+
/**
|
|
554
|
+
* How a PUT ended. Never a URL, never a message: the link is a write key to someone's bucket, and the
|
|
555
|
+
* errors of the transports (`getaddrinfo ENOTFOUND <host>`, `no answer from <origin>`) name the host. All
|
|
556
|
+
* that survives is a status and a short code.
|
|
557
|
+
*/
|
|
558
|
+
type PutOutcome = {
|
|
559
|
+
ok: true;
|
|
560
|
+
} | {
|
|
561
|
+
ok: false;
|
|
562
|
+
status: number | null;
|
|
563
|
+
code: string;
|
|
535
564
|
};
|
|
536
565
|
interface ClientOptions {
|
|
537
566
|
apiUrl: string;
|
|
@@ -554,6 +583,12 @@ interface PlatformClient {
|
|
|
554
583
|
* a proxy error page that echoes the request would put a token in a CI log.
|
|
555
584
|
*/
|
|
556
585
|
del<T>(path: string): Promise<ClientOutcome<T>>;
|
|
586
|
+
/**
|
|
587
|
+
* The bytes to a short-lived link of the customer's bucket (plune-ai/plune#946): exactly the headers the
|
|
588
|
+
* link came with, no token of ours. Network failures and 5xx wait and try again, up to three times; a 403
|
|
589
|
+
* (the link has expired) comes back at once, for the caller to ask for a new one.
|
|
590
|
+
*/
|
|
591
|
+
put(url: string, headers: Record<string, string>, source: PutSource): Promise<PutOutcome>;
|
|
557
592
|
}
|
|
558
593
|
declare function createClient(opts: ClientOptions): PlatformClient;
|
|
559
594
|
|