@plune-ai/cli 0.24.0 → 0.26.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 +78 -7
- package/dist/cli.cjs +501 -61
- package/dist/reporter-core.cjs +126 -12
- package/dist/reporter-core.d.cts +22 -3
- package/dist/reporter-core.d.ts +22 -3
- package/dist/reporter-core.js +128 -14
- package/package.json +1 -1
package/dist/reporter-core.js
CHANGED
|
@@ -33,8 +33,8 @@ function loadToken() {
|
|
|
33
33
|
// src/reporter-core/client.ts
|
|
34
34
|
import { createReadStream, openAsBlob } from "fs";
|
|
35
35
|
import { pipeline } from "stream";
|
|
36
|
-
import { request as httpRequest } from "http";
|
|
37
|
-
import { request as httpsRequest } from "https";
|
|
36
|
+
import { Agent as HttpAgent, request as httpRequest } from "http";
|
|
37
|
+
import { Agent as HttpsAgent, request as httpsRequest } from "https";
|
|
38
38
|
var BACKOFF_BASE_MS = 300;
|
|
39
39
|
var BACKOFF_CAP_MS = 1e4;
|
|
40
40
|
var DEFAULT_RETRIES = 2;
|
|
@@ -46,12 +46,14 @@ function parseRetryAfter(raw, now = Date.now()) {
|
|
|
46
46
|
if (!Number.isFinite(seconds)) return null;
|
|
47
47
|
return Math.min(Math.max(seconds, 0) * 1e3, RETRY_AFTER_CAP_MS);
|
|
48
48
|
}
|
|
49
|
+
var POOLS = { "http:": new HttpAgent({ keepAlive: true }), "https:": new HttpsAgent({ keepAlive: true }) };
|
|
49
50
|
var IDLE_MS = 3e5;
|
|
50
51
|
function nodeTransport(url, init, idleMs = IDLE_MS) {
|
|
51
52
|
return new Promise((resolve2, reject) => {
|
|
52
53
|
const target = new URL(url);
|
|
53
54
|
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
54
|
-
const
|
|
55
|
+
const agent = POOLS[target.protocol === "https:" ? "https:" : "http:"];
|
|
56
|
+
const req = send(target, { method: init.method, headers: init.headers, agent }, (res) => {
|
|
55
57
|
const chunks = [];
|
|
56
58
|
res.on("data", (chunk2) => chunks.push(chunk2));
|
|
57
59
|
res.on("error", reject);
|
|
@@ -112,14 +114,25 @@ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
112
114
|
async function detailOf(res) {
|
|
113
115
|
try {
|
|
114
116
|
const body = await res.json();
|
|
117
|
+
const q = body.quota;
|
|
115
118
|
return {
|
|
119
|
+
...typeof body.shardsExpected === "number" && Number.isInteger(body.shardsExpected) ? { shardsExpected: body.shardsExpected } : {},
|
|
116
120
|
detail: typeof body.error === "string" ? body.error : "",
|
|
121
|
+
...typeof q === "object" && q !== null && !Array.isArray(q) ? { quota: q } : {},
|
|
117
122
|
...typeof body.code === "string" && /^[a-z0-9_]{1,60}$/.test(body.code) ? { code: body.code } : {}
|
|
118
123
|
};
|
|
119
124
|
} catch {
|
|
120
125
|
return { detail: "" };
|
|
121
126
|
}
|
|
122
127
|
}
|
|
128
|
+
function describeQuota(q) {
|
|
129
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
130
|
+
const kind = typeof q["kind"] === "string" ? q["kind"].slice(0, 60) : "limit";
|
|
131
|
+
const [used, limit] = [num(q["used"]), num(q["limit"])];
|
|
132
|
+
const parts = [used !== void 0 && limit !== void 0 ? `${kind}: ${used} of ${limit} used` : kind];
|
|
133
|
+
if (typeof q["resetsAt"] === "string" && q["resetsAt"] !== "") parts.push(`resets ${q["resetsAt"].slice(0, 40)}`);
|
|
134
|
+
return parts.join(", ");
|
|
135
|
+
}
|
|
123
136
|
function classify(status) {
|
|
124
137
|
if (status === 401 || status === 403) return "auth";
|
|
125
138
|
if (status === 409) return "conflict";
|
|
@@ -184,12 +197,12 @@ function createClient(opts) {
|
|
|
184
197
|
}
|
|
185
198
|
const said = await detailOf(res);
|
|
186
199
|
const kind = classify(res.status);
|
|
187
|
-
last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
|
|
200
|
+
last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {}, ...said.shardsExpected !== void 0 ? { shardsExpected: said.shardsExpected } : {} };
|
|
188
201
|
if (kind !== "unavailable") break;
|
|
189
202
|
const said429 = parseRetryAfter(res.headers.get("retry-after"), now());
|
|
190
|
-
if (res.status === 429 && said429 === null) {
|
|
203
|
+
if (res.status === 429 && (said.quota !== void 0 || said429 === null)) {
|
|
191
204
|
last.kind = "invalid";
|
|
192
|
-
last.detail =
|
|
205
|
+
last.detail = `quota exceeded (${said.quota === void 0 ? "no Retry-After" : scrub(describeQuota(said.quota))})`;
|
|
193
206
|
if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
|
|
194
207
|
break;
|
|
195
208
|
}
|
|
@@ -202,6 +215,7 @@ function createClient(opts) {
|
|
|
202
215
|
post: (path3, body, retries) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }, retries),
|
|
203
216
|
upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
|
|
204
217
|
del: (path3) => send("DELETE", path3),
|
|
218
|
+
get: (path3) => send("GET", path3),
|
|
205
219
|
async put(url, headers, source) {
|
|
206
220
|
const putRetries = uploadRetries ?? 3;
|
|
207
221
|
let last = { ok: false, status: null, code: "network" };
|
|
@@ -240,7 +254,18 @@ function createClient(opts) {
|
|
|
240
254
|
// src/reporter-core/env.ts
|
|
241
255
|
import * as path from "path";
|
|
242
256
|
var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
|
|
243
|
-
var
|
|
257
|
+
var SHARDS_MAX = 1e3;
|
|
258
|
+
function shardOf(env) {
|
|
259
|
+
const raw = value(env, "PLUNE_SHARD");
|
|
260
|
+
if (raw === void 0) return {};
|
|
261
|
+
const m = /^(\d{1,4})\/(\d{1,4})$/.exec(raw);
|
|
262
|
+
const [k, n] = m ? [Number(m[1]), Number(m[2])] : [0, 0];
|
|
263
|
+
if (k >= 1 && k <= n && n <= SHARDS_MAX) return { shard: { index: k, total: n } };
|
|
264
|
+
return {
|
|
265
|
+
error: `PLUNE_SHARD=${JSON.stringify(raw.slice(0, 40))} is not "k/N" with 1 <= k <= N <= ${SHARDS_MAX} (for example 2/4) - nothing was sent.`
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 16 };
|
|
244
269
|
var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
|
|
245
270
|
function value(env, name) {
|
|
246
271
|
const raw = env[name]?.trim();
|
|
@@ -284,6 +309,7 @@ function readEnv(env = process.env) {
|
|
|
284
309
|
const labels = list(env, "PLUNE_LABELS");
|
|
285
310
|
const offerDiscovered = flag(env, "PLUNE_CREATE");
|
|
286
311
|
const full = flag(env, "PLUNE_FULL_RUN");
|
|
312
|
+
const { shard, error: shardError } = shardOf(env);
|
|
287
313
|
return {
|
|
288
314
|
config: {
|
|
289
315
|
...externalKey !== void 0 ? { externalKey } : {},
|
|
@@ -304,7 +330,11 @@ function readEnv(env = process.env) {
|
|
|
304
330
|
// Two names for two situations that end the same way. `SHARED_RUN` says other processes are
|
|
305
331
|
// reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
|
|
306
332
|
// way this process must not be the one to call it finished.
|
|
307
|
-
|
|
333
|
+
// `PLUNE_SHARD` implies it: the run is joined by the shared key and this process does not close it
|
|
334
|
+
// by itself; `leaveOpen` reports the shard instead.
|
|
335
|
+
keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
|
|
336
|
+
...shard !== void 0 ? { shard } : {},
|
|
337
|
+
...shardError !== void 0 ? { shardError } : {},
|
|
308
338
|
ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
|
|
309
339
|
warnings
|
|
310
340
|
};
|
|
@@ -514,6 +544,7 @@ var RESOLVE_CHUNK = 500;
|
|
|
514
544
|
var BATCH_MAX = 500;
|
|
515
545
|
var DISCOVER_MAX = 500;
|
|
516
546
|
var BATCH_DEFAULT = 500;
|
|
547
|
+
var SEND_CONCURRENCY = 4;
|
|
517
548
|
var EXPECTED_MAX = 1e4;
|
|
518
549
|
var KEY_MAX = 1024;
|
|
519
550
|
var TITLE_MAX = 300;
|
|
@@ -651,6 +682,7 @@ function warnIfDropped(sent, run, log) {
|
|
|
651
682
|
}
|
|
652
683
|
async function startRun(passed, declared) {
|
|
653
684
|
const env = readEnv();
|
|
685
|
+
if (env.shardError !== void 0) throw new Error(env.shardError);
|
|
654
686
|
const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
|
|
655
687
|
const cfg = { ...env.config, ...stated };
|
|
656
688
|
const log = cfg.log ?? ((line) => void process.stderr.write(`${line}
|
|
@@ -692,6 +724,9 @@ async function startRun(passed, declared) {
|
|
|
692
724
|
const resolved = /* @__PURE__ */ new Map();
|
|
693
725
|
let buffer = [];
|
|
694
726
|
const discoveries = /* @__PURE__ */ new Map();
|
|
727
|
+
const held = /* @__PURE__ */ new Map();
|
|
728
|
+
let createdWithResult = 0;
|
|
729
|
+
let createdUndelivered = 0;
|
|
695
730
|
let runId = null;
|
|
696
731
|
let joined = false;
|
|
697
732
|
let offline = token === "";
|
|
@@ -820,6 +855,7 @@ async function startRun(passed, declared) {
|
|
|
820
855
|
specRef: result.specRef,
|
|
821
856
|
rawStatus: result.rawStatus
|
|
822
857
|
});
|
|
858
|
+
held.set(result.keys[0].value, result);
|
|
823
859
|
}
|
|
824
860
|
async function offer() {
|
|
825
861
|
if (discoveries.size === 0 || offline || runId === null) return;
|
|
@@ -838,7 +874,24 @@ async function startRun(passed, declared) {
|
|
|
838
874
|
return;
|
|
839
875
|
}
|
|
840
876
|
stats.offered += out.body.results.filter((r) => r?.outcome === "queued").length;
|
|
841
|
-
|
|
877
|
+
const made = out.body.results.filter((r) => r?.outcome === "created");
|
|
878
|
+
stats.created += made.length;
|
|
879
|
+
const submissions = [];
|
|
880
|
+
const filesByKey = /* @__PURE__ */ new Map();
|
|
881
|
+
for (const r of made) {
|
|
882
|
+
const pending = typeof r.id === "string" && r.id !== "" ? held.get(r.key?.value) : void 0;
|
|
883
|
+
if (pending === void 0) continue;
|
|
884
|
+
held.delete(r.key.value);
|
|
885
|
+
const { keys: _keys, files, ...rest } = pending;
|
|
886
|
+
submissions.push({ ...rest, testCaseId: r.id });
|
|
887
|
+
if (files !== void 0) filesByKey.set(pending.resultKey, files);
|
|
888
|
+
}
|
|
889
|
+
if (submissions.length > 0) {
|
|
890
|
+
const before = stats.deferred;
|
|
891
|
+
await send(submissions, filesByKey);
|
|
892
|
+
createdWithResult += submissions.length;
|
|
893
|
+
createdUndelivered += stats.deferred - before;
|
|
894
|
+
}
|
|
842
895
|
}
|
|
843
896
|
}
|
|
844
897
|
function skip(kind, name, why) {
|
|
@@ -1245,7 +1298,29 @@ async function startRun(passed, declared) {
|
|
|
1245
1298
|
else await uploadBatched(jobs);
|
|
1246
1299
|
uploadMs += Date.now() - started;
|
|
1247
1300
|
}
|
|
1301
|
+
const sending = /* @__PURE__ */ new Set();
|
|
1302
|
+
let sendError;
|
|
1303
|
+
let answered = false;
|
|
1304
|
+
async function dispatch(work) {
|
|
1305
|
+
while (sending.size >= (answered ? SEND_CONCURRENCY : 1)) await Promise.race(sending);
|
|
1306
|
+
const task = work().catch((err) => {
|
|
1307
|
+
sendError ??= err;
|
|
1308
|
+
}).finally(() => sending.delete(task));
|
|
1309
|
+
sending.add(task);
|
|
1310
|
+
}
|
|
1311
|
+
async function settle() {
|
|
1312
|
+
while (sending.size > 0) await Promise.all([...sending]);
|
|
1313
|
+
if (sendError !== void 0) {
|
|
1314
|
+
const err = sendError;
|
|
1315
|
+
sendError = void 0;
|
|
1316
|
+
throw err;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1248
1319
|
async function flush() {
|
|
1320
|
+
await flushBuffer();
|
|
1321
|
+
await settle();
|
|
1322
|
+
}
|
|
1323
|
+
async function flushBuffer() {
|
|
1249
1324
|
if (buffer.length === 0) return;
|
|
1250
1325
|
const pending = buffer;
|
|
1251
1326
|
buffer = [];
|
|
@@ -1253,6 +1328,9 @@ async function startRun(passed, declared) {
|
|
|
1253
1328
|
defer(pending);
|
|
1254
1329
|
return;
|
|
1255
1330
|
}
|
|
1331
|
+
await dispatch(() => deliver(pending));
|
|
1332
|
+
}
|
|
1333
|
+
async function deliver(pending) {
|
|
1256
1334
|
await resolveKeys(pending.flatMap((p) => p.keys));
|
|
1257
1335
|
const submissions = [];
|
|
1258
1336
|
const unanswered = [];
|
|
@@ -1275,6 +1353,9 @@ async function startRun(passed, declared) {
|
|
|
1275
1353
|
progress();
|
|
1276
1354
|
return;
|
|
1277
1355
|
}
|
|
1356
|
+
await send(submissions, filesByKey);
|
|
1357
|
+
}
|
|
1358
|
+
async function send(submissions, filesByKey) {
|
|
1278
1359
|
for (const part of packed(submissions)) {
|
|
1279
1360
|
if (offline) {
|
|
1280
1361
|
defer(part);
|
|
@@ -1290,6 +1371,7 @@ async function startRun(passed, declared) {
|
|
|
1290
1371
|
for (const k of told) stats[k] += counts[k];
|
|
1291
1372
|
progress();
|
|
1292
1373
|
await upload(out.ok ? out.body?.items : void 0, filesByKey);
|
|
1374
|
+
answered = true;
|
|
1293
1375
|
continue;
|
|
1294
1376
|
}
|
|
1295
1377
|
if (out.ok) {
|
|
@@ -1318,7 +1400,10 @@ async function startRun(passed, declared) {
|
|
|
1318
1400
|
if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
|
|
1319
1401
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
1320
1402
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
1321
|
-
if (stats.created > 0)
|
|
1403
|
+
if (stats.created > 0) {
|
|
1404
|
+
const recorded = createdWithResult === stats.created && createdUndelivered === 0;
|
|
1405
|
+
parts.push(`${stats.created} added as cases (trusted source)${recorded ? " and their results recorded" : ""}`);
|
|
1406
|
+
}
|
|
1322
1407
|
const kinds = [stats.screenshots, stats.textFiles, stats.otherFiles];
|
|
1323
1408
|
const sum = (what) => kinds.reduce((n, counts) => n + counts[what], 0);
|
|
1324
1409
|
if (bucketSeen) {
|
|
@@ -1348,6 +1433,7 @@ async function startRun(passed, declared) {
|
|
|
1348
1433
|
log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
|
|
1349
1434
|
}
|
|
1350
1435
|
}
|
|
1436
|
+
const shardLines = [];
|
|
1351
1437
|
return {
|
|
1352
1438
|
get runId() {
|
|
1353
1439
|
return runId;
|
|
@@ -1360,7 +1446,7 @@ async function startRun(passed, declared) {
|
|
|
1360
1446
|
},
|
|
1361
1447
|
async add(result) {
|
|
1362
1448
|
buffer.push({ ...result, keys: bound(result.keys) });
|
|
1363
|
-
if (buffer.length >= batchSize) await
|
|
1449
|
+
if (buffer.length >= batchSize) await flushBuffer();
|
|
1364
1450
|
},
|
|
1365
1451
|
flush,
|
|
1366
1452
|
async finish(reason) {
|
|
@@ -1385,15 +1471,43 @@ async function startRun(passed, declared) {
|
|
|
1385
1471
|
summarise();
|
|
1386
1472
|
return frozen;
|
|
1387
1473
|
},
|
|
1388
|
-
|
|
1389
|
-
|
|
1474
|
+
shardLines,
|
|
1475
|
+
async leaveOpen(opts) {
|
|
1476
|
+
if (done) return void 0;
|
|
1390
1477
|
done = true;
|
|
1391
1478
|
await flush();
|
|
1392
1479
|
await offer();
|
|
1393
|
-
|
|
1480
|
+
let frozen;
|
|
1481
|
+
const after = [];
|
|
1482
|
+
const shard = env.shard;
|
|
1483
|
+
let counted = false;
|
|
1484
|
+
let mismatch;
|
|
1485
|
+
if (runId !== null && shard !== void 0 && !offline) {
|
|
1486
|
+
const out = await client.post(`/v1/runs/${runId}/events`, { event: "finish", shardIndex: shard.index, shardsExpected: shard.total });
|
|
1487
|
+
if (out.ok && typeof out.body?.shardsReported === "number") {
|
|
1488
|
+
counted = true;
|
|
1489
|
+
frozen = out.body.summary ?? void 0;
|
|
1490
|
+
const n = out.body.shardsExpected ?? shard.total;
|
|
1491
|
+
if (out.body.run?.statusEvent === "finished") after.push("plune: run closed");
|
|
1492
|
+
after.push(`plune: shard ${shard.index} of ${shard.total} done \u2014 ${out.body.shardsReported} of ${n} shards reported`);
|
|
1493
|
+
} else if (out.ok || out.kind === "invalid") {
|
|
1494
|
+
log('plune: the platform does not support shard auto-close yet \u2014 the run is left open for "plune run finish".');
|
|
1495
|
+
} else if (out.code === "shards_mismatch") {
|
|
1496
|
+
mismatch = new Error(
|
|
1497
|
+
`PLUNE_SHARD=${shard.index}/${shard.total} says ${shard.total} shards, but run ${runId} already expects ${out.shardsExpected ?? "a different number of"} \u2014 every shard of one run must use the same N. The results are uploaded; the run is left open.`
|
|
1498
|
+
);
|
|
1499
|
+
} else {
|
|
1500
|
+
log(`plune: could not report shard ${shard.index} of ${shard.total} to run ${runId} (${out.detail || out.kind}) \u2014 the results are uploaded; close the run with "plune run finish ${runId}".`);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
if (runId !== null && !counted) {
|
|
1394
1504
|
log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
|
|
1395
1505
|
}
|
|
1396
1506
|
summarise();
|
|
1507
|
+
shardLines.push(...after);
|
|
1508
|
+
if (opts?.holdShardLines !== true) for (const line of after) log(line);
|
|
1509
|
+
if (mismatch !== void 0) throw mismatch;
|
|
1510
|
+
return frozen;
|
|
1397
1511
|
}
|
|
1398
1512
|
};
|
|
1399
1513
|
}
|
package/package.json
CHANGED