@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.
@@ -48,8 +48,8 @@ __export(reporter_core_exports, {
48
48
  module.exports = __toCommonJS(reporter_core_exports);
49
49
 
50
50
  // src/reporter-core/session.ts
51
- var import_node_crypto = require("crypto");
52
- var import_node_fs4 = require("fs");
51
+ var import_node_crypto2 = require("crypto");
52
+ var import_node_fs5 = require("fs");
53
53
  var import_node_path3 = require("path");
54
54
  var import_node_util2 = require("util");
55
55
 
@@ -519,6 +519,27 @@ function webLink(href) {
519
519
  return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
520
520
  }
521
521
 
522
+ // src/reporter-core/files-batch.ts
523
+ var import_node_crypto = require("crypto");
524
+ var import_node_fs4 = require("fs");
525
+ var FILES_BATCH_MAX = 100;
526
+ var DIRECT_BATCH_BYTES = 6 * 1024 * 1024;
527
+ var BATCH_PIPELINES = 2;
528
+ async function sha256File(path3) {
529
+ const hash = (0, import_node_crypto.createHash)("sha256");
530
+ for await (const part of (0, import_node_fs4.createReadStream)(path3)) hash.update(part);
531
+ return hash.digest("hex");
532
+ }
533
+ var sha256Bytes = (bytes) => (0, import_node_crypto.createHash)("sha256").update(bytes).digest("hex");
534
+ var BUCKET_CODES = /* @__PURE__ */ new Set([
535
+ "bucket_unreachable",
536
+ "not_connected",
537
+ "platform_not_configured",
538
+ "upload_expired",
539
+ "file_mismatch",
540
+ "file_missing"
541
+ ]);
542
+
522
543
  // src/reporter-core/fallback.ts
523
544
  var fs = __toESM(require("fs"), 1);
524
545
  var path2 = __toESM(require("path"), 1);
@@ -631,7 +652,7 @@ function tally(counts, noun) {
631
652
  }
632
653
  function boundKey(key) {
633
654
  if (key.value.length <= KEY_MAX) return key;
634
- const digest = (0, import_node_crypto.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
655
+ const digest = (0, import_node_crypto2.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
635
656
  let head = key.value.slice(0, KEY_MAX - digest.length - 1);
636
657
  if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
637
658
  return { ...key, value: `${head}#${digest}` };
@@ -685,7 +706,7 @@ async function startRun(passed, declared) {
685
706
  `));
686
707
  const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
687
708
  const externalKey = cfg.externalKey ?? null;
688
- const sessionMarker = (0, import_node_crypto.randomUUID)();
709
+ const sessionMarker = (0, import_node_crypto2.randomUUID)();
689
710
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
690
711
  const offerDiscovered = cfg.offerDiscovered ?? false;
691
712
  const token = cfg.token ?? loadToken() ?? "";
@@ -693,6 +714,9 @@ async function startRun(passed, declared) {
693
714
  const uploadRetries = Math.min(Math.max(Math.floor(cfg.uploadRetries ?? UPLOAD_RETRIES.default), UPLOAD_RETRIES.min), UPLOAD_RETRIES.max);
694
715
  let retried = 0;
695
716
  let uploadMs = 0;
717
+ let requests = 0;
718
+ let existed = 0;
719
+ let batchMissing = false;
696
720
  const client = createClient({
697
721
  apiUrl: resolveApiUrl(cfg.apiUrl),
698
722
  token,
@@ -876,6 +900,7 @@ async function startRun(passed, declared) {
876
900
  else log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
877
901
  }
878
902
  function ask(job, size) {
903
+ requests += 1;
879
904
  return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
880
905
  files: [{ name: job.name, contentType: job.type, size }]
881
906
  }, uploadRetries);
@@ -910,13 +935,14 @@ async function startRun(passed, declared) {
910
935
  if (job.body !== void 0) bytes = job.body;
911
936
  else {
912
937
  try {
913
- bytes = await import_node_fs4.promises.readFile(job.path);
938
+ bytes = await import_node_fs5.promises.readFile(job.path);
914
939
  } catch {
915
940
  return skip(kind, job.name, "missing");
916
941
  }
917
942
  }
918
943
  const refusal = kind === "text" ? textRefusal(bytes) : void 0;
919
944
  if (refusal !== void 0) return skip(kind, job.name, refusal);
945
+ requests += 1;
920
946
  const out = await client.upload(
921
947
  `/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
922
948
  bytes,
@@ -939,6 +965,7 @@ async function startRun(passed, declared) {
939
965
  if (fresh === void 0) return failedToBucket(job, "bad_answer");
940
966
  link = fresh;
941
967
  }
968
+ requests += 1;
942
969
  const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {}, uploadRetries);
943
970
  if (!done2.ok) return failedToBucket(job, codeOf(done2));
944
971
  countsOf(job.kind).uploaded += 1;
@@ -963,7 +990,7 @@ async function startRun(passed, declared) {
963
990
  else if (job.path === void 0) return void skip(kind, job.name, "missing");
964
991
  else {
965
992
  try {
966
- size = (await import_node_fs4.promises.stat(job.path)).size;
993
+ size = (await import_node_fs5.promises.stat(job.path)).size;
967
994
  } catch {
968
995
  return void skip(kind, job.name, "missing");
969
996
  }
@@ -999,6 +1026,245 @@ async function startRun(passed, declared) {
999
1026
  }
1000
1027
  return () => uploadToBucket(job, size, link);
1001
1028
  }
1029
+ async function uploadEach(jobs) {
1030
+ const byResult = /* @__PURE__ */ new Map();
1031
+ for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
1032
+ await Promise.all(
1033
+ [...byResult.values()].map(async (group) => {
1034
+ const work = [];
1035
+ for (const job of group) {
1036
+ const act = await plan(job);
1037
+ if (act !== void 0) work.push(gated(act));
1038
+ }
1039
+ await Promise.all(work);
1040
+ })
1041
+ );
1042
+ }
1043
+ async function measure(job) {
1044
+ let size;
1045
+ if (job.body !== void 0) size = job.body.length;
1046
+ else if (job.path === void 0) return void skip(job.kind, job.name, "missing");
1047
+ else {
1048
+ try {
1049
+ size = (await import_node_fs5.promises.stat(job.path)).size;
1050
+ } catch {
1051
+ return void skip(job.kind, job.name, "missing");
1052
+ }
1053
+ }
1054
+ if (size === 0) return void skip(job.kind, job.name, "empty");
1055
+ return size;
1056
+ }
1057
+ const routeMissing = (out) => !out.ok && out.status === 404 && out.detail === "" && out.code === void 0;
1058
+ function takeRoom(job) {
1059
+ if (job.kind === "file") {
1060
+ skip(job.kind, job.name, "noStorage");
1061
+ return false;
1062
+ }
1063
+ const roomKey = `${job.id}/${job.kind}`;
1064
+ const left = rooms.get(roomKey) ?? KINDS[job.kind].room;
1065
+ if (left === 0) {
1066
+ skip(job.kind, job.name, KINDS[job.kind].limit);
1067
+ return false;
1068
+ }
1069
+ rooms.set(roomKey, left - 1);
1070
+ return true;
1071
+ }
1072
+ function refusedItem(job, size, item) {
1073
+ const code = item.code ?? "refused";
1074
+ if (code === "duplicate") existed += 1;
1075
+ else if (code === "no_storage") skip(job.kind, job.name, refusedHere(job, size) ?? "noStorage");
1076
+ else if (code === "too_many_files") skip(job.kind, job.name, KINDS[job.kind].limit);
1077
+ else if (BUCKET_CODES.has(code)) failedToBucket(job, code);
1078
+ else failed(job, code);
1079
+ }
1080
+ const itemPause = (round) => new Promise((r) => setTimeout(r, Math.min(250 * 2 ** round, 4e3)));
1081
+ async function batchOf(jobs) {
1082
+ if (batchMissing) return uploadEach(jobs);
1083
+ const sized = [];
1084
+ for (const job of jobs) {
1085
+ const size = await measure(job);
1086
+ if (size === void 0) continue;
1087
+ try {
1088
+ sized.push({ job, size, sha256: job.body !== void 0 ? sha256Bytes(job.body) : await sha256File(job.path) });
1089
+ } catch {
1090
+ skip(job.kind, job.name, "missing");
1091
+ }
1092
+ }
1093
+ if (sized.length === 0) return;
1094
+ const announced = await gated(() => {
1095
+ requests += 1;
1096
+ return client.post(
1097
+ "/v1/files/uploads-batch",
1098
+ { items: sized.map((s) => ({ resultId: s.job.id, name: s.job.name, contentType: s.job.type, size: s.size, sha256: s.sha256 })) },
1099
+ uploadRetries
1100
+ );
1101
+ });
1102
+ if (routeMissing(announced)) {
1103
+ batchMissing = true;
1104
+ return uploadEach(sized.map((s) => s.job));
1105
+ }
1106
+ const verdicts = announced.ok ? announced.body?.items : void 0;
1107
+ if (!announced.ok || !Array.isArray(verdicts) || verdicts.length !== sized.length) {
1108
+ for (const s of sized) {
1109
+ if (announced.ok) failed(s.job, "bad_answer");
1110
+ else if (announced.code !== void 0) failedToBucket(s.job, announced.code);
1111
+ else failed(s.job, announced.detail || announced.kind);
1112
+ }
1113
+ return;
1114
+ }
1115
+ const puts = [];
1116
+ const directs = [];
1117
+ sized.forEach((s, i) => {
1118
+ const v = verdicts[i] ?? {};
1119
+ if (v.status === "exists") existed += 1;
1120
+ else if (v.status === "direct") {
1121
+ if (takeRoom(s.job)) directs.push(s);
1122
+ } else if (v.status === "upload") {
1123
+ const link = linkOf(v);
1124
+ if (link === void 0) return failed(s.job, "bad_answer");
1125
+ if (!bucketSeen) {
1126
+ bucketSeen = true;
1127
+ log(BUCKET_FOUND);
1128
+ }
1129
+ puts.push({ s, link });
1130
+ } else if (v.status === "refused") refusedItem(s.job, s.size, v);
1131
+ else failed(s.job, "bad_answer");
1132
+ });
1133
+ const toComplete = [];
1134
+ await Promise.all(
1135
+ puts.map(
1136
+ ({ s, link: first }) => gated(async () => {
1137
+ const source = s.job.body !== void 0 ? { body: s.job.body } : { path: s.job.path, size: s.size };
1138
+ let link = first;
1139
+ for (let renewed = false; ; ) {
1140
+ const put = await client.put(link.url, link.headers, source);
1141
+ if (put.ok) break;
1142
+ if (put.status !== 403 || renewed) return failedToBucket(s.job, put.code);
1143
+ renewed = true;
1144
+ requests += 1;
1145
+ const again = await client.post(
1146
+ "/v1/files/uploads-batch",
1147
+ { items: [{ resultId: s.job.id, name: s.job.name, contentType: s.job.type, size: s.size, sha256: s.sha256 }] },
1148
+ uploadRetries
1149
+ );
1150
+ const fresh = again.ok ? linkOf(again.body?.items?.[0]) : void 0;
1151
+ if (fresh === void 0) return failedToBucket(s.job, again.ok ? "bad_answer" : codeOf(again));
1152
+ link = fresh;
1153
+ }
1154
+ toComplete.push({ job: s.job, id: link.id });
1155
+ })
1156
+ )
1157
+ );
1158
+ await complete(toComplete);
1159
+ await sendDirect(directs);
1160
+ }
1161
+ async function complete(items) {
1162
+ for (let round = 0; items.length > 0; round += 1) {
1163
+ const out = await gated(() => {
1164
+ requests += 1;
1165
+ return client.post("/v1/files/complete-batch", { ids: items.map((i) => i.id) }, uploadRetries);
1166
+ });
1167
+ if (routeMissing(out)) {
1168
+ await Promise.all(
1169
+ items.map(
1170
+ (i) => gated(async () => {
1171
+ requests += 1;
1172
+ const one = await client.post(`/v1/files/${encodeURIComponent(i.id)}/complete`, {}, uploadRetries);
1173
+ if (one.ok) countsOf(i.job.kind).uploaded += 1;
1174
+ else failedToBucket(i.job, codeOf(one));
1175
+ })
1176
+ )
1177
+ );
1178
+ return;
1179
+ }
1180
+ const verdicts = out.ok ? out.body?.items : void 0;
1181
+ if (!out.ok || !Array.isArray(verdicts) || verdicts.length !== items.length) {
1182
+ for (const i of items) failedToBucket(i.job, out.ok ? "bad_answer" : codeOf(out));
1183
+ return;
1184
+ }
1185
+ const again = [];
1186
+ items.forEach((i, at) => {
1187
+ const v = verdicts[at] ?? {};
1188
+ if (v.status === "ready") countsOf(i.job.kind).uploaded += 1;
1189
+ else if (v.retryable === true && round < Math.min(uploadRetries, 3)) again.push(i);
1190
+ else refusedItem(i.job, 0, v.status === "refused" ? v : { code: "bad_answer" });
1191
+ });
1192
+ items = again;
1193
+ if (items.length > 0) {
1194
+ retried += items.length;
1195
+ await itemPause(round);
1196
+ }
1197
+ }
1198
+ }
1199
+ async function sendDirect(items) {
1200
+ const groups = [];
1201
+ let bytes = 0;
1202
+ for (const item of items) {
1203
+ const group = groups[groups.length - 1];
1204
+ if (group === void 0 || group.length >= FILES_BATCH_MAX || bytes + item.size > DIRECT_BATCH_BYTES) {
1205
+ groups.push([item]);
1206
+ bytes = item.size;
1207
+ } else {
1208
+ group.push(item);
1209
+ bytes += item.size;
1210
+ }
1211
+ }
1212
+ for (const group of groups) {
1213
+ const body = [];
1214
+ const sent = [];
1215
+ for (const { job, size } of group) {
1216
+ const { kind } = job;
1217
+ if (size > KINDS[kind].bytes) {
1218
+ skip(kind, job.name, KINDS[kind].size);
1219
+ continue;
1220
+ }
1221
+ let data;
1222
+ try {
1223
+ data = job.body ?? await import_node_fs5.promises.readFile(job.path);
1224
+ } catch {
1225
+ skip(kind, job.name, "missing");
1226
+ continue;
1227
+ }
1228
+ const refusal = kind === "text" ? textRefusal(data) : void 0;
1229
+ if (refusal !== void 0) {
1230
+ skip(kind, job.name, refusal);
1231
+ continue;
1232
+ }
1233
+ body.push({ resultId: job.id, name: job.name, contentType: job.type, data: data.toString("base64") });
1234
+ sent.push({ job, size });
1235
+ }
1236
+ if (body.length === 0) continue;
1237
+ const out = await gated(() => {
1238
+ requests += 1;
1239
+ return client.post("/v1/files/direct-batch", { items: body }, uploadRetries);
1240
+ });
1241
+ if (routeMissing(out)) {
1242
+ batchMissing = true;
1243
+ for (const { job, size } of sent) await gated(() => uploadDirect(job, size));
1244
+ continue;
1245
+ }
1246
+ const verdicts = out.ok ? out.body?.items : void 0;
1247
+ if (!out.ok || !Array.isArray(verdicts) || verdicts.length !== sent.length) {
1248
+ for (const { job } of sent) failed(job, out.ok ? "bad_answer" : out.detail || out.kind);
1249
+ continue;
1250
+ }
1251
+ sent.forEach(({ job }, i) => {
1252
+ const v = verdicts[i] ?? {};
1253
+ if (v.status === "stored") countsOf(job.kind).uploaded += 1;
1254
+ else if (v.status === "exists") existed += 1;
1255
+ else refusedItem(job, 0, v.status === "refused" ? v : { code: "bad_answer" });
1256
+ });
1257
+ }
1258
+ }
1259
+ async function uploadBatched(jobs) {
1260
+ const batches = chunk(jobs, FILES_BATCH_MAX);
1261
+ let next = 0;
1262
+ await Promise.all(
1263
+ Array.from({ length: Math.min(BATCH_PIPELINES, batches.length) }, async () => {
1264
+ for (let at = next++; at < batches.length; at = next++) await batchOf(batches[at]);
1265
+ })
1266
+ );
1267
+ }
1002
1268
  async function upload(items, filesByKey) {
1003
1269
  if (filesByKey.size === 0 || !Array.isArray(items)) return;
1004
1270
  const jobs = [];
@@ -1024,18 +1290,8 @@ async function startRun(passed, declared) {
1024
1290
  }
1025
1291
  }
1026
1292
  const started = Date.now();
1027
- const byResult = /* @__PURE__ */ new Map();
1028
- for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
1029
- await Promise.all(
1030
- [...byResult.values()].map(async (group) => {
1031
- const work = [];
1032
- for (const job of group) {
1033
- const act = await plan(job);
1034
- if (act !== void 0) work.push(gated(act));
1035
- }
1036
- await Promise.all(work);
1037
- })
1038
- );
1293
+ if (batchMissing) await uploadEach(jobs);
1294
+ else await uploadBatched(jobs);
1039
1295
  uploadMs += Date.now() - started;
1040
1296
  }
1041
1297
  async function flush() {
@@ -1131,9 +1387,9 @@ async function startRun(passed, declared) {
1131
1387
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
1132
1388
  const files = [stats.screenshots, stats.textFiles, stats.otherFiles];
1133
1389
  const total = (what) => files.reduce((n, counts) => n + counts[what], 0);
1134
- if (total("uploaded") + total("skipped") + total("failed") > 0) {
1390
+ if (total("uploaded") + total("skipped") + total("failed") + existed > 0) {
1135
1391
  log(
1136
- `plune: files \u2014 ${total("uploaded")} uploaded, ${total("skipped")} skipped, ${retried} retried, ${total("failed")} dropped, in ${(uploadMs / 1e3).toFixed(1)} s (${uploadsAtOnce} at once)`
1392
+ `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)`
1137
1393
  );
1138
1394
  }
1139
1395
  log(`plune: ${parts.join(" \xB7 ")}`);
@@ -1192,9 +1448,9 @@ async function startRun(passed, declared) {
1192
1448
  }
1193
1449
 
1194
1450
  // src/util/hash.ts
1195
- var import_node_crypto2 = require("crypto");
1451
+ var import_node_crypto3 = require("crypto");
1196
1452
  function sha256(input) {
1197
- return (0, import_node_crypto2.createHash)("sha256").update(input, "utf8").digest("hex");
1453
+ return (0, import_node_crypto3.createHash)("sha256").update(input, "utf8").digest("hex");
1198
1454
  }
1199
1455
 
1200
1456
  // src/reporter-core/result-key.ts