@plune-ai/cli 0.23.0 → 0.25.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 +68 -8
- package/dist/cli.cjs +418 -61
- package/dist/index.cjs +9 -2
- package/dist/index.js +9 -2
- package/dist/reporter-core.cjs +95 -10
- package/dist/reporter-core.d.cts +22 -3
- package/dist/reporter-core.d.ts +22 -3
- package/dist/reporter-core.js +95 -10
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1610,6 +1610,7 @@ async function handleRun(options, depsFactory) {
|
|
|
1610
1610
|
}
|
|
1611
1611
|
|
|
1612
1612
|
// src/cli/commands/sync.ts
|
|
1613
|
+
var import_node_crypto2 = require("crypto");
|
|
1613
1614
|
var fs6 = __toESM(require("fs"), 1);
|
|
1614
1615
|
var path5 = __toESM(require("path"), 1);
|
|
1615
1616
|
|
|
@@ -1794,12 +1795,18 @@ async function handleSync(deps = {}) {
|
|
|
1794
1795
|
const run = readRun(cwd, deps.file);
|
|
1795
1796
|
const doFetch = deps.fetchImpl ?? fetch;
|
|
1796
1797
|
const endpoint = `${apiUrl}/v1/runs`;
|
|
1798
|
+
const payload = JSON.stringify(run);
|
|
1799
|
+
const idempotencyKey = (0, import_node_crypto2.createHash)("sha256").update(payload).digest("hex");
|
|
1797
1800
|
let res;
|
|
1798
1801
|
try {
|
|
1799
1802
|
res = await doFetch(endpoint, {
|
|
1800
1803
|
method: "POST",
|
|
1801
|
-
headers: {
|
|
1802
|
-
|
|
1804
|
+
headers: {
|
|
1805
|
+
"content-type": "application/json",
|
|
1806
|
+
authorization: `Bearer ${token}`,
|
|
1807
|
+
"idempotency-key": idempotencyKey
|
|
1808
|
+
},
|
|
1809
|
+
body: payload
|
|
1803
1810
|
});
|
|
1804
1811
|
} catch {
|
|
1805
1812
|
throw new SyncNetworkError(apiUrl);
|
package/dist/index.js
CHANGED
|
@@ -1554,6 +1554,7 @@ async function handleRun(options, depsFactory) {
|
|
|
1554
1554
|
}
|
|
1555
1555
|
|
|
1556
1556
|
// src/cli/commands/sync.ts
|
|
1557
|
+
import { createHash as createHash2 } from "crypto";
|
|
1557
1558
|
import * as fs6 from "fs";
|
|
1558
1559
|
import * as path5 from "path";
|
|
1559
1560
|
|
|
@@ -1738,12 +1739,18 @@ async function handleSync(deps = {}) {
|
|
|
1738
1739
|
const run = readRun(cwd, deps.file);
|
|
1739
1740
|
const doFetch = deps.fetchImpl ?? fetch;
|
|
1740
1741
|
const endpoint = `${apiUrl}/v1/runs`;
|
|
1742
|
+
const payload = JSON.stringify(run);
|
|
1743
|
+
const idempotencyKey = createHash2("sha256").update(payload).digest("hex");
|
|
1741
1744
|
let res;
|
|
1742
1745
|
try {
|
|
1743
1746
|
res = await doFetch(endpoint, {
|
|
1744
1747
|
method: "POST",
|
|
1745
|
-
headers: {
|
|
1746
|
-
|
|
1748
|
+
headers: {
|
|
1749
|
+
"content-type": "application/json",
|
|
1750
|
+
authorization: `Bearer ${token}`,
|
|
1751
|
+
"idempotency-key": idempotencyKey
|
|
1752
|
+
},
|
|
1753
|
+
body: payload
|
|
1747
1754
|
});
|
|
1748
1755
|
} catch {
|
|
1749
1756
|
throw new SyncNetworkError(apiUrl);
|
package/dist/reporter-core.cjs
CHANGED
|
@@ -161,14 +161,25 @@ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
161
161
|
async function detailOf(res) {
|
|
162
162
|
try {
|
|
163
163
|
const body = await res.json();
|
|
164
|
+
const q = body.quota;
|
|
164
165
|
return {
|
|
166
|
+
...typeof body.shardsExpected === "number" && Number.isInteger(body.shardsExpected) ? { shardsExpected: body.shardsExpected } : {},
|
|
165
167
|
detail: typeof body.error === "string" ? body.error : "",
|
|
168
|
+
...typeof q === "object" && q !== null && !Array.isArray(q) ? { quota: q } : {},
|
|
166
169
|
...typeof body.code === "string" && /^[a-z0-9_]{1,60}$/.test(body.code) ? { code: body.code } : {}
|
|
167
170
|
};
|
|
168
171
|
} catch {
|
|
169
172
|
return { detail: "" };
|
|
170
173
|
}
|
|
171
174
|
}
|
|
175
|
+
function describeQuota(q) {
|
|
176
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
177
|
+
const kind = typeof q["kind"] === "string" ? q["kind"].slice(0, 60) : "limit";
|
|
178
|
+
const [used, limit] = [num(q["used"]), num(q["limit"])];
|
|
179
|
+
const parts = [used !== void 0 && limit !== void 0 ? `${kind}: ${used} of ${limit} used` : kind];
|
|
180
|
+
if (typeof q["resetsAt"] === "string" && q["resetsAt"] !== "") parts.push(`resets ${q["resetsAt"].slice(0, 40)}`);
|
|
181
|
+
return parts.join(", ");
|
|
182
|
+
}
|
|
172
183
|
function classify(status) {
|
|
173
184
|
if (status === 401 || status === 403) return "auth";
|
|
174
185
|
if (status === 409) return "conflict";
|
|
@@ -233,12 +244,12 @@ function createClient(opts) {
|
|
|
233
244
|
}
|
|
234
245
|
const said = await detailOf(res);
|
|
235
246
|
const kind = classify(res.status);
|
|
236
|
-
last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
|
|
247
|
+
last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {}, ...said.shardsExpected !== void 0 ? { shardsExpected: said.shardsExpected } : {} };
|
|
237
248
|
if (kind !== "unavailable") break;
|
|
238
249
|
const said429 = parseRetryAfter(res.headers.get("retry-after"), now());
|
|
239
|
-
if (res.status === 429 && said429 === null) {
|
|
250
|
+
if (res.status === 429 && (said.quota !== void 0 || said429 === null)) {
|
|
240
251
|
last.kind = "invalid";
|
|
241
|
-
last.detail =
|
|
252
|
+
last.detail = `quota exceeded (${said.quota === void 0 ? "no Retry-After" : scrub(describeQuota(said.quota))})`;
|
|
242
253
|
if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
|
|
243
254
|
break;
|
|
244
255
|
}
|
|
@@ -251,6 +262,7 @@ function createClient(opts) {
|
|
|
251
262
|
post: (path3, body, retries) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }, retries),
|
|
252
263
|
upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
|
|
253
264
|
del: (path3) => send("DELETE", path3),
|
|
265
|
+
get: (path3) => send("GET", path3),
|
|
254
266
|
async put(url, headers, source) {
|
|
255
267
|
const putRetries = uploadRetries ?? 3;
|
|
256
268
|
let last = { ok: false, status: null, code: "network" };
|
|
@@ -289,7 +301,18 @@ function createClient(opts) {
|
|
|
289
301
|
// src/reporter-core/env.ts
|
|
290
302
|
var path = __toESM(require("path"), 1);
|
|
291
303
|
var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
|
|
292
|
-
var
|
|
304
|
+
var SHARDS_MAX = 1e3;
|
|
305
|
+
function shardOf(env) {
|
|
306
|
+
const raw = value(env, "PLUNE_SHARD");
|
|
307
|
+
if (raw === void 0) return {};
|
|
308
|
+
const m = /^(\d{1,4})\/(\d{1,4})$/.exec(raw);
|
|
309
|
+
const [k, n] = m ? [Number(m[1]), Number(m[2])] : [0, 0];
|
|
310
|
+
if (k >= 1 && k <= n && n <= SHARDS_MAX) return { shard: { index: k, total: n } };
|
|
311
|
+
return {
|
|
312
|
+
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.`
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 16 };
|
|
293
316
|
var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
|
|
294
317
|
function value(env, name) {
|
|
295
318
|
const raw = env[name]?.trim();
|
|
@@ -333,6 +356,7 @@ function readEnv(env = process.env) {
|
|
|
333
356
|
const labels = list(env, "PLUNE_LABELS");
|
|
334
357
|
const offerDiscovered = flag(env, "PLUNE_CREATE");
|
|
335
358
|
const full = flag(env, "PLUNE_FULL_RUN");
|
|
359
|
+
const { shard, error: shardError } = shardOf(env);
|
|
336
360
|
return {
|
|
337
361
|
config: {
|
|
338
362
|
...externalKey !== void 0 ? { externalKey } : {},
|
|
@@ -353,7 +377,11 @@ function readEnv(env = process.env) {
|
|
|
353
377
|
// Two names for two situations that end the same way. `SHARED_RUN` says other processes are
|
|
354
378
|
// reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
|
|
355
379
|
// way this process must not be the one to call it finished.
|
|
356
|
-
|
|
380
|
+
// `PLUNE_SHARD` implies it: the run is joined by the shared key and this process does not close it
|
|
381
|
+
// by itself; `leaveOpen` reports the shard instead.
|
|
382
|
+
keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
|
|
383
|
+
...shard !== void 0 ? { shard } : {},
|
|
384
|
+
...shardError !== void 0 ? { shardError } : {},
|
|
357
385
|
ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
|
|
358
386
|
warnings
|
|
359
387
|
};
|
|
@@ -700,6 +728,7 @@ function warnIfDropped(sent, run, log) {
|
|
|
700
728
|
}
|
|
701
729
|
async function startRun(passed, declared) {
|
|
702
730
|
const env = readEnv();
|
|
731
|
+
if (env.shardError !== void 0) throw new Error(env.shardError);
|
|
703
732
|
const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
|
|
704
733
|
const cfg = { ...env.config, ...stated };
|
|
705
734
|
const log = cfg.log ?? ((line) => void process.stderr.write(`${line}
|
|
@@ -741,6 +770,9 @@ async function startRun(passed, declared) {
|
|
|
741
770
|
const resolved = /* @__PURE__ */ new Map();
|
|
742
771
|
let buffer = [];
|
|
743
772
|
const discoveries = /* @__PURE__ */ new Map();
|
|
773
|
+
const held = /* @__PURE__ */ new Map();
|
|
774
|
+
let createdWithResult = 0;
|
|
775
|
+
let createdUndelivered = 0;
|
|
744
776
|
let runId = null;
|
|
745
777
|
let joined = false;
|
|
746
778
|
let offline = token === "";
|
|
@@ -869,6 +901,7 @@ async function startRun(passed, declared) {
|
|
|
869
901
|
specRef: result.specRef,
|
|
870
902
|
rawStatus: result.rawStatus
|
|
871
903
|
});
|
|
904
|
+
held.set(result.keys[0].value, result);
|
|
872
905
|
}
|
|
873
906
|
async function offer() {
|
|
874
907
|
if (discoveries.size === 0 || offline || runId === null) return;
|
|
@@ -887,7 +920,24 @@ async function startRun(passed, declared) {
|
|
|
887
920
|
return;
|
|
888
921
|
}
|
|
889
922
|
stats.offered += out.body.results.filter((r) => r?.outcome === "queued").length;
|
|
890
|
-
|
|
923
|
+
const made = out.body.results.filter((r) => r?.outcome === "created");
|
|
924
|
+
stats.created += made.length;
|
|
925
|
+
const submissions = [];
|
|
926
|
+
const filesByKey = /* @__PURE__ */ new Map();
|
|
927
|
+
for (const r of made) {
|
|
928
|
+
const pending = typeof r.id === "string" && r.id !== "" ? held.get(r.key?.value) : void 0;
|
|
929
|
+
if (pending === void 0) continue;
|
|
930
|
+
held.delete(r.key.value);
|
|
931
|
+
const { keys: _keys, files, ...rest } = pending;
|
|
932
|
+
submissions.push({ ...rest, testCaseId: r.id });
|
|
933
|
+
if (files !== void 0) filesByKey.set(pending.resultKey, files);
|
|
934
|
+
}
|
|
935
|
+
if (submissions.length > 0) {
|
|
936
|
+
const before = stats.deferred;
|
|
937
|
+
await send(submissions, filesByKey);
|
|
938
|
+
createdWithResult += submissions.length;
|
|
939
|
+
createdUndelivered += stats.deferred - before;
|
|
940
|
+
}
|
|
891
941
|
}
|
|
892
942
|
}
|
|
893
943
|
function skip(kind, name, why) {
|
|
@@ -1324,6 +1374,9 @@ async function startRun(passed, declared) {
|
|
|
1324
1374
|
progress();
|
|
1325
1375
|
return;
|
|
1326
1376
|
}
|
|
1377
|
+
await send(submissions, filesByKey);
|
|
1378
|
+
}
|
|
1379
|
+
async function send(submissions, filesByKey) {
|
|
1327
1380
|
for (const part of packed(submissions)) {
|
|
1328
1381
|
if (offline) {
|
|
1329
1382
|
defer(part);
|
|
@@ -1367,7 +1420,10 @@ async function startRun(passed, declared) {
|
|
|
1367
1420
|
if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
|
|
1368
1421
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
1369
1422
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
1370
|
-
if (stats.created > 0)
|
|
1423
|
+
if (stats.created > 0) {
|
|
1424
|
+
const recorded = createdWithResult === stats.created && createdUndelivered === 0;
|
|
1425
|
+
parts.push(`${stats.created} added as cases (trusted source)${recorded ? " and their results recorded" : ""}`);
|
|
1426
|
+
}
|
|
1371
1427
|
const kinds = [stats.screenshots, stats.textFiles, stats.otherFiles];
|
|
1372
1428
|
const sum = (what) => kinds.reduce((n, counts) => n + counts[what], 0);
|
|
1373
1429
|
if (bucketSeen) {
|
|
@@ -1397,6 +1453,7 @@ async function startRun(passed, declared) {
|
|
|
1397
1453
|
log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
|
|
1398
1454
|
}
|
|
1399
1455
|
}
|
|
1456
|
+
const shardLines = [];
|
|
1400
1457
|
return {
|
|
1401
1458
|
get runId() {
|
|
1402
1459
|
return runId;
|
|
@@ -1434,15 +1491,43 @@ async function startRun(passed, declared) {
|
|
|
1434
1491
|
summarise();
|
|
1435
1492
|
return frozen;
|
|
1436
1493
|
},
|
|
1437
|
-
|
|
1438
|
-
|
|
1494
|
+
shardLines,
|
|
1495
|
+
async leaveOpen(opts) {
|
|
1496
|
+
if (done) return void 0;
|
|
1439
1497
|
done = true;
|
|
1440
1498
|
await flush();
|
|
1441
1499
|
await offer();
|
|
1442
|
-
|
|
1500
|
+
let frozen;
|
|
1501
|
+
const after = [];
|
|
1502
|
+
const shard = env.shard;
|
|
1503
|
+
let counted = false;
|
|
1504
|
+
let mismatch;
|
|
1505
|
+
if (runId !== null && shard !== void 0 && !offline) {
|
|
1506
|
+
const out = await client.post(`/v1/runs/${runId}/events`, { event: "finish", shardIndex: shard.index, shardsExpected: shard.total });
|
|
1507
|
+
if (out.ok && typeof out.body?.shardsReported === "number") {
|
|
1508
|
+
counted = true;
|
|
1509
|
+
frozen = out.body.summary ?? void 0;
|
|
1510
|
+
const n = out.body.shardsExpected ?? shard.total;
|
|
1511
|
+
if (out.body.run?.statusEvent === "finished") after.push("plune: run closed");
|
|
1512
|
+
after.push(`plune: shard ${shard.index} of ${shard.total} done \u2014 ${out.body.shardsReported} of ${n} shards reported`);
|
|
1513
|
+
} else if (out.ok || out.kind === "invalid") {
|
|
1514
|
+
log('plune: the platform does not support shard auto-close yet \u2014 the run is left open for "plune run finish".');
|
|
1515
|
+
} else if (out.code === "shards_mismatch") {
|
|
1516
|
+
mismatch = new Error(
|
|
1517
|
+
`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.`
|
|
1518
|
+
);
|
|
1519
|
+
} else {
|
|
1520
|
+
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}".`);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
if (runId !== null && !counted) {
|
|
1443
1524
|
log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
|
|
1444
1525
|
}
|
|
1445
1526
|
summarise();
|
|
1527
|
+
shardLines.push(...after);
|
|
1528
|
+
if (opts?.holdShardLines !== true) for (const line of after) log(line);
|
|
1529
|
+
if (mismatch !== void 0) throw mismatch;
|
|
1530
|
+
return frozen;
|
|
1446
1531
|
}
|
|
1447
1532
|
};
|
|
1448
1533
|
}
|
package/dist/reporter-core.d.cts
CHANGED
|
@@ -326,7 +326,7 @@ interface ReporterConfig {
|
|
|
326
326
|
labels?: string[];
|
|
327
327
|
/** Results per request. The platform's ceiling is 500. */
|
|
328
328
|
batchSize?: number;
|
|
329
|
-
/** Files in flight at once (`PLUNE_UPLOAD_CONCURRENCY`, 1 to 32, default
|
|
329
|
+
/** Files in flight at once (`PLUNE_UPLOAD_CONCURRENCY`, 1 to 32, default 16). */
|
|
330
330
|
uploadConcurrency?: number;
|
|
331
331
|
/** Retries of a request the platform or a bucket asked to wait for (`PLUNE_UPLOAD_RETRIES`, 0 to 20, default 6). */
|
|
332
332
|
uploadRetries?: number;
|
|
@@ -377,8 +377,15 @@ interface RunSession {
|
|
|
377
377
|
* `undefined` when it answered with none: no run, a refused finish, a deployment older than the summary.
|
|
378
378
|
*/
|
|
379
379
|
finish(reason?: string): Promise<FrozenSummary | undefined>;
|
|
380
|
-
/**
|
|
381
|
-
|
|
380
|
+
/**
|
|
381
|
+
* Send what is left and deliberately leave the run open - the shard case (AC-03). With `PLUNE_SHARD=k/N`
|
|
382
|
+
* it reports "shard k of N is done" instead (cli#110) and resolves to the summary when that was the Nth.
|
|
383
|
+
*/
|
|
384
|
+
leaveOpen(opts?: {
|
|
385
|
+
holdShardLines?: boolean;
|
|
386
|
+
}): Promise<FrozenSummary | undefined>;
|
|
387
|
+
/** The closing lines of a `PLUNE_SHARD` report (`run closed`, `shard k of N done`). Printed by `leaveOpen` unless it was told to hold them for a caller that has more to say first. */
|
|
388
|
+
readonly shardLines: readonly string[];
|
|
382
389
|
}
|
|
383
390
|
declare function startRun(passed: ReporterConfig, declared?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
|
|
384
391
|
|
|
@@ -424,11 +431,20 @@ declare function resultKey(testId: string, retry: number): string;
|
|
|
424
431
|
* put them. `PLUNE_GROUP` stays: a run group is D6's, and a group of one run means nothing.
|
|
425
432
|
*/
|
|
426
433
|
declare const UNSUPPORTED_VARS: readonly ["PLUNE_GROUP"];
|
|
434
|
+
interface ShardOf {
|
|
435
|
+
/** 1-based. */
|
|
436
|
+
index: number;
|
|
437
|
+
total: number;
|
|
438
|
+
}
|
|
427
439
|
interface EnvSettings {
|
|
428
440
|
/** Config fields the environment supplied. Anything passed explicitly outranks these. */
|
|
429
441
|
config: Partial<ReporterConfig>;
|
|
430
442
|
/** This process must not close the run — several share it, or the job closes it itself. */
|
|
431
443
|
keepOpen: boolean;
|
|
444
|
+
/** `PLUNE_SHARD=k/N`: this process is shard k of N, and tells the platform when it is done (cli#110). */
|
|
445
|
+
shard?: ShardOf;
|
|
446
|
+
/** `PLUNE_SHARD` was set to something that is not `k/N`. A configuration error: the caller refuses to start. */
|
|
447
|
+
shardError?: string;
|
|
432
448
|
/** Variables that were set and have nowhere to land. The caller says so once. */
|
|
433
449
|
ignored: string[];
|
|
434
450
|
/** Variables that were set to something unusable and fell back to their default. The caller says so once. */
|
|
@@ -562,6 +578,7 @@ type ClientOutcome<T> = {
|
|
|
562
578
|
status: number | null;
|
|
563
579
|
detail: string;
|
|
564
580
|
code?: string;
|
|
581
|
+
shardsExpected?: number;
|
|
565
582
|
};
|
|
566
583
|
/** What a PUT to a bucket carries: a file on disk, streamed at a length known beforehand, or bytes in memory. */
|
|
567
584
|
type PutSource = {
|
|
@@ -612,6 +629,8 @@ interface PlatformClient {
|
|
|
612
629
|
* a proxy error page that echoes the request would put a token in a CI log.
|
|
613
630
|
*/
|
|
614
631
|
del<T>(path: string): Promise<ClientOutcome<T>>;
|
|
632
|
+
/** A GET, through the same retries, classification and token scrubbing. */
|
|
633
|
+
get<T>(path: string): Promise<ClientOutcome<T>>;
|
|
615
634
|
/**
|
|
616
635
|
* The bytes to a short-lived link of the customer's bucket (plune-ai/plune#946): exactly the headers the
|
|
617
636
|
* link came with, no token of ours. Network failures and 5xx wait and try again, up to three times; a 403
|
package/dist/reporter-core.d.ts
CHANGED
|
@@ -326,7 +326,7 @@ interface ReporterConfig {
|
|
|
326
326
|
labels?: string[];
|
|
327
327
|
/** Results per request. The platform's ceiling is 500. */
|
|
328
328
|
batchSize?: number;
|
|
329
|
-
/** Files in flight at once (`PLUNE_UPLOAD_CONCURRENCY`, 1 to 32, default
|
|
329
|
+
/** Files in flight at once (`PLUNE_UPLOAD_CONCURRENCY`, 1 to 32, default 16). */
|
|
330
330
|
uploadConcurrency?: number;
|
|
331
331
|
/** Retries of a request the platform or a bucket asked to wait for (`PLUNE_UPLOAD_RETRIES`, 0 to 20, default 6). */
|
|
332
332
|
uploadRetries?: number;
|
|
@@ -377,8 +377,15 @@ interface RunSession {
|
|
|
377
377
|
* `undefined` when it answered with none: no run, a refused finish, a deployment older than the summary.
|
|
378
378
|
*/
|
|
379
379
|
finish(reason?: string): Promise<FrozenSummary | undefined>;
|
|
380
|
-
/**
|
|
381
|
-
|
|
380
|
+
/**
|
|
381
|
+
* Send what is left and deliberately leave the run open - the shard case (AC-03). With `PLUNE_SHARD=k/N`
|
|
382
|
+
* it reports "shard k of N is done" instead (cli#110) and resolves to the summary when that was the Nth.
|
|
383
|
+
*/
|
|
384
|
+
leaveOpen(opts?: {
|
|
385
|
+
holdShardLines?: boolean;
|
|
386
|
+
}): Promise<FrozenSummary | undefined>;
|
|
387
|
+
/** The closing lines of a `PLUNE_SHARD` report (`run closed`, `shard k of N done`). Printed by `leaveOpen` unless it was told to hold them for a caller that has more to say first. */
|
|
388
|
+
readonly shardLines: readonly string[];
|
|
382
389
|
}
|
|
383
390
|
declare function startRun(passed: ReporterConfig, declared?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
|
|
384
391
|
|
|
@@ -424,11 +431,20 @@ declare function resultKey(testId: string, retry: number): string;
|
|
|
424
431
|
* put them. `PLUNE_GROUP` stays: a run group is D6's, and a group of one run means nothing.
|
|
425
432
|
*/
|
|
426
433
|
declare const UNSUPPORTED_VARS: readonly ["PLUNE_GROUP"];
|
|
434
|
+
interface ShardOf {
|
|
435
|
+
/** 1-based. */
|
|
436
|
+
index: number;
|
|
437
|
+
total: number;
|
|
438
|
+
}
|
|
427
439
|
interface EnvSettings {
|
|
428
440
|
/** Config fields the environment supplied. Anything passed explicitly outranks these. */
|
|
429
441
|
config: Partial<ReporterConfig>;
|
|
430
442
|
/** This process must not close the run — several share it, or the job closes it itself. */
|
|
431
443
|
keepOpen: boolean;
|
|
444
|
+
/** `PLUNE_SHARD=k/N`: this process is shard k of N, and tells the platform when it is done (cli#110). */
|
|
445
|
+
shard?: ShardOf;
|
|
446
|
+
/** `PLUNE_SHARD` was set to something that is not `k/N`. A configuration error: the caller refuses to start. */
|
|
447
|
+
shardError?: string;
|
|
432
448
|
/** Variables that were set and have nowhere to land. The caller says so once. */
|
|
433
449
|
ignored: string[];
|
|
434
450
|
/** Variables that were set to something unusable and fell back to their default. The caller says so once. */
|
|
@@ -562,6 +578,7 @@ type ClientOutcome<T> = {
|
|
|
562
578
|
status: number | null;
|
|
563
579
|
detail: string;
|
|
564
580
|
code?: string;
|
|
581
|
+
shardsExpected?: number;
|
|
565
582
|
};
|
|
566
583
|
/** What a PUT to a bucket carries: a file on disk, streamed at a length known beforehand, or bytes in memory. */
|
|
567
584
|
type PutSource = {
|
|
@@ -612,6 +629,8 @@ interface PlatformClient {
|
|
|
612
629
|
* a proxy error page that echoes the request would put a token in a CI log.
|
|
613
630
|
*/
|
|
614
631
|
del<T>(path: string): Promise<ClientOutcome<T>>;
|
|
632
|
+
/** A GET, through the same retries, classification and token scrubbing. */
|
|
633
|
+
get<T>(path: string): Promise<ClientOutcome<T>>;
|
|
615
634
|
/**
|
|
616
635
|
* The bytes to a short-lived link of the customer's bucket (plune-ai/plune#946): exactly the headers the
|
|
617
636
|
* link came with, no token of ours. Network failures and 5xx wait and try again, up to three times; a 403
|
package/dist/reporter-core.js
CHANGED
|
@@ -112,14 +112,25 @@ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
112
112
|
async function detailOf(res) {
|
|
113
113
|
try {
|
|
114
114
|
const body = await res.json();
|
|
115
|
+
const q = body.quota;
|
|
115
116
|
return {
|
|
117
|
+
...typeof body.shardsExpected === "number" && Number.isInteger(body.shardsExpected) ? { shardsExpected: body.shardsExpected } : {},
|
|
116
118
|
detail: typeof body.error === "string" ? body.error : "",
|
|
119
|
+
...typeof q === "object" && q !== null && !Array.isArray(q) ? { quota: q } : {},
|
|
117
120
|
...typeof body.code === "string" && /^[a-z0-9_]{1,60}$/.test(body.code) ? { code: body.code } : {}
|
|
118
121
|
};
|
|
119
122
|
} catch {
|
|
120
123
|
return { detail: "" };
|
|
121
124
|
}
|
|
122
125
|
}
|
|
126
|
+
function describeQuota(q) {
|
|
127
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
128
|
+
const kind = typeof q["kind"] === "string" ? q["kind"].slice(0, 60) : "limit";
|
|
129
|
+
const [used, limit] = [num(q["used"]), num(q["limit"])];
|
|
130
|
+
const parts = [used !== void 0 && limit !== void 0 ? `${kind}: ${used} of ${limit} used` : kind];
|
|
131
|
+
if (typeof q["resetsAt"] === "string" && q["resetsAt"] !== "") parts.push(`resets ${q["resetsAt"].slice(0, 40)}`);
|
|
132
|
+
return parts.join(", ");
|
|
133
|
+
}
|
|
123
134
|
function classify(status) {
|
|
124
135
|
if (status === 401 || status === 403) return "auth";
|
|
125
136
|
if (status === 409) return "conflict";
|
|
@@ -184,12 +195,12 @@ function createClient(opts) {
|
|
|
184
195
|
}
|
|
185
196
|
const said = await detailOf(res);
|
|
186
197
|
const kind = classify(res.status);
|
|
187
|
-
last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
|
|
198
|
+
last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {}, ...said.shardsExpected !== void 0 ? { shardsExpected: said.shardsExpected } : {} };
|
|
188
199
|
if (kind !== "unavailable") break;
|
|
189
200
|
const said429 = parseRetryAfter(res.headers.get("retry-after"), now());
|
|
190
|
-
if (res.status === 429 && said429 === null) {
|
|
201
|
+
if (res.status === 429 && (said.quota !== void 0 || said429 === null)) {
|
|
191
202
|
last.kind = "invalid";
|
|
192
|
-
last.detail =
|
|
203
|
+
last.detail = `quota exceeded (${said.quota === void 0 ? "no Retry-After" : scrub(describeQuota(said.quota))})`;
|
|
193
204
|
if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
|
|
194
205
|
break;
|
|
195
206
|
}
|
|
@@ -202,6 +213,7 @@ function createClient(opts) {
|
|
|
202
213
|
post: (path3, body, retries) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }, retries),
|
|
203
214
|
upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
|
|
204
215
|
del: (path3) => send("DELETE", path3),
|
|
216
|
+
get: (path3) => send("GET", path3),
|
|
205
217
|
async put(url, headers, source) {
|
|
206
218
|
const putRetries = uploadRetries ?? 3;
|
|
207
219
|
let last = { ok: false, status: null, code: "network" };
|
|
@@ -240,7 +252,18 @@ function createClient(opts) {
|
|
|
240
252
|
// src/reporter-core/env.ts
|
|
241
253
|
import * as path from "path";
|
|
242
254
|
var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
|
|
243
|
-
var
|
|
255
|
+
var SHARDS_MAX = 1e3;
|
|
256
|
+
function shardOf(env) {
|
|
257
|
+
const raw = value(env, "PLUNE_SHARD");
|
|
258
|
+
if (raw === void 0) return {};
|
|
259
|
+
const m = /^(\d{1,4})\/(\d{1,4})$/.exec(raw);
|
|
260
|
+
const [k, n] = m ? [Number(m[1]), Number(m[2])] : [0, 0];
|
|
261
|
+
if (k >= 1 && k <= n && n <= SHARDS_MAX) return { shard: { index: k, total: n } };
|
|
262
|
+
return {
|
|
263
|
+
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.`
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 16 };
|
|
244
267
|
var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
|
|
245
268
|
function value(env, name) {
|
|
246
269
|
const raw = env[name]?.trim();
|
|
@@ -284,6 +307,7 @@ function readEnv(env = process.env) {
|
|
|
284
307
|
const labels = list(env, "PLUNE_LABELS");
|
|
285
308
|
const offerDiscovered = flag(env, "PLUNE_CREATE");
|
|
286
309
|
const full = flag(env, "PLUNE_FULL_RUN");
|
|
310
|
+
const { shard, error: shardError } = shardOf(env);
|
|
287
311
|
return {
|
|
288
312
|
config: {
|
|
289
313
|
...externalKey !== void 0 ? { externalKey } : {},
|
|
@@ -304,7 +328,11 @@ function readEnv(env = process.env) {
|
|
|
304
328
|
// Two names for two situations that end the same way. `SHARED_RUN` says other processes are
|
|
305
329
|
// reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
|
|
306
330
|
// way this process must not be the one to call it finished.
|
|
307
|
-
|
|
331
|
+
// `PLUNE_SHARD` implies it: the run is joined by the shared key and this process does not close it
|
|
332
|
+
// by itself; `leaveOpen` reports the shard instead.
|
|
333
|
+
keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
|
|
334
|
+
...shard !== void 0 ? { shard } : {},
|
|
335
|
+
...shardError !== void 0 ? { shardError } : {},
|
|
308
336
|
ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
|
|
309
337
|
warnings
|
|
310
338
|
};
|
|
@@ -651,6 +679,7 @@ function warnIfDropped(sent, run, log) {
|
|
|
651
679
|
}
|
|
652
680
|
async function startRun(passed, declared) {
|
|
653
681
|
const env = readEnv();
|
|
682
|
+
if (env.shardError !== void 0) throw new Error(env.shardError);
|
|
654
683
|
const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
|
|
655
684
|
const cfg = { ...env.config, ...stated };
|
|
656
685
|
const log = cfg.log ?? ((line) => void process.stderr.write(`${line}
|
|
@@ -692,6 +721,9 @@ async function startRun(passed, declared) {
|
|
|
692
721
|
const resolved = /* @__PURE__ */ new Map();
|
|
693
722
|
let buffer = [];
|
|
694
723
|
const discoveries = /* @__PURE__ */ new Map();
|
|
724
|
+
const held = /* @__PURE__ */ new Map();
|
|
725
|
+
let createdWithResult = 0;
|
|
726
|
+
let createdUndelivered = 0;
|
|
695
727
|
let runId = null;
|
|
696
728
|
let joined = false;
|
|
697
729
|
let offline = token === "";
|
|
@@ -820,6 +852,7 @@ async function startRun(passed, declared) {
|
|
|
820
852
|
specRef: result.specRef,
|
|
821
853
|
rawStatus: result.rawStatus
|
|
822
854
|
});
|
|
855
|
+
held.set(result.keys[0].value, result);
|
|
823
856
|
}
|
|
824
857
|
async function offer() {
|
|
825
858
|
if (discoveries.size === 0 || offline || runId === null) return;
|
|
@@ -838,7 +871,24 @@ async function startRun(passed, declared) {
|
|
|
838
871
|
return;
|
|
839
872
|
}
|
|
840
873
|
stats.offered += out.body.results.filter((r) => r?.outcome === "queued").length;
|
|
841
|
-
|
|
874
|
+
const made = out.body.results.filter((r) => r?.outcome === "created");
|
|
875
|
+
stats.created += made.length;
|
|
876
|
+
const submissions = [];
|
|
877
|
+
const filesByKey = /* @__PURE__ */ new Map();
|
|
878
|
+
for (const r of made) {
|
|
879
|
+
const pending = typeof r.id === "string" && r.id !== "" ? held.get(r.key?.value) : void 0;
|
|
880
|
+
if (pending === void 0) continue;
|
|
881
|
+
held.delete(r.key.value);
|
|
882
|
+
const { keys: _keys, files, ...rest } = pending;
|
|
883
|
+
submissions.push({ ...rest, testCaseId: r.id });
|
|
884
|
+
if (files !== void 0) filesByKey.set(pending.resultKey, files);
|
|
885
|
+
}
|
|
886
|
+
if (submissions.length > 0) {
|
|
887
|
+
const before = stats.deferred;
|
|
888
|
+
await send(submissions, filesByKey);
|
|
889
|
+
createdWithResult += submissions.length;
|
|
890
|
+
createdUndelivered += stats.deferred - before;
|
|
891
|
+
}
|
|
842
892
|
}
|
|
843
893
|
}
|
|
844
894
|
function skip(kind, name, why) {
|
|
@@ -1275,6 +1325,9 @@ async function startRun(passed, declared) {
|
|
|
1275
1325
|
progress();
|
|
1276
1326
|
return;
|
|
1277
1327
|
}
|
|
1328
|
+
await send(submissions, filesByKey);
|
|
1329
|
+
}
|
|
1330
|
+
async function send(submissions, filesByKey) {
|
|
1278
1331
|
for (const part of packed(submissions)) {
|
|
1279
1332
|
if (offline) {
|
|
1280
1333
|
defer(part);
|
|
@@ -1318,7 +1371,10 @@ async function startRun(passed, declared) {
|
|
|
1318
1371
|
if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
|
|
1319
1372
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
1320
1373
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
1321
|
-
if (stats.created > 0)
|
|
1374
|
+
if (stats.created > 0) {
|
|
1375
|
+
const recorded = createdWithResult === stats.created && createdUndelivered === 0;
|
|
1376
|
+
parts.push(`${stats.created} added as cases (trusted source)${recorded ? " and their results recorded" : ""}`);
|
|
1377
|
+
}
|
|
1322
1378
|
const kinds = [stats.screenshots, stats.textFiles, stats.otherFiles];
|
|
1323
1379
|
const sum = (what) => kinds.reduce((n, counts) => n + counts[what], 0);
|
|
1324
1380
|
if (bucketSeen) {
|
|
@@ -1348,6 +1404,7 @@ async function startRun(passed, declared) {
|
|
|
1348
1404
|
log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
|
|
1349
1405
|
}
|
|
1350
1406
|
}
|
|
1407
|
+
const shardLines = [];
|
|
1351
1408
|
return {
|
|
1352
1409
|
get runId() {
|
|
1353
1410
|
return runId;
|
|
@@ -1385,15 +1442,43 @@ async function startRun(passed, declared) {
|
|
|
1385
1442
|
summarise();
|
|
1386
1443
|
return frozen;
|
|
1387
1444
|
},
|
|
1388
|
-
|
|
1389
|
-
|
|
1445
|
+
shardLines,
|
|
1446
|
+
async leaveOpen(opts) {
|
|
1447
|
+
if (done) return void 0;
|
|
1390
1448
|
done = true;
|
|
1391
1449
|
await flush();
|
|
1392
1450
|
await offer();
|
|
1393
|
-
|
|
1451
|
+
let frozen;
|
|
1452
|
+
const after = [];
|
|
1453
|
+
const shard = env.shard;
|
|
1454
|
+
let counted = false;
|
|
1455
|
+
let mismatch;
|
|
1456
|
+
if (runId !== null && shard !== void 0 && !offline) {
|
|
1457
|
+
const out = await client.post(`/v1/runs/${runId}/events`, { event: "finish", shardIndex: shard.index, shardsExpected: shard.total });
|
|
1458
|
+
if (out.ok && typeof out.body?.shardsReported === "number") {
|
|
1459
|
+
counted = true;
|
|
1460
|
+
frozen = out.body.summary ?? void 0;
|
|
1461
|
+
const n = out.body.shardsExpected ?? shard.total;
|
|
1462
|
+
if (out.body.run?.statusEvent === "finished") after.push("plune: run closed");
|
|
1463
|
+
after.push(`plune: shard ${shard.index} of ${shard.total} done \u2014 ${out.body.shardsReported} of ${n} shards reported`);
|
|
1464
|
+
} else if (out.ok || out.kind === "invalid") {
|
|
1465
|
+
log('plune: the platform does not support shard auto-close yet \u2014 the run is left open for "plune run finish".');
|
|
1466
|
+
} else if (out.code === "shards_mismatch") {
|
|
1467
|
+
mismatch = new Error(
|
|
1468
|
+
`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.`
|
|
1469
|
+
);
|
|
1470
|
+
} else {
|
|
1471
|
+
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}".`);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
if (runId !== null && !counted) {
|
|
1394
1475
|
log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
|
|
1395
1476
|
}
|
|
1396
1477
|
summarise();
|
|
1478
|
+
shardLines.push(...after);
|
|
1479
|
+
if (opts?.holdShardLines !== true) for (const line of after) log(line);
|
|
1480
|
+
if (mismatch !== void 0) throw mismatch;
|
|
1481
|
+
return frozen;
|
|
1397
1482
|
}
|
|
1398
1483
|
};
|
|
1399
1484
|
}
|
package/package.json
CHANGED