@plune-ai/cli 0.21.0 → 0.22.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 +21 -1
- package/dist/cli.cjs +98 -32
- package/dist/reporter-core.cjs +86 -29
- package/dist/reporter-core.d.cts +16 -1
- package/dist/reporter-core.d.ts +16 -1
- package/dist/reporter-core.js +86 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -120,7 +120,7 @@ the last resort, used only when `PLUNE_TOKEN` is set nowhere else. Nothing write
|
|
|
120
120
|
| `plune login` | Save a [Plune platform](https://plune.ai/platform) API token so `sync` and `ingest` can reach it. The token is **checked against the API before it is saved**, so a wrong one fails here rather than two commands later. Get one at `https://beta.plune.ai` → Account → API tokens. Flags: `--token <token>` (omit to paste it or pipe it via stdin), `--skip-verify` (save without checking, for offline setup). In CI, skip this step: every platform command reads `PLUNE_TOKEN` from the environment first, and the saved login only when it is not set. |
|
|
121
121
|
| `plune logout` | Remove the saved token. |
|
|
122
122
|
| `plune sync` | Upload the latest local run to the platform. Flags: `--file <path>` to send a specific run JSON, `--fail-on-failures` to read the run's summary back once after the upload and exit with [the gate](#a-release-gate---fail-on-failures)'s code. |
|
|
123
|
-
| `plune run import <file>` | Turn a **JUnit XML** or **Playwright JSON** report, or an **Allure `allure-results` folder**, into a run in Plune. Needs no provider key — nothing is generated. Flags: `--format junit\|playwright-json\|allure` (detected from the file when omitted; a folder is Allure), `--key <externalKey>` to land several reports in one run (with `PLUNE_SHARED_RUN=1` — see below), `--create` to offer unmatched tests to the review queue, `--fail-on-missing-attachments` to exit 1 when a file a JUnit report names with `[[ATTACHMENT\|path]]`, or an Allure result names as a `source`, is not there (the results are sent either way), `--fail-on-failures` to exit with [the gate](#a-release-gate---fail-on-failures)'s code on the summary the platform froze when this import finished the run. |
|
|
123
|
+
| `plune run import <file>` | Turn a **JUnit XML** or **Playwright JSON** report, or an **Allure `allure-results` folder**, into a run in Plune. Needs no provider key — nothing is generated. Flags: `--format junit\|playwright-json\|allure` (detected from the file when omitted; a folder is Allure), `--key <externalKey>` to land several reports in one run (with `PLUNE_SHARED_RUN=1` — see below), `--create` to offer unmatched tests to the review queue, `--fail-on-missing-attachments` to exit 1 when a file a JUnit report names with `[[ATTACHMENT\|path]]`, or an Allure result names as a `source`, is not there (the results are sent either way), `--upload-concurrency <n>` for files uploaded at once ([1 to 32, default 4](#many-screenshots-upload-concurrency-and-retries)), `--fail-on-failures` to exit with [the gate](#a-release-gate---fail-on-failures)'s code on the summary the platform froze when this import finished the run. |
|
|
124
124
|
| `plune run start` | Open a platform run — or join the one already carrying `--key` — and print its id. Flags: `--key <externalKey>` (generated when absent), `--json` for one machine-readable line. |
|
|
125
125
|
| `plune run finish <id>` | Close a platform run. This is what the reporter tells you to do for a run it had to leave open. Flags: `--terminate` to record it as cut short, `--reason <text>`, `--fail-on-failures` to exit with [the gate](#a-release-gate---fail-on-failures)'s code on the summary the platform froze. |
|
|
126
126
|
| `plune run exec -- <command>` | Open a run, run the command inside it, close the run — and exit with whatever the command returned. Sets `PLUNE_SHARED_RUN` for you, so anything reporting inside joins that run. Flags: `--key <externalKey>`, `--fail-on-failures` to exit with [the gate](#a-release-gate---fail-on-failures)'s code instead — or with the command's own when no result reached the run. |
|
|
@@ -322,6 +322,26 @@ job closes the run instead of the explicit `finish`.
|
|
|
322
322
|
`plune run exec` sets it for you — anything reporting inside it, this command included, joins
|
|
323
323
|
without closing.
|
|
324
324
|
|
|
325
|
+
### Many screenshots: upload concurrency and retries
|
|
326
|
+
|
|
327
|
+
Every file is its own request, so a report with thousands of screenshots is bound by the round trip, not
|
|
328
|
+
by bandwidth. Files go four at a time by default; raise it on a runner with a slow link:
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
PLUNE_UPLOAD_CONCURRENCY=16 plune run import ./playwright.json # 1 to 32, default 4
|
|
332
|
+
plune run import ./playwright.json --upload-concurrency 16 # the same, as a flag (it wins)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
When the platform answers 429 with a `Retry-After` (seconds or an HTTP date, never waited longer than 60 s),
|
|
336
|
+
the request is tried again after that time plus up to half as much, and every other request of the import
|
|
337
|
+
waits through the same pause. `PLUNE_UPLOAD_RETRIES` (0 to 20, default 6) says how many times; a 429 that
|
|
338
|
+
names no time is the quota and is not retried. A platform that is down (a 5xx or no answer) is still tried
|
|
339
|
+
twice (three times for a bucket). A file that stays refused is reported without it, never fails the import,
|
|
340
|
+
and is counted in the line printed before the summary:
|
|
341
|
+
`plune: files — 2758 uploaded, 0 skipped, 14 retried, 0 dropped, in 41.2 s (16 at once)`.
|
|
342
|
+
A value outside the range is ignored with a warning, and the default is used. The Playwright reporter reads
|
|
343
|
+
the same variables.
|
|
344
|
+
|
|
325
345
|
### When a test is deleted
|
|
326
346
|
|
|
327
347
|
A test that vanishes from the code stays in Plune as an active case until something says otherwise.
|
package/dist/cli.cjs
CHANGED
|
@@ -2304,6 +2304,13 @@ var init_credentials = __esm({
|
|
|
2304
2304
|
});
|
|
2305
2305
|
|
|
2306
2306
|
// src/reporter-core/client.ts
|
|
2307
|
+
function parseRetryAfter(raw, now = Date.now()) {
|
|
2308
|
+
const text2 = raw?.trim();
|
|
2309
|
+
if (text2 === void 0 || text2 === "") return null;
|
|
2310
|
+
const seconds = /^\d+(\.\d+)?$/.test(text2) ? Number(text2) : (Date.parse(text2) - now) / 1e3;
|
|
2311
|
+
if (!Number.isFinite(seconds)) return null;
|
|
2312
|
+
return Math.min(Math.max(seconds, 0) * 1e3, RETRY_AFTER_CAP_MS);
|
|
2313
|
+
}
|
|
2307
2314
|
function nodeTransport(url, init, idleMs = IDLE_MS) {
|
|
2308
2315
|
return new Promise((resolve9, reject) => {
|
|
2309
2316
|
const target = new URL(url);
|
|
@@ -2371,12 +2378,6 @@ async function detailOf(res) {
|
|
|
2371
2378
|
return { detail: "" };
|
|
2372
2379
|
}
|
|
2373
2380
|
}
|
|
2374
|
-
function retryAfterMs2(res) {
|
|
2375
|
-
const raw = res.headers.get("retry-after");
|
|
2376
|
-
if (raw === null) return null;
|
|
2377
|
-
const seconds = Number(raw.trim());
|
|
2378
|
-
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : null;
|
|
2379
|
-
}
|
|
2380
2381
|
function classify(status) {
|
|
2381
2382
|
if (status === 401 || status === 403) return "auth";
|
|
2382
2383
|
if (status === 409) return "conflict";
|
|
@@ -2387,15 +2388,34 @@ function classify(status) {
|
|
|
2387
2388
|
function createClient(opts) {
|
|
2388
2389
|
const doFetch = opts.fetchImpl ?? nodeTransport;
|
|
2389
2390
|
const wait = opts.wait ?? sleep;
|
|
2391
|
+
const uploadRetries = opts.uploadRetries === void 0 ? void 0 : Math.max(0, Math.floor(opts.uploadRetries));
|
|
2392
|
+
const random = opts.random ?? Math.random;
|
|
2393
|
+
const now = opts.now ?? Date.now;
|
|
2394
|
+
let pausedUntil = 0;
|
|
2395
|
+
const jittered = (ms) => Math.round(ms * (1 + random() * 0.5));
|
|
2396
|
+
async function backOff(attempt, said) {
|
|
2397
|
+
opts.onRetry?.();
|
|
2398
|
+
const ms = jittered(said ?? Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
|
|
2399
|
+
const until = now() + ms;
|
|
2400
|
+
pausedUntil = Math.max(pausedUntil, until);
|
|
2401
|
+
await wait(ms);
|
|
2402
|
+
return until;
|
|
2403
|
+
}
|
|
2404
|
+
async function respectPause(mine) {
|
|
2405
|
+
const left = pausedUntil - now();
|
|
2406
|
+
if (pausedUntil > mine && left > 0) await wait(left + Math.round(random() * 250));
|
|
2407
|
+
}
|
|
2390
2408
|
const base = opts.apiUrl.replace(/\/+$/, "");
|
|
2391
2409
|
const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
|
|
2392
|
-
async function send(method, path17, payload) {
|
|
2410
|
+
async function send(method, path17, payload, retries = DEFAULT_RETRIES) {
|
|
2393
2411
|
let last = {
|
|
2394
2412
|
kind: "unavailable",
|
|
2395
2413
|
status: null,
|
|
2396
2414
|
detail: "no attempt was made"
|
|
2397
2415
|
};
|
|
2398
|
-
|
|
2416
|
+
let mine = 0;
|
|
2417
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
2418
|
+
await respectPause(mine);
|
|
2399
2419
|
let res;
|
|
2400
2420
|
try {
|
|
2401
2421
|
res = await doFetch(`${base}${path17}`, {
|
|
@@ -2408,7 +2428,8 @@ function createClient(opts) {
|
|
|
2408
2428
|
});
|
|
2409
2429
|
} catch (err) {
|
|
2410
2430
|
last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
|
|
2411
|
-
if (attempt
|
|
2431
|
+
if (attempt >= Math.min(retries, DEFAULT_RETRIES)) break;
|
|
2432
|
+
mine = await backOff(attempt, null);
|
|
2412
2433
|
continue;
|
|
2413
2434
|
}
|
|
2414
2435
|
if (res.ok) {
|
|
@@ -2423,25 +2444,28 @@ function createClient(opts) {
|
|
|
2423
2444
|
const kind = classify(res.status);
|
|
2424
2445
|
last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
|
|
2425
2446
|
if (kind !== "unavailable") break;
|
|
2426
|
-
|
|
2447
|
+
const said429 = parseRetryAfter(res.headers.get("retry-after"), now());
|
|
2448
|
+
if (res.status === 429 && said429 === null) {
|
|
2427
2449
|
last.kind = "invalid";
|
|
2428
2450
|
last.detail = "quota exceeded (no Retry-After)";
|
|
2429
2451
|
if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
|
|
2430
2452
|
break;
|
|
2431
2453
|
}
|
|
2432
|
-
if (attempt
|
|
2433
|
-
|
|
2434
|
-
}
|
|
2454
|
+
if (attempt >= (said429 === null ? Math.min(retries, DEFAULT_RETRIES) : retries)) break;
|
|
2455
|
+
mine = await backOff(attempt, said429);
|
|
2435
2456
|
}
|
|
2436
2457
|
return { ok: false, ...last };
|
|
2437
2458
|
}
|
|
2438
2459
|
return {
|
|
2439
|
-
post: (path17, body) => send("POST", path17, { body: JSON.stringify(body), type: "application/json" }),
|
|
2440
|
-
upload: (path17, bytes, contentType) => send("POST", path17, { body: bytes, type: contentType }),
|
|
2460
|
+
post: (path17, body, retries) => send("POST", path17, { body: JSON.stringify(body), type: "application/json" }, retries),
|
|
2461
|
+
upload: (path17, bytes, contentType) => send("POST", path17, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
|
|
2441
2462
|
del: (path17) => send("DELETE", path17),
|
|
2442
2463
|
async put(url, headers, source) {
|
|
2464
|
+
const putRetries = uploadRetries ?? 3;
|
|
2443
2465
|
let last = { ok: false, status: null, code: "network" };
|
|
2444
|
-
|
|
2466
|
+
let mine = 0;
|
|
2467
|
+
for (let attempt = 0; attempt <= putRetries; attempt += 1) {
|
|
2468
|
+
await respectPause(mine);
|
|
2445
2469
|
try {
|
|
2446
2470
|
let status;
|
|
2447
2471
|
let retryAfter;
|
|
@@ -2456,21 +2480,21 @@ function createClient(opts) {
|
|
|
2456
2480
|
if (status >= 200 && status <= 299) return { ok: true };
|
|
2457
2481
|
last = { ok: false, status, code: `bucket_${status}` };
|
|
2458
2482
|
if (status < 500 && status !== 429) return last;
|
|
2459
|
-
const
|
|
2460
|
-
if (attempt
|
|
2461
|
-
|
|
2462
|
-
}
|
|
2483
|
+
const said = parseRetryAfter(retryAfter, now());
|
|
2484
|
+
if (attempt >= (said === null ? Math.min(putRetries, 3) : putRetries)) return last;
|
|
2485
|
+
mine = await backOff(attempt, said);
|
|
2463
2486
|
continue;
|
|
2464
2487
|
} catch (err) {
|
|
2465
2488
|
last = { ok: false, status: null, code: codeOfError(err) };
|
|
2466
2489
|
}
|
|
2467
|
-
if (attempt
|
|
2490
|
+
if (attempt >= Math.min(putRetries, 3)) return last;
|
|
2491
|
+
mine = await backOff(attempt, null);
|
|
2468
2492
|
}
|
|
2469
2493
|
return last;
|
|
2470
2494
|
}
|
|
2471
2495
|
};
|
|
2472
2496
|
}
|
|
2473
|
-
var import_node_fs2, import_node_stream, import_node_http, import_node_https,
|
|
2497
|
+
var import_node_fs2, import_node_stream, import_node_http, import_node_https, BACKOFF_BASE_MS, BACKOFF_CAP_MS, DEFAULT_RETRIES, RETRY_AFTER_CAP_MS, IDLE_MS, withoutLength, codeOfError, sleep;
|
|
2474
2498
|
var init_client = __esm({
|
|
2475
2499
|
"src/reporter-core/client.ts"() {
|
|
2476
2500
|
"use strict";
|
|
@@ -2479,9 +2503,11 @@ var init_client = __esm({
|
|
|
2479
2503
|
import_node_stream = require("stream");
|
|
2480
2504
|
import_node_http = require("http");
|
|
2481
2505
|
import_node_https = require("https");
|
|
2482
|
-
|
|
2506
|
+
BACKOFF_BASE_MS = 300;
|
|
2507
|
+
BACKOFF_CAP_MS = 1e4;
|
|
2508
|
+
DEFAULT_RETRIES = 2;
|
|
2509
|
+
RETRY_AFTER_CAP_MS = 6e4;
|
|
2483
2510
|
IDLE_MS = 3e5;
|
|
2484
|
-
PUT_DELAYS_MS = [500, 1500, 4e3];
|
|
2485
2511
|
withoutLength = (headers) => Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-length"));
|
|
2486
2512
|
codeOfError = (err) => {
|
|
2487
2513
|
const code = err?.code;
|
|
@@ -2506,6 +2532,14 @@ function count(env, name) {
|
|
|
2506
2532
|
const n = Number(raw);
|
|
2507
2533
|
return Number.isInteger(n) && n > 0 ? n : void 0;
|
|
2508
2534
|
}
|
|
2535
|
+
function bounded(env, name, range, warnings) {
|
|
2536
|
+
const raw = value(env, name);
|
|
2537
|
+
if (raw === void 0) return void 0;
|
|
2538
|
+
const n = Number(raw);
|
|
2539
|
+
if (/^\d+$/.test(raw) && n >= range.min && n <= range.max) return n;
|
|
2540
|
+
warnings.push(`${name}=${JSON.stringify(raw.slice(0, 40))} is not a whole number from ${range.min} to ${range.max} \u2014 using ${range.default}.`);
|
|
2541
|
+
return void 0;
|
|
2542
|
+
}
|
|
2509
2543
|
function list(env, name) {
|
|
2510
2544
|
const raw = value(env, name);
|
|
2511
2545
|
if (raw === void 0) return void 0;
|
|
@@ -2518,6 +2552,9 @@ function readEnv(env = process.env) {
|
|
|
2518
2552
|
const apiUrl = value(env, "PLUNE_API_URL");
|
|
2519
2553
|
const fallbackPath = value(env, "PLUNE_FALLBACK");
|
|
2520
2554
|
const batchSize = count(env, "PLUNE_BATCH_SIZE");
|
|
2555
|
+
const warnings = [];
|
|
2556
|
+
const uploadConcurrency = bounded(env, "PLUNE_UPLOAD_CONCURRENCY", UPLOAD_CONCURRENCY, warnings);
|
|
2557
|
+
const uploadRetries = bounded(env, "PLUNE_UPLOAD_RETRIES", UPLOAD_RETRIES, warnings);
|
|
2521
2558
|
const title = value(env, "PLUNE_RUN_TITLE");
|
|
2522
2559
|
const environment = value(env, "PLUNE_ENV");
|
|
2523
2560
|
const labels = list(env, "PLUNE_LABELS");
|
|
@@ -2530,6 +2567,8 @@ function readEnv(env = process.env) {
|
|
|
2530
2567
|
...apiUrl !== void 0 ? { apiUrl } : {},
|
|
2531
2568
|
...fallbackPath !== void 0 ? { fallbackPath } : {},
|
|
2532
2569
|
...batchSize !== void 0 ? { batchSize } : {},
|
|
2570
|
+
...uploadConcurrency !== void 0 ? { uploadConcurrency } : {},
|
|
2571
|
+
...uploadRetries !== void 0 ? { uploadRetries } : {},
|
|
2533
2572
|
...title !== void 0 ? { title } : {},
|
|
2534
2573
|
...environment !== void 0 ? { environment } : {},
|
|
2535
2574
|
...labels !== void 0 ? { labels } : {},
|
|
@@ -2542,7 +2581,8 @@ function readEnv(env = process.env) {
|
|
|
2542
2581
|
// reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
|
|
2543
2582
|
// way this process must not be the one to call it finished.
|
|
2544
2583
|
keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED"),
|
|
2545
|
-
ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0)
|
|
2584
|
+
ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
|
|
2585
|
+
warnings
|
|
2546
2586
|
};
|
|
2547
2587
|
}
|
|
2548
2588
|
function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cwd = process.cwd()) {
|
|
@@ -2568,13 +2608,15 @@ function ciCommit(reported, env = process.env) {
|
|
|
2568
2608
|
...branch !== void 0 ? { branch } : {}
|
|
2569
2609
|
};
|
|
2570
2610
|
}
|
|
2571
|
-
var path6, UNSUPPORTED_VARS, PROVIDERS;
|
|
2611
|
+
var path6, UNSUPPORTED_VARS, UPLOAD_CONCURRENCY, UPLOAD_RETRIES, PROVIDERS;
|
|
2572
2612
|
var init_env2 = __esm({
|
|
2573
2613
|
"src/reporter-core/env.ts"() {
|
|
2574
2614
|
"use strict";
|
|
2575
2615
|
init_cjs_shims();
|
|
2576
2616
|
path6 = __toESM(require("path"), 1);
|
|
2577
2617
|
UNSUPPORTED_VARS = ["PLUNE_GROUP"];
|
|
2618
|
+
UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 4 };
|
|
2619
|
+
UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
|
|
2578
2620
|
PROVIDERS = [
|
|
2579
2621
|
{ is: "GITHUB_ACTIONS", sha: "GITHUB_SHA", branch: ["GITHUB_HEAD_REF", "GITHUB_REF_NAME"] },
|
|
2580
2622
|
{ is: "GITLAB_CI", sha: "CI_COMMIT_SHA", branch: ["CI_COMMIT_REF_NAME"] },
|
|
@@ -2854,9 +2896,15 @@ async function startRun(passed, declared2) {
|
|
|
2854
2896
|
const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
|
|
2855
2897
|
const offerDiscovered = cfg.offerDiscovered ?? false;
|
|
2856
2898
|
const token = cfg.token ?? loadToken() ?? "";
|
|
2899
|
+
const uploadsAtOnce = Math.min(Math.max(Math.floor(cfg.uploadConcurrency ?? UPLOAD_CONCURRENCY.default), UPLOAD_CONCURRENCY.min), UPLOAD_CONCURRENCY.max);
|
|
2900
|
+
const uploadRetries = Math.min(Math.max(Math.floor(cfg.uploadRetries ?? UPLOAD_RETRIES.default), UPLOAD_RETRIES.min), UPLOAD_RETRIES.max);
|
|
2901
|
+
let retried = 0;
|
|
2902
|
+
let uploadMs = 0;
|
|
2857
2903
|
const client = createClient({
|
|
2858
2904
|
apiUrl: resolveApiUrl(cfg.apiUrl),
|
|
2859
2905
|
token,
|
|
2906
|
+
uploadRetries,
|
|
2907
|
+
onRetry: () => void (retried += 1),
|
|
2860
2908
|
...cfg.fetchImpl !== void 0 ? { fetchImpl: cfg.fetchImpl } : {}
|
|
2861
2909
|
});
|
|
2862
2910
|
const stats = {
|
|
@@ -2907,6 +2955,7 @@ async function startRun(passed, declared2) {
|
|
|
2907
2955
|
if (offline) {
|
|
2908
2956
|
log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
|
|
2909
2957
|
}
|
|
2958
|
+
for (const warning of env.warnings) log(`plune: ${warning}`);
|
|
2910
2959
|
if (env.ignored.length > 0) {
|
|
2911
2960
|
log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
|
|
2912
2961
|
}
|
|
@@ -3036,7 +3085,7 @@ async function startRun(passed, declared2) {
|
|
|
3036
3085
|
function ask(job, size) {
|
|
3037
3086
|
return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
|
|
3038
3087
|
files: [{ name: job.name, contentType: job.type, size }]
|
|
3039
|
-
});
|
|
3088
|
+
}, uploadRetries);
|
|
3040
3089
|
}
|
|
3041
3090
|
function failed(job, why) {
|
|
3042
3091
|
countsOf(job.kind).failed += 1;
|
|
@@ -3097,11 +3146,11 @@ async function startRun(passed, declared2) {
|
|
|
3097
3146
|
if (fresh === void 0) return failedToBucket(job, "bad_answer");
|
|
3098
3147
|
link = fresh;
|
|
3099
3148
|
}
|
|
3100
|
-
const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {});
|
|
3149
|
+
const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {}, uploadRetries);
|
|
3101
3150
|
if (!done2.ok) return failedToBucket(job, codeOf2(done2));
|
|
3102
3151
|
countsOf(job.kind).uploaded += 1;
|
|
3103
3152
|
}
|
|
3104
|
-
let free =
|
|
3153
|
+
let free = uploadsAtOnce;
|
|
3105
3154
|
const waiting = [];
|
|
3106
3155
|
async function gated(work) {
|
|
3107
3156
|
if (free === 0) await new Promise((resolve9) => waiting.push(resolve9));
|
|
@@ -3181,6 +3230,7 @@ async function startRun(passed, declared2) {
|
|
|
3181
3230
|
}
|
|
3182
3231
|
}
|
|
3183
3232
|
}
|
|
3233
|
+
const started = Date.now();
|
|
3184
3234
|
const byResult = /* @__PURE__ */ new Map();
|
|
3185
3235
|
for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
|
|
3186
3236
|
await Promise.all(
|
|
@@ -3193,6 +3243,7 @@ async function startRun(passed, declared2) {
|
|
|
3193
3243
|
await Promise.all(work);
|
|
3194
3244
|
})
|
|
3195
3245
|
);
|
|
3246
|
+
uploadMs += Date.now() - started;
|
|
3196
3247
|
}
|
|
3197
3248
|
async function flush() {
|
|
3198
3249
|
if (buffer.length === 0) return;
|
|
@@ -3285,6 +3336,13 @@ async function startRun(passed, declared2) {
|
|
|
3285
3336
|
}
|
|
3286
3337
|
if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
|
|
3287
3338
|
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
3339
|
+
const files = [stats.screenshots, stats.textFiles, stats.otherFiles];
|
|
3340
|
+
const total = (what) => files.reduce((n, counts) => n + counts[what], 0);
|
|
3341
|
+
if (total("uploaded") + total("skipped") + total("failed") > 0) {
|
|
3342
|
+
log(
|
|
3343
|
+
`plune: files \u2014 ${total("uploaded")} uploaded, ${total("skipped")} skipped, ${retried} retried, ${total("failed")} dropped, in ${(uploadMs / 1e3).toFixed(1)} s (${uploadsAtOnce} at once)`
|
|
3344
|
+
);
|
|
3345
|
+
}
|
|
3288
3346
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
3289
3347
|
if (stats.deferred > 0 && process.env["GITHUB_ACTIONS"] === "true") {
|
|
3290
3348
|
log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
|
|
@@ -3339,7 +3397,7 @@ async function startRun(passed, declared2) {
|
|
|
3339
3397
|
}
|
|
3340
3398
|
};
|
|
3341
3399
|
}
|
|
3342
|
-
var import_node_crypto2, import_node_fs4, import_node_path3, import_node_util2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TEXT_BYTES, TEXTS_PER_RESULT, UTF8, TYPE_BY_EXTENSION, METADATA_TYPE2,
|
|
3400
|
+
var import_node_crypto2, import_node_fs4, import_node_path3, import_node_util2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TEXT_BYTES, TEXTS_PER_RESULT, UTF8, TYPE_BY_EXTENSION, METADATA_TYPE2, NAME_MAX, SKIPPED, BUCKET_FOUND, KINDS, codeOf2, BATCH_BYTES, ENVELOPE_BYTES;
|
|
3343
3401
|
var init_session = __esm({
|
|
3344
3402
|
"src/reporter-core/session.ts"() {
|
|
3345
3403
|
"use strict";
|
|
@@ -3357,7 +3415,7 @@ var init_session = __esm({
|
|
|
3357
3415
|
RESOLVE_CHUNK = 500;
|
|
3358
3416
|
BATCH_MAX = 500;
|
|
3359
3417
|
DISCOVER_MAX = 500;
|
|
3360
|
-
BATCH_DEFAULT =
|
|
3418
|
+
BATCH_DEFAULT = 500;
|
|
3361
3419
|
EXPECTED_MAX = 1e4;
|
|
3362
3420
|
KEY_MAX = 1024;
|
|
3363
3421
|
TITLE_MAX = 300;
|
|
@@ -3375,7 +3433,6 @@ var init_session = __esm({
|
|
|
3375
3433
|
".webp": "image/webp"
|
|
3376
3434
|
};
|
|
3377
3435
|
METADATA_TYPE2 = "application/plune.metadata+json";
|
|
3378
|
-
UPLOADS_AT_ONCE = 4;
|
|
3379
3436
|
NAME_MAX = 200;
|
|
3380
3437
|
SKIPPED = {
|
|
3381
3438
|
hidden: "its name starts with an underscore, which hides an attachment in Playwright's own reports too",
|
|
@@ -4958,6 +5015,7 @@ async function handleRunImport(options) {
|
|
|
4958
5015
|
...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
|
|
4959
5016
|
...options.key !== void 0 ? { externalKey: options.key } : {},
|
|
4960
5017
|
...options.create === true ? { offerDiscovered: true } : {},
|
|
5018
|
+
...options.uploadConcurrency !== void 0 ? { uploadConcurrency: options.uploadConcurrency } : {},
|
|
4961
5019
|
kind: "automated",
|
|
4962
5020
|
// The CI run the report names, never this machine's: whoever imports may be another job (#790).
|
|
4963
5021
|
// The commit and branch likewise, where the report names a commit; a report that names none —
|
|
@@ -6110,6 +6168,13 @@ function createProgram() {
|
|
|
6110
6168
|
"--fail-on-missing-attachments",
|
|
6111
6169
|
"Exit 1 when a file the report names ([[ATTACHMENT|path]] in JUnit, a source in allure-results) is not there; the results are sent either way",
|
|
6112
6170
|
false
|
|
6171
|
+
).option(
|
|
6172
|
+
"--upload-concurrency <n>",
|
|
6173
|
+
"Files uploaded at once, 1 to 32 (default 4; PLUNE_UPLOAD_CONCURRENCY sets it too). A 429 is retried PLUNE_UPLOAD_RETRIES times (default 6)",
|
|
6174
|
+
(v) => {
|
|
6175
|
+
if (!/^\d+$/.test(v) || Number(v) < 1 || Number(v) > 32) throw new import_commander.InvalidArgumentError("a whole number from 1 to 32.");
|
|
6176
|
+
return Number(v);
|
|
6177
|
+
}
|
|
6113
6178
|
).option("--fail-on-failures", FAIL_ON_FAILURES, false).action(
|
|
6114
6179
|
async (file, options, command) => {
|
|
6115
6180
|
const [
|
|
@@ -6135,6 +6200,7 @@ function createProgram() {
|
|
|
6135
6200
|
...format !== void 0 ? { format } : {},
|
|
6136
6201
|
...options.key !== void 0 ? { key: options.key } : {},
|
|
6137
6202
|
...options.create ? { create: true } : {},
|
|
6203
|
+
...options.uploadConcurrency !== void 0 ? { uploadConcurrency: options.uploadConcurrency } : {},
|
|
6138
6204
|
...options.failOnFailures ? { failOnFailures: true } : {}
|
|
6139
6205
|
});
|
|
6140
6206
|
if (options.failOnMissingAttachments && imported.missingAttachments.length > 0) process.exitCode = 1;
|
package/dist/reporter-core.cjs
CHANGED
|
@@ -84,7 +84,17 @@ var import_node_fs2 = require("fs");
|
|
|
84
84
|
var import_node_stream = require("stream");
|
|
85
85
|
var import_node_http = require("http");
|
|
86
86
|
var import_node_https = require("https");
|
|
87
|
-
var
|
|
87
|
+
var BACKOFF_BASE_MS = 300;
|
|
88
|
+
var BACKOFF_CAP_MS = 1e4;
|
|
89
|
+
var DEFAULT_RETRIES = 2;
|
|
90
|
+
var RETRY_AFTER_CAP_MS = 6e4;
|
|
91
|
+
function parseRetryAfter(raw, now = Date.now()) {
|
|
92
|
+
const text = raw?.trim();
|
|
93
|
+
if (text === void 0 || text === "") return null;
|
|
94
|
+
const seconds = /^\d+(\.\d+)?$/.test(text) ? Number(text) : (Date.parse(text) - now) / 1e3;
|
|
95
|
+
if (!Number.isFinite(seconds)) return null;
|
|
96
|
+
return Math.min(Math.max(seconds, 0) * 1e3, RETRY_AFTER_CAP_MS);
|
|
97
|
+
}
|
|
88
98
|
var IDLE_MS = 3e5;
|
|
89
99
|
function nodeTransport(url, init, idleMs = IDLE_MS) {
|
|
90
100
|
return new Promise((resolve2, reject) => {
|
|
@@ -115,7 +125,6 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
|
|
|
115
125
|
req.end(init.body);
|
|
116
126
|
});
|
|
117
127
|
}
|
|
118
|
-
var PUT_DELAYS_MS = [500, 1500, 4e3];
|
|
119
128
|
var withoutLength = (headers) => Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-length"));
|
|
120
129
|
function nodePut(url, headers, source, idleMs) {
|
|
121
130
|
return new Promise((resolve2, reject) => {
|
|
@@ -160,12 +169,6 @@ async function detailOf(res) {
|
|
|
160
169
|
return { detail: "" };
|
|
161
170
|
}
|
|
162
171
|
}
|
|
163
|
-
function retryAfterMs(res) {
|
|
164
|
-
const raw = res.headers.get("retry-after");
|
|
165
|
-
if (raw === null) return null;
|
|
166
|
-
const seconds = Number(raw.trim());
|
|
167
|
-
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : null;
|
|
168
|
-
}
|
|
169
172
|
function classify(status) {
|
|
170
173
|
if (status === 401 || status === 403) return "auth";
|
|
171
174
|
if (status === 409) return "conflict";
|
|
@@ -176,15 +179,34 @@ function classify(status) {
|
|
|
176
179
|
function createClient(opts) {
|
|
177
180
|
const doFetch = opts.fetchImpl ?? nodeTransport;
|
|
178
181
|
const wait = opts.wait ?? sleep;
|
|
182
|
+
const uploadRetries = opts.uploadRetries === void 0 ? void 0 : Math.max(0, Math.floor(opts.uploadRetries));
|
|
183
|
+
const random = opts.random ?? Math.random;
|
|
184
|
+
const now = opts.now ?? Date.now;
|
|
185
|
+
let pausedUntil = 0;
|
|
186
|
+
const jittered = (ms) => Math.round(ms * (1 + random() * 0.5));
|
|
187
|
+
async function backOff(attempt, said) {
|
|
188
|
+
opts.onRetry?.();
|
|
189
|
+
const ms = jittered(said ?? Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
|
|
190
|
+
const until = now() + ms;
|
|
191
|
+
pausedUntil = Math.max(pausedUntil, until);
|
|
192
|
+
await wait(ms);
|
|
193
|
+
return until;
|
|
194
|
+
}
|
|
195
|
+
async function respectPause(mine) {
|
|
196
|
+
const left = pausedUntil - now();
|
|
197
|
+
if (pausedUntil > mine && left > 0) await wait(left + Math.round(random() * 250));
|
|
198
|
+
}
|
|
179
199
|
const base = opts.apiUrl.replace(/\/+$/, "");
|
|
180
200
|
const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
|
|
181
|
-
async function send(method, path3, payload) {
|
|
201
|
+
async function send(method, path3, payload, retries = DEFAULT_RETRIES) {
|
|
182
202
|
let last = {
|
|
183
203
|
kind: "unavailable",
|
|
184
204
|
status: null,
|
|
185
205
|
detail: "no attempt was made"
|
|
186
206
|
};
|
|
187
|
-
|
|
207
|
+
let mine = 0;
|
|
208
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
209
|
+
await respectPause(mine);
|
|
188
210
|
let res;
|
|
189
211
|
try {
|
|
190
212
|
res = await doFetch(`${base}${path3}`, {
|
|
@@ -197,7 +219,8 @@ function createClient(opts) {
|
|
|
197
219
|
});
|
|
198
220
|
} catch (err) {
|
|
199
221
|
last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
|
|
200
|
-
if (attempt
|
|
222
|
+
if (attempt >= Math.min(retries, DEFAULT_RETRIES)) break;
|
|
223
|
+
mine = await backOff(attempt, null);
|
|
201
224
|
continue;
|
|
202
225
|
}
|
|
203
226
|
if (res.ok) {
|
|
@@ -212,25 +235,28 @@ function createClient(opts) {
|
|
|
212
235
|
const kind = classify(res.status);
|
|
213
236
|
last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
|
|
214
237
|
if (kind !== "unavailable") break;
|
|
215
|
-
|
|
238
|
+
const said429 = parseRetryAfter(res.headers.get("retry-after"), now());
|
|
239
|
+
if (res.status === 429 && said429 === null) {
|
|
216
240
|
last.kind = "invalid";
|
|
217
241
|
last.detail = "quota exceeded (no Retry-After)";
|
|
218
242
|
if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
|
|
219
243
|
break;
|
|
220
244
|
}
|
|
221
|
-
if (attempt
|
|
222
|
-
|
|
223
|
-
}
|
|
245
|
+
if (attempt >= (said429 === null ? Math.min(retries, DEFAULT_RETRIES) : retries)) break;
|
|
246
|
+
mine = await backOff(attempt, said429);
|
|
224
247
|
}
|
|
225
248
|
return { ok: false, ...last };
|
|
226
249
|
}
|
|
227
250
|
return {
|
|
228
|
-
post: (path3, body) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }),
|
|
229
|
-
upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }),
|
|
251
|
+
post: (path3, body, retries) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }, retries),
|
|
252
|
+
upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
|
|
230
253
|
del: (path3) => send("DELETE", path3),
|
|
231
254
|
async put(url, headers, source) {
|
|
255
|
+
const putRetries = uploadRetries ?? 3;
|
|
232
256
|
let last = { ok: false, status: null, code: "network" };
|
|
233
|
-
|
|
257
|
+
let mine = 0;
|
|
258
|
+
for (let attempt = 0; attempt <= putRetries; attempt += 1) {
|
|
259
|
+
await respectPause(mine);
|
|
234
260
|
try {
|
|
235
261
|
let status;
|
|
236
262
|
let retryAfter;
|
|
@@ -245,15 +271,15 @@ function createClient(opts) {
|
|
|
245
271
|
if (status >= 200 && status <= 299) return { ok: true };
|
|
246
272
|
last = { ok: false, status, code: `bucket_${status}` };
|
|
247
273
|
if (status < 500 && status !== 429) return last;
|
|
248
|
-
const
|
|
249
|
-
if (attempt
|
|
250
|
-
|
|
251
|
-
}
|
|
274
|
+
const said = parseRetryAfter(retryAfter, now());
|
|
275
|
+
if (attempt >= (said === null ? Math.min(putRetries, 3) : putRetries)) return last;
|
|
276
|
+
mine = await backOff(attempt, said);
|
|
252
277
|
continue;
|
|
253
278
|
} catch (err) {
|
|
254
279
|
last = { ok: false, status: null, code: codeOfError(err) };
|
|
255
280
|
}
|
|
256
|
-
if (attempt
|
|
281
|
+
if (attempt >= Math.min(putRetries, 3)) return last;
|
|
282
|
+
mine = await backOff(attempt, null);
|
|
257
283
|
}
|
|
258
284
|
return last;
|
|
259
285
|
}
|
|
@@ -263,6 +289,8 @@ function createClient(opts) {
|
|
|
263
289
|
// src/reporter-core/env.ts
|
|
264
290
|
var path = __toESM(require("path"), 1);
|
|
265
291
|
var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
|
|
292
|
+
var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 4 };
|
|
293
|
+
var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
|
|
266
294
|
function value(env, name) {
|
|
267
295
|
const raw = env[name]?.trim();
|
|
268
296
|
return raw === void 0 || raw === "" ? void 0 : raw;
|
|
@@ -277,6 +305,14 @@ function count(env, name) {
|
|
|
277
305
|
const n = Number(raw);
|
|
278
306
|
return Number.isInteger(n) && n > 0 ? n : void 0;
|
|
279
307
|
}
|
|
308
|
+
function bounded(env, name, range, warnings) {
|
|
309
|
+
const raw = value(env, name);
|
|
310
|
+
if (raw === void 0) return void 0;
|
|
311
|
+
const n = Number(raw);
|
|
312
|
+
if (/^\d+$/.test(raw) && n >= range.min && n <= range.max) return n;
|
|
313
|
+
warnings.push(`${name}=${JSON.stringify(raw.slice(0, 40))} is not a whole number from ${range.min} to ${range.max} \u2014 using ${range.default}.`);
|
|
314
|
+
return void 0;
|
|
315
|
+
}
|
|
280
316
|
function list(env, name) {
|
|
281
317
|
const raw = value(env, name);
|
|
282
318
|
if (raw === void 0) return void 0;
|
|
@@ -289,6 +325,9 @@ function readEnv(env = process.env) {
|
|
|
289
325
|
const apiUrl = value(env, "PLUNE_API_URL");
|
|
290
326
|
const fallbackPath = value(env, "PLUNE_FALLBACK");
|
|
291
327
|
const batchSize = count(env, "PLUNE_BATCH_SIZE");
|
|
328
|
+
const warnings = [];
|
|
329
|
+
const uploadConcurrency = bounded(env, "PLUNE_UPLOAD_CONCURRENCY", UPLOAD_CONCURRENCY, warnings);
|
|
330
|
+
const uploadRetries = bounded(env, "PLUNE_UPLOAD_RETRIES", UPLOAD_RETRIES, warnings);
|
|
292
331
|
const title = value(env, "PLUNE_RUN_TITLE");
|
|
293
332
|
const environment = value(env, "PLUNE_ENV");
|
|
294
333
|
const labels = list(env, "PLUNE_LABELS");
|
|
@@ -301,6 +340,8 @@ function readEnv(env = process.env) {
|
|
|
301
340
|
...apiUrl !== void 0 ? { apiUrl } : {},
|
|
302
341
|
...fallbackPath !== void 0 ? { fallbackPath } : {},
|
|
303
342
|
...batchSize !== void 0 ? { batchSize } : {},
|
|
343
|
+
...uploadConcurrency !== void 0 ? { uploadConcurrency } : {},
|
|
344
|
+
...uploadRetries !== void 0 ? { uploadRetries } : {},
|
|
304
345
|
...title !== void 0 ? { title } : {},
|
|
305
346
|
...environment !== void 0 ? { environment } : {},
|
|
306
347
|
...labels !== void 0 ? { labels } : {},
|
|
@@ -313,7 +354,8 @@ function readEnv(env = process.env) {
|
|
|
313
354
|
// reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
|
|
314
355
|
// way this process must not be the one to call it finished.
|
|
315
356
|
keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED"),
|
|
316
|
-
ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0)
|
|
357
|
+
ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
|
|
358
|
+
warnings
|
|
317
359
|
};
|
|
318
360
|
}
|
|
319
361
|
function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cwd = process.cwd()) {
|
|
@@ -499,7 +541,7 @@ function appendBatch(file, batch) {
|
|
|
499
541
|
var RESOLVE_CHUNK = 500;
|
|
500
542
|
var BATCH_MAX = 500;
|
|
501
543
|
var DISCOVER_MAX = 500;
|
|
502
|
-
var BATCH_DEFAULT =
|
|
544
|
+
var BATCH_DEFAULT = 500;
|
|
503
545
|
var EXPECTED_MAX = 1e4;
|
|
504
546
|
var KEY_MAX = 1024;
|
|
505
547
|
var TITLE_MAX = 300;
|
|
@@ -517,7 +559,6 @@ var TYPE_BY_EXTENSION = {
|
|
|
517
559
|
".webp": "image/webp"
|
|
518
560
|
};
|
|
519
561
|
var METADATA_TYPE2 = "application/plune.metadata+json";
|
|
520
|
-
var UPLOADS_AT_ONCE = 4;
|
|
521
562
|
var NAME_MAX = 200;
|
|
522
563
|
function nameOf(file) {
|
|
523
564
|
const own = file.name.split(/[\\/]/).pop()?.trim() || (file.path === void 0 ? "" : (0, import_node_path3.basename)(file.path));
|
|
@@ -648,9 +689,15 @@ async function startRun(passed, declared) {
|
|
|
648
689
|
const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
|
|
649
690
|
const offerDiscovered = cfg.offerDiscovered ?? false;
|
|
650
691
|
const token = cfg.token ?? loadToken() ?? "";
|
|
692
|
+
const uploadsAtOnce = Math.min(Math.max(Math.floor(cfg.uploadConcurrency ?? UPLOAD_CONCURRENCY.default), UPLOAD_CONCURRENCY.min), UPLOAD_CONCURRENCY.max);
|
|
693
|
+
const uploadRetries = Math.min(Math.max(Math.floor(cfg.uploadRetries ?? UPLOAD_RETRIES.default), UPLOAD_RETRIES.min), UPLOAD_RETRIES.max);
|
|
694
|
+
let retried = 0;
|
|
695
|
+
let uploadMs = 0;
|
|
651
696
|
const client = createClient({
|
|
652
697
|
apiUrl: resolveApiUrl(cfg.apiUrl),
|
|
653
698
|
token,
|
|
699
|
+
uploadRetries,
|
|
700
|
+
onRetry: () => void (retried += 1),
|
|
654
701
|
...cfg.fetchImpl !== void 0 ? { fetchImpl: cfg.fetchImpl } : {}
|
|
655
702
|
});
|
|
656
703
|
const stats = {
|
|
@@ -701,6 +748,7 @@ async function startRun(passed, declared) {
|
|
|
701
748
|
if (offline) {
|
|
702
749
|
log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
|
|
703
750
|
}
|
|
751
|
+
for (const warning of env.warnings) log(`plune: ${warning}`);
|
|
704
752
|
if (env.ignored.length > 0) {
|
|
705
753
|
log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
|
|
706
754
|
}
|
|
@@ -830,7 +878,7 @@ async function startRun(passed, declared) {
|
|
|
830
878
|
function ask(job, size) {
|
|
831
879
|
return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
|
|
832
880
|
files: [{ name: job.name, contentType: job.type, size }]
|
|
833
|
-
});
|
|
881
|
+
}, uploadRetries);
|
|
834
882
|
}
|
|
835
883
|
function failed(job, why) {
|
|
836
884
|
countsOf(job.kind).failed += 1;
|
|
@@ -891,11 +939,11 @@ async function startRun(passed, declared) {
|
|
|
891
939
|
if (fresh === void 0) return failedToBucket(job, "bad_answer");
|
|
892
940
|
link = fresh;
|
|
893
941
|
}
|
|
894
|
-
const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {});
|
|
942
|
+
const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {}, uploadRetries);
|
|
895
943
|
if (!done2.ok) return failedToBucket(job, codeOf(done2));
|
|
896
944
|
countsOf(job.kind).uploaded += 1;
|
|
897
945
|
}
|
|
898
|
-
let free =
|
|
946
|
+
let free = uploadsAtOnce;
|
|
899
947
|
const waiting = [];
|
|
900
948
|
async function gated(work) {
|
|
901
949
|
if (free === 0) await new Promise((resolve2) => waiting.push(resolve2));
|
|
@@ -975,6 +1023,7 @@ async function startRun(passed, declared) {
|
|
|
975
1023
|
}
|
|
976
1024
|
}
|
|
977
1025
|
}
|
|
1026
|
+
const started = Date.now();
|
|
978
1027
|
const byResult = /* @__PURE__ */ new Map();
|
|
979
1028
|
for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
|
|
980
1029
|
await Promise.all(
|
|
@@ -987,6 +1036,7 @@ async function startRun(passed, declared) {
|
|
|
987
1036
|
await Promise.all(work);
|
|
988
1037
|
})
|
|
989
1038
|
);
|
|
1039
|
+
uploadMs += Date.now() - started;
|
|
990
1040
|
}
|
|
991
1041
|
async function flush() {
|
|
992
1042
|
if (buffer.length === 0) return;
|
|
@@ -1079,6 +1129,13 @@ async function startRun(passed, declared) {
|
|
|
1079
1129
|
}
|
|
1080
1130
|
if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
|
|
1081
1131
|
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
1132
|
+
const files = [stats.screenshots, stats.textFiles, stats.otherFiles];
|
|
1133
|
+
const total = (what) => files.reduce((n, counts) => n + counts[what], 0);
|
|
1134
|
+
if (total("uploaded") + total("skipped") + total("failed") > 0) {
|
|
1135
|
+
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)`
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1082
1139
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
1083
1140
|
if (stats.deferred > 0 && process.env["GITHUB_ACTIONS"] === "true") {
|
|
1084
1141
|
log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
|
package/dist/reporter-core.d.cts
CHANGED
|
@@ -326,6 +326,10 @@ 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 4). */
|
|
330
|
+
uploadConcurrency?: number;
|
|
331
|
+
/** Retries of a request the platform or a bucket asked to wait for (`PLUNE_UPLOAD_RETRIES`, 0 to 20, default 6). */
|
|
332
|
+
uploadRetries?: number;
|
|
329
333
|
/** Where unsent batches go. Defaults beside the run file the CLI already writes. */
|
|
330
334
|
fallbackPath?: string;
|
|
331
335
|
/**
|
|
@@ -427,6 +431,8 @@ interface EnvSettings {
|
|
|
427
431
|
keepOpen: boolean;
|
|
428
432
|
/** Variables that were set and have nowhere to land. The caller says so once. */
|
|
429
433
|
ignored: string[];
|
|
434
|
+
/** Variables that were set to something unusable and fell back to their default. The caller says so once. */
|
|
435
|
+
warnings: string[];
|
|
430
436
|
}
|
|
431
437
|
declare function readEnv(env?: NodeJS.ProcessEnv): EnvSettings;
|
|
432
438
|
/**
|
|
@@ -582,9 +588,18 @@ interface ClientOptions {
|
|
|
582
588
|
fetchImpl?: typeof fetch;
|
|
583
589
|
/** Test seam — the same idea as `fetchImpl`, so a retry test costs milliseconds, not seconds. */
|
|
584
590
|
wait?: (ms: number) => Promise<void>;
|
|
591
|
+
/** Retries of a file's requests (default 2 for the platform's, 3 for a bucket's): what `uploadRetries` is. */
|
|
592
|
+
uploadRetries?: number;
|
|
593
|
+
/** Test seam for the jitter: a number in [0, 1). */
|
|
594
|
+
random?: () => number;
|
|
595
|
+
/** Test seam for the clock the shared pause is read against. */
|
|
596
|
+
now?: () => number;
|
|
597
|
+
/** Called once for every retry that is about to wait. */
|
|
598
|
+
onRetry?: () => void;
|
|
585
599
|
}
|
|
586
600
|
interface PlatformClient {
|
|
587
|
-
|
|
601
|
+
/** `retries` after the first try: the default is 2; a file's requests ask for more. */
|
|
602
|
+
post<T>(path: string, body: unknown, retries?: number): Promise<ClientOutcome<T>>;
|
|
588
603
|
/**
|
|
589
604
|
* A file's bytes as the body, under the file's own type (platform ADR 0040) — not JSON. Through the
|
|
590
605
|
* same retries as a POST, so a 429 on the platform's upload limit waits out its `Retry-After`.
|
package/dist/reporter-core.d.ts
CHANGED
|
@@ -326,6 +326,10 @@ 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 4). */
|
|
330
|
+
uploadConcurrency?: number;
|
|
331
|
+
/** Retries of a request the platform or a bucket asked to wait for (`PLUNE_UPLOAD_RETRIES`, 0 to 20, default 6). */
|
|
332
|
+
uploadRetries?: number;
|
|
329
333
|
/** Where unsent batches go. Defaults beside the run file the CLI already writes. */
|
|
330
334
|
fallbackPath?: string;
|
|
331
335
|
/**
|
|
@@ -427,6 +431,8 @@ interface EnvSettings {
|
|
|
427
431
|
keepOpen: boolean;
|
|
428
432
|
/** Variables that were set and have nowhere to land. The caller says so once. */
|
|
429
433
|
ignored: string[];
|
|
434
|
+
/** Variables that were set to something unusable and fell back to their default. The caller says so once. */
|
|
435
|
+
warnings: string[];
|
|
430
436
|
}
|
|
431
437
|
declare function readEnv(env?: NodeJS.ProcessEnv): EnvSettings;
|
|
432
438
|
/**
|
|
@@ -582,9 +588,18 @@ interface ClientOptions {
|
|
|
582
588
|
fetchImpl?: typeof fetch;
|
|
583
589
|
/** Test seam — the same idea as `fetchImpl`, so a retry test costs milliseconds, not seconds. */
|
|
584
590
|
wait?: (ms: number) => Promise<void>;
|
|
591
|
+
/** Retries of a file's requests (default 2 for the platform's, 3 for a bucket's): what `uploadRetries` is. */
|
|
592
|
+
uploadRetries?: number;
|
|
593
|
+
/** Test seam for the jitter: a number in [0, 1). */
|
|
594
|
+
random?: () => number;
|
|
595
|
+
/** Test seam for the clock the shared pause is read against. */
|
|
596
|
+
now?: () => number;
|
|
597
|
+
/** Called once for every retry that is about to wait. */
|
|
598
|
+
onRetry?: () => void;
|
|
585
599
|
}
|
|
586
600
|
interface PlatformClient {
|
|
587
|
-
|
|
601
|
+
/** `retries` after the first try: the default is 2; a file's requests ask for more. */
|
|
602
|
+
post<T>(path: string, body: unknown, retries?: number): Promise<ClientOutcome<T>>;
|
|
588
603
|
/**
|
|
589
604
|
* A file's bytes as the body, under the file's own type (platform ADR 0040) — not JSON. Through the
|
|
590
605
|
* same retries as a POST, so a 429 on the platform's upload limit waits out its `Retry-After`.
|
package/dist/reporter-core.js
CHANGED
|
@@ -35,7 +35,17 @@ import { createReadStream, openAsBlob } from "fs";
|
|
|
35
35
|
import { pipeline } from "stream";
|
|
36
36
|
import { request as httpRequest } from "http";
|
|
37
37
|
import { request as httpsRequest } from "https";
|
|
38
|
-
var
|
|
38
|
+
var BACKOFF_BASE_MS = 300;
|
|
39
|
+
var BACKOFF_CAP_MS = 1e4;
|
|
40
|
+
var DEFAULT_RETRIES = 2;
|
|
41
|
+
var RETRY_AFTER_CAP_MS = 6e4;
|
|
42
|
+
function parseRetryAfter(raw, now = Date.now()) {
|
|
43
|
+
const text = raw?.trim();
|
|
44
|
+
if (text === void 0 || text === "") return null;
|
|
45
|
+
const seconds = /^\d+(\.\d+)?$/.test(text) ? Number(text) : (Date.parse(text) - now) / 1e3;
|
|
46
|
+
if (!Number.isFinite(seconds)) return null;
|
|
47
|
+
return Math.min(Math.max(seconds, 0) * 1e3, RETRY_AFTER_CAP_MS);
|
|
48
|
+
}
|
|
39
49
|
var IDLE_MS = 3e5;
|
|
40
50
|
function nodeTransport(url, init, idleMs = IDLE_MS) {
|
|
41
51
|
return new Promise((resolve2, reject) => {
|
|
@@ -66,7 +76,6 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
|
|
|
66
76
|
req.end(init.body);
|
|
67
77
|
});
|
|
68
78
|
}
|
|
69
|
-
var PUT_DELAYS_MS = [500, 1500, 4e3];
|
|
70
79
|
var withoutLength = (headers) => Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-length"));
|
|
71
80
|
function nodePut(url, headers, source, idleMs) {
|
|
72
81
|
return new Promise((resolve2, reject) => {
|
|
@@ -111,12 +120,6 @@ async function detailOf(res) {
|
|
|
111
120
|
return { detail: "" };
|
|
112
121
|
}
|
|
113
122
|
}
|
|
114
|
-
function retryAfterMs(res) {
|
|
115
|
-
const raw = res.headers.get("retry-after");
|
|
116
|
-
if (raw === null) return null;
|
|
117
|
-
const seconds = Number(raw.trim());
|
|
118
|
-
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : null;
|
|
119
|
-
}
|
|
120
123
|
function classify(status) {
|
|
121
124
|
if (status === 401 || status === 403) return "auth";
|
|
122
125
|
if (status === 409) return "conflict";
|
|
@@ -127,15 +130,34 @@ function classify(status) {
|
|
|
127
130
|
function createClient(opts) {
|
|
128
131
|
const doFetch = opts.fetchImpl ?? nodeTransport;
|
|
129
132
|
const wait = opts.wait ?? sleep;
|
|
133
|
+
const uploadRetries = opts.uploadRetries === void 0 ? void 0 : Math.max(0, Math.floor(opts.uploadRetries));
|
|
134
|
+
const random = opts.random ?? Math.random;
|
|
135
|
+
const now = opts.now ?? Date.now;
|
|
136
|
+
let pausedUntil = 0;
|
|
137
|
+
const jittered = (ms) => Math.round(ms * (1 + random() * 0.5));
|
|
138
|
+
async function backOff(attempt, said) {
|
|
139
|
+
opts.onRetry?.();
|
|
140
|
+
const ms = jittered(said ?? Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
|
|
141
|
+
const until = now() + ms;
|
|
142
|
+
pausedUntil = Math.max(pausedUntil, until);
|
|
143
|
+
await wait(ms);
|
|
144
|
+
return until;
|
|
145
|
+
}
|
|
146
|
+
async function respectPause(mine) {
|
|
147
|
+
const left = pausedUntil - now();
|
|
148
|
+
if (pausedUntil > mine && left > 0) await wait(left + Math.round(random() * 250));
|
|
149
|
+
}
|
|
130
150
|
const base = opts.apiUrl.replace(/\/+$/, "");
|
|
131
151
|
const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
|
|
132
|
-
async function send(method, path3, payload) {
|
|
152
|
+
async function send(method, path3, payload, retries = DEFAULT_RETRIES) {
|
|
133
153
|
let last = {
|
|
134
154
|
kind: "unavailable",
|
|
135
155
|
status: null,
|
|
136
156
|
detail: "no attempt was made"
|
|
137
157
|
};
|
|
138
|
-
|
|
158
|
+
let mine = 0;
|
|
159
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
160
|
+
await respectPause(mine);
|
|
139
161
|
let res;
|
|
140
162
|
try {
|
|
141
163
|
res = await doFetch(`${base}${path3}`, {
|
|
@@ -148,7 +170,8 @@ function createClient(opts) {
|
|
|
148
170
|
});
|
|
149
171
|
} catch (err) {
|
|
150
172
|
last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
|
|
151
|
-
if (attempt
|
|
173
|
+
if (attempt >= Math.min(retries, DEFAULT_RETRIES)) break;
|
|
174
|
+
mine = await backOff(attempt, null);
|
|
152
175
|
continue;
|
|
153
176
|
}
|
|
154
177
|
if (res.ok) {
|
|
@@ -163,25 +186,28 @@ function createClient(opts) {
|
|
|
163
186
|
const kind = classify(res.status);
|
|
164
187
|
last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
|
|
165
188
|
if (kind !== "unavailable") break;
|
|
166
|
-
|
|
189
|
+
const said429 = parseRetryAfter(res.headers.get("retry-after"), now());
|
|
190
|
+
if (res.status === 429 && said429 === null) {
|
|
167
191
|
last.kind = "invalid";
|
|
168
192
|
last.detail = "quota exceeded (no Retry-After)";
|
|
169
193
|
if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
|
|
170
194
|
break;
|
|
171
195
|
}
|
|
172
|
-
if (attempt
|
|
173
|
-
|
|
174
|
-
}
|
|
196
|
+
if (attempt >= (said429 === null ? Math.min(retries, DEFAULT_RETRIES) : retries)) break;
|
|
197
|
+
mine = await backOff(attempt, said429);
|
|
175
198
|
}
|
|
176
199
|
return { ok: false, ...last };
|
|
177
200
|
}
|
|
178
201
|
return {
|
|
179
|
-
post: (path3, body) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }),
|
|
180
|
-
upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }),
|
|
202
|
+
post: (path3, body, retries) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }, retries),
|
|
203
|
+
upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
|
|
181
204
|
del: (path3) => send("DELETE", path3),
|
|
182
205
|
async put(url, headers, source) {
|
|
206
|
+
const putRetries = uploadRetries ?? 3;
|
|
183
207
|
let last = { ok: false, status: null, code: "network" };
|
|
184
|
-
|
|
208
|
+
let mine = 0;
|
|
209
|
+
for (let attempt = 0; attempt <= putRetries; attempt += 1) {
|
|
210
|
+
await respectPause(mine);
|
|
185
211
|
try {
|
|
186
212
|
let status;
|
|
187
213
|
let retryAfter;
|
|
@@ -196,15 +222,15 @@ function createClient(opts) {
|
|
|
196
222
|
if (status >= 200 && status <= 299) return { ok: true };
|
|
197
223
|
last = { ok: false, status, code: `bucket_${status}` };
|
|
198
224
|
if (status < 500 && status !== 429) return last;
|
|
199
|
-
const
|
|
200
|
-
if (attempt
|
|
201
|
-
|
|
202
|
-
}
|
|
225
|
+
const said = parseRetryAfter(retryAfter, now());
|
|
226
|
+
if (attempt >= (said === null ? Math.min(putRetries, 3) : putRetries)) return last;
|
|
227
|
+
mine = await backOff(attempt, said);
|
|
203
228
|
continue;
|
|
204
229
|
} catch (err) {
|
|
205
230
|
last = { ok: false, status: null, code: codeOfError(err) };
|
|
206
231
|
}
|
|
207
|
-
if (attempt
|
|
232
|
+
if (attempt >= Math.min(putRetries, 3)) return last;
|
|
233
|
+
mine = await backOff(attempt, null);
|
|
208
234
|
}
|
|
209
235
|
return last;
|
|
210
236
|
}
|
|
@@ -214,6 +240,8 @@ function createClient(opts) {
|
|
|
214
240
|
// src/reporter-core/env.ts
|
|
215
241
|
import * as path from "path";
|
|
216
242
|
var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
|
|
243
|
+
var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 4 };
|
|
244
|
+
var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
|
|
217
245
|
function value(env, name) {
|
|
218
246
|
const raw = env[name]?.trim();
|
|
219
247
|
return raw === void 0 || raw === "" ? void 0 : raw;
|
|
@@ -228,6 +256,14 @@ function count(env, name) {
|
|
|
228
256
|
const n = Number(raw);
|
|
229
257
|
return Number.isInteger(n) && n > 0 ? n : void 0;
|
|
230
258
|
}
|
|
259
|
+
function bounded(env, name, range, warnings) {
|
|
260
|
+
const raw = value(env, name);
|
|
261
|
+
if (raw === void 0) return void 0;
|
|
262
|
+
const n = Number(raw);
|
|
263
|
+
if (/^\d+$/.test(raw) && n >= range.min && n <= range.max) return n;
|
|
264
|
+
warnings.push(`${name}=${JSON.stringify(raw.slice(0, 40))} is not a whole number from ${range.min} to ${range.max} \u2014 using ${range.default}.`);
|
|
265
|
+
return void 0;
|
|
266
|
+
}
|
|
231
267
|
function list(env, name) {
|
|
232
268
|
const raw = value(env, name);
|
|
233
269
|
if (raw === void 0) return void 0;
|
|
@@ -240,6 +276,9 @@ function readEnv(env = process.env) {
|
|
|
240
276
|
const apiUrl = value(env, "PLUNE_API_URL");
|
|
241
277
|
const fallbackPath = value(env, "PLUNE_FALLBACK");
|
|
242
278
|
const batchSize = count(env, "PLUNE_BATCH_SIZE");
|
|
279
|
+
const warnings = [];
|
|
280
|
+
const uploadConcurrency = bounded(env, "PLUNE_UPLOAD_CONCURRENCY", UPLOAD_CONCURRENCY, warnings);
|
|
281
|
+
const uploadRetries = bounded(env, "PLUNE_UPLOAD_RETRIES", UPLOAD_RETRIES, warnings);
|
|
243
282
|
const title = value(env, "PLUNE_RUN_TITLE");
|
|
244
283
|
const environment = value(env, "PLUNE_ENV");
|
|
245
284
|
const labels = list(env, "PLUNE_LABELS");
|
|
@@ -252,6 +291,8 @@ function readEnv(env = process.env) {
|
|
|
252
291
|
...apiUrl !== void 0 ? { apiUrl } : {},
|
|
253
292
|
...fallbackPath !== void 0 ? { fallbackPath } : {},
|
|
254
293
|
...batchSize !== void 0 ? { batchSize } : {},
|
|
294
|
+
...uploadConcurrency !== void 0 ? { uploadConcurrency } : {},
|
|
295
|
+
...uploadRetries !== void 0 ? { uploadRetries } : {},
|
|
255
296
|
...title !== void 0 ? { title } : {},
|
|
256
297
|
...environment !== void 0 ? { environment } : {},
|
|
257
298
|
...labels !== void 0 ? { labels } : {},
|
|
@@ -264,7 +305,8 @@ function readEnv(env = process.env) {
|
|
|
264
305
|
// reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
|
|
265
306
|
// way this process must not be the one to call it finished.
|
|
266
307
|
keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED"),
|
|
267
|
-
ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0)
|
|
308
|
+
ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
|
|
309
|
+
warnings
|
|
268
310
|
};
|
|
269
311
|
}
|
|
270
312
|
function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cwd = process.cwd()) {
|
|
@@ -450,7 +492,7 @@ function appendBatch(file, batch) {
|
|
|
450
492
|
var RESOLVE_CHUNK = 500;
|
|
451
493
|
var BATCH_MAX = 500;
|
|
452
494
|
var DISCOVER_MAX = 500;
|
|
453
|
-
var BATCH_DEFAULT =
|
|
495
|
+
var BATCH_DEFAULT = 500;
|
|
454
496
|
var EXPECTED_MAX = 1e4;
|
|
455
497
|
var KEY_MAX = 1024;
|
|
456
498
|
var TITLE_MAX = 300;
|
|
@@ -468,7 +510,6 @@ var TYPE_BY_EXTENSION = {
|
|
|
468
510
|
".webp": "image/webp"
|
|
469
511
|
};
|
|
470
512
|
var METADATA_TYPE2 = "application/plune.metadata+json";
|
|
471
|
-
var UPLOADS_AT_ONCE = 4;
|
|
472
513
|
var NAME_MAX = 200;
|
|
473
514
|
function nameOf(file) {
|
|
474
515
|
const own = file.name.split(/[\\/]/).pop()?.trim() || (file.path === void 0 ? "" : basename2(file.path));
|
|
@@ -599,9 +640,15 @@ async function startRun(passed, declared) {
|
|
|
599
640
|
const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
|
|
600
641
|
const offerDiscovered = cfg.offerDiscovered ?? false;
|
|
601
642
|
const token = cfg.token ?? loadToken() ?? "";
|
|
643
|
+
const uploadsAtOnce = Math.min(Math.max(Math.floor(cfg.uploadConcurrency ?? UPLOAD_CONCURRENCY.default), UPLOAD_CONCURRENCY.min), UPLOAD_CONCURRENCY.max);
|
|
644
|
+
const uploadRetries = Math.min(Math.max(Math.floor(cfg.uploadRetries ?? UPLOAD_RETRIES.default), UPLOAD_RETRIES.min), UPLOAD_RETRIES.max);
|
|
645
|
+
let retried = 0;
|
|
646
|
+
let uploadMs = 0;
|
|
602
647
|
const client = createClient({
|
|
603
648
|
apiUrl: resolveApiUrl(cfg.apiUrl),
|
|
604
649
|
token,
|
|
650
|
+
uploadRetries,
|
|
651
|
+
onRetry: () => void (retried += 1),
|
|
605
652
|
...cfg.fetchImpl !== void 0 ? { fetchImpl: cfg.fetchImpl } : {}
|
|
606
653
|
});
|
|
607
654
|
const stats = {
|
|
@@ -652,6 +699,7 @@ async function startRun(passed, declared) {
|
|
|
652
699
|
if (offline) {
|
|
653
700
|
log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
|
|
654
701
|
}
|
|
702
|
+
for (const warning of env.warnings) log(`plune: ${warning}`);
|
|
655
703
|
if (env.ignored.length > 0) {
|
|
656
704
|
log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
|
|
657
705
|
}
|
|
@@ -781,7 +829,7 @@ async function startRun(passed, declared) {
|
|
|
781
829
|
function ask(job, size) {
|
|
782
830
|
return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
|
|
783
831
|
files: [{ name: job.name, contentType: job.type, size }]
|
|
784
|
-
});
|
|
832
|
+
}, uploadRetries);
|
|
785
833
|
}
|
|
786
834
|
function failed(job, why) {
|
|
787
835
|
countsOf(job.kind).failed += 1;
|
|
@@ -842,11 +890,11 @@ async function startRun(passed, declared) {
|
|
|
842
890
|
if (fresh === void 0) return failedToBucket(job, "bad_answer");
|
|
843
891
|
link = fresh;
|
|
844
892
|
}
|
|
845
|
-
const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {});
|
|
893
|
+
const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {}, uploadRetries);
|
|
846
894
|
if (!done2.ok) return failedToBucket(job, codeOf(done2));
|
|
847
895
|
countsOf(job.kind).uploaded += 1;
|
|
848
896
|
}
|
|
849
|
-
let free =
|
|
897
|
+
let free = uploadsAtOnce;
|
|
850
898
|
const waiting = [];
|
|
851
899
|
async function gated(work) {
|
|
852
900
|
if (free === 0) await new Promise((resolve2) => waiting.push(resolve2));
|
|
@@ -926,6 +974,7 @@ async function startRun(passed, declared) {
|
|
|
926
974
|
}
|
|
927
975
|
}
|
|
928
976
|
}
|
|
977
|
+
const started = Date.now();
|
|
929
978
|
const byResult = /* @__PURE__ */ new Map();
|
|
930
979
|
for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
|
|
931
980
|
await Promise.all(
|
|
@@ -938,6 +987,7 @@ async function startRun(passed, declared) {
|
|
|
938
987
|
await Promise.all(work);
|
|
939
988
|
})
|
|
940
989
|
);
|
|
990
|
+
uploadMs += Date.now() - started;
|
|
941
991
|
}
|
|
942
992
|
async function flush() {
|
|
943
993
|
if (buffer.length === 0) return;
|
|
@@ -1030,6 +1080,13 @@ async function startRun(passed, declared) {
|
|
|
1030
1080
|
}
|
|
1031
1081
|
if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
|
|
1032
1082
|
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
1083
|
+
const files = [stats.screenshots, stats.textFiles, stats.otherFiles];
|
|
1084
|
+
const total = (what) => files.reduce((n, counts) => n + counts[what], 0);
|
|
1085
|
+
if (total("uploaded") + total("skipped") + total("failed") > 0) {
|
|
1086
|
+
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)`
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1033
1090
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
1034
1091
|
if (stats.deferred > 0 && process.env["GITHUB_ACTIONS"] === "true") {
|
|
1035
1092
|
log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
|
package/package.json
CHANGED