@plune-ai/cli 0.22.0 → 0.24.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 +16 -8
- package/dist/cli.cjs +307 -33
- package/dist/index.cjs +9 -2
- package/dist/index.js +9 -2
- package/dist/reporter-core.cjs +278 -22
- package/dist/reporter-core.js +274 -18
- package/package.json +1 -1
package/dist/reporter-core.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/reporter-core/session.ts
|
|
2
|
-
import { createHash, randomUUID } from "crypto";
|
|
2
|
+
import { createHash as createHash2, randomUUID } from "crypto";
|
|
3
3
|
import { promises as disk } from "fs";
|
|
4
4
|
import { basename as basename2, extname } from "path";
|
|
5
5
|
import { TextDecoder } from "util";
|
|
@@ -470,6 +470,27 @@ function webLink(href) {
|
|
|
470
470
|
return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
|
|
471
471
|
}
|
|
472
472
|
|
|
473
|
+
// src/reporter-core/files-batch.ts
|
|
474
|
+
import { createHash } from "crypto";
|
|
475
|
+
import { createReadStream as createReadStream2 } from "fs";
|
|
476
|
+
var FILES_BATCH_MAX = 100;
|
|
477
|
+
var DIRECT_BATCH_BYTES = 6 * 1024 * 1024;
|
|
478
|
+
var BATCH_PIPELINES = 2;
|
|
479
|
+
async function sha256File(path3) {
|
|
480
|
+
const hash = createHash("sha256");
|
|
481
|
+
for await (const part of createReadStream2(path3)) hash.update(part);
|
|
482
|
+
return hash.digest("hex");
|
|
483
|
+
}
|
|
484
|
+
var sha256Bytes = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
485
|
+
var BUCKET_CODES = /* @__PURE__ */ new Set([
|
|
486
|
+
"bucket_unreachable",
|
|
487
|
+
"not_connected",
|
|
488
|
+
"platform_not_configured",
|
|
489
|
+
"upload_expired",
|
|
490
|
+
"file_mismatch",
|
|
491
|
+
"file_missing"
|
|
492
|
+
]);
|
|
493
|
+
|
|
473
494
|
// src/reporter-core/fallback.ts
|
|
474
495
|
import * as fs from "fs";
|
|
475
496
|
import * as path2 from "path";
|
|
@@ -582,7 +603,7 @@ function tally(counts, noun) {
|
|
|
582
603
|
}
|
|
583
604
|
function boundKey(key) {
|
|
584
605
|
if (key.value.length <= KEY_MAX) return key;
|
|
585
|
-
const digest =
|
|
606
|
+
const digest = createHash2("sha256").update(key.value).digest("hex").slice(0, 16);
|
|
586
607
|
let head = key.value.slice(0, KEY_MAX - digest.length - 1);
|
|
587
608
|
if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
|
|
588
609
|
return { ...key, value: `${head}#${digest}` };
|
|
@@ -644,6 +665,9 @@ async function startRun(passed, declared) {
|
|
|
644
665
|
const uploadRetries = Math.min(Math.max(Math.floor(cfg.uploadRetries ?? UPLOAD_RETRIES.default), UPLOAD_RETRIES.min), UPLOAD_RETRIES.max);
|
|
645
666
|
let retried = 0;
|
|
646
667
|
let uploadMs = 0;
|
|
668
|
+
let requests = 0;
|
|
669
|
+
let existed = 0;
|
|
670
|
+
let batchMissing = false;
|
|
647
671
|
const client = createClient({
|
|
648
672
|
apiUrl: resolveApiUrl(cfg.apiUrl),
|
|
649
673
|
token,
|
|
@@ -827,6 +851,7 @@ async function startRun(passed, declared) {
|
|
|
827
851
|
else log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
|
|
828
852
|
}
|
|
829
853
|
function ask(job, size) {
|
|
854
|
+
requests += 1;
|
|
830
855
|
return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
|
|
831
856
|
files: [{ name: job.name, contentType: job.type, size }]
|
|
832
857
|
}, uploadRetries);
|
|
@@ -868,6 +893,7 @@ async function startRun(passed, declared) {
|
|
|
868
893
|
}
|
|
869
894
|
const refusal = kind === "text" ? textRefusal(bytes) : void 0;
|
|
870
895
|
if (refusal !== void 0) return skip(kind, job.name, refusal);
|
|
896
|
+
requests += 1;
|
|
871
897
|
const out = await client.upload(
|
|
872
898
|
`/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
|
|
873
899
|
bytes,
|
|
@@ -890,6 +916,7 @@ async function startRun(passed, declared) {
|
|
|
890
916
|
if (fresh === void 0) return failedToBucket(job, "bad_answer");
|
|
891
917
|
link = fresh;
|
|
892
918
|
}
|
|
919
|
+
requests += 1;
|
|
893
920
|
const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {}, uploadRetries);
|
|
894
921
|
if (!done2.ok) return failedToBucket(job, codeOf(done2));
|
|
895
922
|
countsOf(job.kind).uploaded += 1;
|
|
@@ -950,6 +977,245 @@ async function startRun(passed, declared) {
|
|
|
950
977
|
}
|
|
951
978
|
return () => uploadToBucket(job, size, link);
|
|
952
979
|
}
|
|
980
|
+
async function uploadEach(jobs) {
|
|
981
|
+
const byResult = /* @__PURE__ */ new Map();
|
|
982
|
+
for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
|
|
983
|
+
await Promise.all(
|
|
984
|
+
[...byResult.values()].map(async (group) => {
|
|
985
|
+
const work = [];
|
|
986
|
+
for (const job of group) {
|
|
987
|
+
const act = await plan(job);
|
|
988
|
+
if (act !== void 0) work.push(gated(act));
|
|
989
|
+
}
|
|
990
|
+
await Promise.all(work);
|
|
991
|
+
})
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
async function measure(job) {
|
|
995
|
+
let size;
|
|
996
|
+
if (job.body !== void 0) size = job.body.length;
|
|
997
|
+
else if (job.path === void 0) return void skip(job.kind, job.name, "missing");
|
|
998
|
+
else {
|
|
999
|
+
try {
|
|
1000
|
+
size = (await disk.stat(job.path)).size;
|
|
1001
|
+
} catch {
|
|
1002
|
+
return void skip(job.kind, job.name, "missing");
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (size === 0) return void skip(job.kind, job.name, "empty");
|
|
1006
|
+
return size;
|
|
1007
|
+
}
|
|
1008
|
+
const routeMissing = (out) => !out.ok && out.status === 404 && out.detail === "" && out.code === void 0;
|
|
1009
|
+
function takeRoom(job) {
|
|
1010
|
+
if (job.kind === "file") {
|
|
1011
|
+
skip(job.kind, job.name, "noStorage");
|
|
1012
|
+
return false;
|
|
1013
|
+
}
|
|
1014
|
+
const roomKey = `${job.id}/${job.kind}`;
|
|
1015
|
+
const left = rooms.get(roomKey) ?? KINDS[job.kind].room;
|
|
1016
|
+
if (left === 0) {
|
|
1017
|
+
skip(job.kind, job.name, KINDS[job.kind].limit);
|
|
1018
|
+
return false;
|
|
1019
|
+
}
|
|
1020
|
+
rooms.set(roomKey, left - 1);
|
|
1021
|
+
return true;
|
|
1022
|
+
}
|
|
1023
|
+
function refusedItem(job, size, item) {
|
|
1024
|
+
const code = item.code ?? "refused";
|
|
1025
|
+
if (code === "duplicate") existed += 1;
|
|
1026
|
+
else if (code === "no_storage") skip(job.kind, job.name, refusedHere(job, size) ?? "noStorage");
|
|
1027
|
+
else if (code === "too_many_files") skip(job.kind, job.name, KINDS[job.kind].limit);
|
|
1028
|
+
else if (BUCKET_CODES.has(code)) failedToBucket(job, code);
|
|
1029
|
+
else failed(job, code);
|
|
1030
|
+
}
|
|
1031
|
+
const itemPause = (round) => new Promise((r) => setTimeout(r, Math.min(250 * 2 ** round, 4e3)));
|
|
1032
|
+
async function batchOf(jobs) {
|
|
1033
|
+
if (batchMissing) return uploadEach(jobs);
|
|
1034
|
+
const sized = [];
|
|
1035
|
+
for (const job of jobs) {
|
|
1036
|
+
const size = await measure(job);
|
|
1037
|
+
if (size === void 0) continue;
|
|
1038
|
+
try {
|
|
1039
|
+
sized.push({ job, size, sha256: job.body !== void 0 ? sha256Bytes(job.body) : await sha256File(job.path) });
|
|
1040
|
+
} catch {
|
|
1041
|
+
skip(job.kind, job.name, "missing");
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
if (sized.length === 0) return;
|
|
1045
|
+
const announced = await gated(() => {
|
|
1046
|
+
requests += 1;
|
|
1047
|
+
return client.post(
|
|
1048
|
+
"/v1/files/uploads-batch",
|
|
1049
|
+
{ items: sized.map((s) => ({ resultId: s.job.id, name: s.job.name, contentType: s.job.type, size: s.size, sha256: s.sha256 })) },
|
|
1050
|
+
uploadRetries
|
|
1051
|
+
);
|
|
1052
|
+
});
|
|
1053
|
+
if (routeMissing(announced)) {
|
|
1054
|
+
batchMissing = true;
|
|
1055
|
+
return uploadEach(sized.map((s) => s.job));
|
|
1056
|
+
}
|
|
1057
|
+
const verdicts = announced.ok ? announced.body?.items : void 0;
|
|
1058
|
+
if (!announced.ok || !Array.isArray(verdicts) || verdicts.length !== sized.length) {
|
|
1059
|
+
for (const s of sized) {
|
|
1060
|
+
if (announced.ok) failed(s.job, "bad_answer");
|
|
1061
|
+
else if (announced.code !== void 0) failedToBucket(s.job, announced.code);
|
|
1062
|
+
else failed(s.job, announced.detail || announced.kind);
|
|
1063
|
+
}
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
const puts = [];
|
|
1067
|
+
const directs = [];
|
|
1068
|
+
sized.forEach((s, i) => {
|
|
1069
|
+
const v = verdicts[i] ?? {};
|
|
1070
|
+
if (v.status === "exists") existed += 1;
|
|
1071
|
+
else if (v.status === "direct") {
|
|
1072
|
+
if (takeRoom(s.job)) directs.push(s);
|
|
1073
|
+
} else if (v.status === "upload") {
|
|
1074
|
+
const link = linkOf(v);
|
|
1075
|
+
if (link === void 0) return failed(s.job, "bad_answer");
|
|
1076
|
+
if (!bucketSeen) {
|
|
1077
|
+
bucketSeen = true;
|
|
1078
|
+
log(BUCKET_FOUND);
|
|
1079
|
+
}
|
|
1080
|
+
puts.push({ s, link });
|
|
1081
|
+
} else if (v.status === "refused") refusedItem(s.job, s.size, v);
|
|
1082
|
+
else failed(s.job, "bad_answer");
|
|
1083
|
+
});
|
|
1084
|
+
const toComplete = [];
|
|
1085
|
+
await Promise.all(
|
|
1086
|
+
puts.map(
|
|
1087
|
+
({ s, link: first }) => gated(async () => {
|
|
1088
|
+
const source = s.job.body !== void 0 ? { body: s.job.body } : { path: s.job.path, size: s.size };
|
|
1089
|
+
let link = first;
|
|
1090
|
+
for (let renewed = false; ; ) {
|
|
1091
|
+
const put = await client.put(link.url, link.headers, source);
|
|
1092
|
+
if (put.ok) break;
|
|
1093
|
+
if (put.status !== 403 || renewed) return failedToBucket(s.job, put.code);
|
|
1094
|
+
renewed = true;
|
|
1095
|
+
requests += 1;
|
|
1096
|
+
const again = await client.post(
|
|
1097
|
+
"/v1/files/uploads-batch",
|
|
1098
|
+
{ items: [{ resultId: s.job.id, name: s.job.name, contentType: s.job.type, size: s.size, sha256: s.sha256 }] },
|
|
1099
|
+
uploadRetries
|
|
1100
|
+
);
|
|
1101
|
+
const fresh = again.ok ? linkOf(again.body?.items?.[0]) : void 0;
|
|
1102
|
+
if (fresh === void 0) return failedToBucket(s.job, again.ok ? "bad_answer" : codeOf(again));
|
|
1103
|
+
link = fresh;
|
|
1104
|
+
}
|
|
1105
|
+
toComplete.push({ job: s.job, id: link.id });
|
|
1106
|
+
})
|
|
1107
|
+
)
|
|
1108
|
+
);
|
|
1109
|
+
await complete(toComplete);
|
|
1110
|
+
await sendDirect(directs);
|
|
1111
|
+
}
|
|
1112
|
+
async function complete(items) {
|
|
1113
|
+
for (let round = 0; items.length > 0; round += 1) {
|
|
1114
|
+
const out = await gated(() => {
|
|
1115
|
+
requests += 1;
|
|
1116
|
+
return client.post("/v1/files/complete-batch", { ids: items.map((i) => i.id) }, uploadRetries);
|
|
1117
|
+
});
|
|
1118
|
+
if (routeMissing(out)) {
|
|
1119
|
+
await Promise.all(
|
|
1120
|
+
items.map(
|
|
1121
|
+
(i) => gated(async () => {
|
|
1122
|
+
requests += 1;
|
|
1123
|
+
const one = await client.post(`/v1/files/${encodeURIComponent(i.id)}/complete`, {}, uploadRetries);
|
|
1124
|
+
if (one.ok) countsOf(i.job.kind).uploaded += 1;
|
|
1125
|
+
else failedToBucket(i.job, codeOf(one));
|
|
1126
|
+
})
|
|
1127
|
+
)
|
|
1128
|
+
);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
const verdicts = out.ok ? out.body?.items : void 0;
|
|
1132
|
+
if (!out.ok || !Array.isArray(verdicts) || verdicts.length !== items.length) {
|
|
1133
|
+
for (const i of items) failedToBucket(i.job, out.ok ? "bad_answer" : codeOf(out));
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
const again = [];
|
|
1137
|
+
items.forEach((i, at) => {
|
|
1138
|
+
const v = verdicts[at] ?? {};
|
|
1139
|
+
if (v.status === "ready") countsOf(i.job.kind).uploaded += 1;
|
|
1140
|
+
else if (v.retryable === true && round < Math.min(uploadRetries, 3)) again.push(i);
|
|
1141
|
+
else refusedItem(i.job, 0, v.status === "refused" ? v : { code: "bad_answer" });
|
|
1142
|
+
});
|
|
1143
|
+
items = again;
|
|
1144
|
+
if (items.length > 0) {
|
|
1145
|
+
retried += items.length;
|
|
1146
|
+
await itemPause(round);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
async function sendDirect(items) {
|
|
1151
|
+
const groups = [];
|
|
1152
|
+
let bytes = 0;
|
|
1153
|
+
for (const item of items) {
|
|
1154
|
+
const group = groups[groups.length - 1];
|
|
1155
|
+
if (group === void 0 || group.length >= FILES_BATCH_MAX || bytes + item.size > DIRECT_BATCH_BYTES) {
|
|
1156
|
+
groups.push([item]);
|
|
1157
|
+
bytes = item.size;
|
|
1158
|
+
} else {
|
|
1159
|
+
group.push(item);
|
|
1160
|
+
bytes += item.size;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
for (const group of groups) {
|
|
1164
|
+
const body = [];
|
|
1165
|
+
const sent = [];
|
|
1166
|
+
for (const { job, size } of group) {
|
|
1167
|
+
const { kind } = job;
|
|
1168
|
+
if (size > KINDS[kind].bytes) {
|
|
1169
|
+
skip(kind, job.name, KINDS[kind].size);
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
let data;
|
|
1173
|
+
try {
|
|
1174
|
+
data = job.body ?? await disk.readFile(job.path);
|
|
1175
|
+
} catch {
|
|
1176
|
+
skip(kind, job.name, "missing");
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
const refusal = kind === "text" ? textRefusal(data) : void 0;
|
|
1180
|
+
if (refusal !== void 0) {
|
|
1181
|
+
skip(kind, job.name, refusal);
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
body.push({ resultId: job.id, name: job.name, contentType: job.type, data: data.toString("base64") });
|
|
1185
|
+
sent.push({ job, size });
|
|
1186
|
+
}
|
|
1187
|
+
if (body.length === 0) continue;
|
|
1188
|
+
const out = await gated(() => {
|
|
1189
|
+
requests += 1;
|
|
1190
|
+
return client.post("/v1/files/direct-batch", { items: body }, uploadRetries);
|
|
1191
|
+
});
|
|
1192
|
+
if (routeMissing(out)) {
|
|
1193
|
+
batchMissing = true;
|
|
1194
|
+
for (const { job, size } of sent) await gated(() => uploadDirect(job, size));
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
const verdicts = out.ok ? out.body?.items : void 0;
|
|
1198
|
+
if (!out.ok || !Array.isArray(verdicts) || verdicts.length !== sent.length) {
|
|
1199
|
+
for (const { job } of sent) failed(job, out.ok ? "bad_answer" : out.detail || out.kind);
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
sent.forEach(({ job }, i) => {
|
|
1203
|
+
const v = verdicts[i] ?? {};
|
|
1204
|
+
if (v.status === "stored") countsOf(job.kind).uploaded += 1;
|
|
1205
|
+
else if (v.status === "exists") existed += 1;
|
|
1206
|
+
else refusedItem(job, 0, v.status === "refused" ? v : { code: "bad_answer" });
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
async function uploadBatched(jobs) {
|
|
1211
|
+
const batches = chunk(jobs, FILES_BATCH_MAX);
|
|
1212
|
+
let next = 0;
|
|
1213
|
+
await Promise.all(
|
|
1214
|
+
Array.from({ length: Math.min(BATCH_PIPELINES, batches.length) }, async () => {
|
|
1215
|
+
for (let at = next++; at < batches.length; at = next++) await batchOf(batches[at]);
|
|
1216
|
+
})
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
953
1219
|
async function upload(items, filesByKey) {
|
|
954
1220
|
if (filesByKey.size === 0 || !Array.isArray(items)) return;
|
|
955
1221
|
const jobs = [];
|
|
@@ -975,18 +1241,8 @@ async function startRun(passed, declared) {
|
|
|
975
1241
|
}
|
|
976
1242
|
}
|
|
977
1243
|
const started = Date.now();
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
await Promise.all(
|
|
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);
|
|
988
|
-
})
|
|
989
|
-
);
|
|
1244
|
+
if (batchMissing) await uploadEach(jobs);
|
|
1245
|
+
else await uploadBatched(jobs);
|
|
990
1246
|
uploadMs += Date.now() - started;
|
|
991
1247
|
}
|
|
992
1248
|
async function flush() {
|
|
@@ -1082,9 +1338,9 @@ async function startRun(passed, declared) {
|
|
|
1082
1338
|
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
1083
1339
|
const files = [stats.screenshots, stats.textFiles, stats.otherFiles];
|
|
1084
1340
|
const total = (what) => files.reduce((n, counts) => n + counts[what], 0);
|
|
1085
|
-
if (total("uploaded") + total("skipped") + total("failed") > 0) {
|
|
1341
|
+
if (total("uploaded") + total("skipped") + total("failed") + existed > 0) {
|
|
1086
1342
|
log(
|
|
1087
|
-
`plune: files \u2014 ${total("uploaded")} uploaded, ${total("skipped")} skipped, ${retried} retried, ${total("failed")} dropped, in ${(uploadMs / 1e3).toFixed(1)} s (${uploadsAtOnce} at once)`
|
|
1343
|
+
`plune: files \u2014 ${total("uploaded")} uploaded, ${total("skipped")} skipped, ${existed} already there (skipped), ${retried} retried, ${total("failed")} dropped, in ${(uploadMs / 1e3).toFixed(1)} s (${uploadsAtOnce} at once, ${requests} request(s) to Plune)`
|
|
1088
1344
|
);
|
|
1089
1345
|
}
|
|
1090
1346
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
@@ -1143,9 +1399,9 @@ async function startRun(passed, declared) {
|
|
|
1143
1399
|
}
|
|
1144
1400
|
|
|
1145
1401
|
// src/util/hash.ts
|
|
1146
|
-
import { createHash as
|
|
1402
|
+
import { createHash as createHash3 } from "crypto";
|
|
1147
1403
|
function sha256(input) {
|
|
1148
|
-
return
|
|
1404
|
+
return createHash3("sha256").update(input, "utf8").digest("hex");
|
|
1149
1405
|
}
|
|
1150
1406
|
|
|
1151
1407
|
// src/reporter-core/result-key.ts
|
package/package.json
CHANGED