@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 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
- for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
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 < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[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
- if (res.status === 429 && retryAfterMs2(res) === null) {
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 < RETRY_DELAYS_MS.length) {
2433
- await wait(retryAfterMs2(res) ?? RETRY_DELAYS_MS[attempt]);
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
- for (let attempt = 0; attempt <= PUT_DELAYS_MS.length; attempt += 1) {
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 seconds = retryAfter === null ? NaN : Number(retryAfter.trim());
2460
- if (attempt < PUT_DELAYS_MS.length) {
2461
- await wait(Number.isFinite(seconds) && seconds >= 0 ? Math.min(seconds, 60) * 1e3 : PUT_DELAYS_MS[attempt]);
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 < PUT_DELAYS_MS.length) await wait(PUT_DELAYS_MS[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, RETRY_DELAYS_MS, IDLE_MS, PUT_DELAYS_MS, withoutLength, codeOfError, sleep;
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
- RETRY_DELAYS_MS = [300, 900];
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 = UPLOADS_AT_ONCE;
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, UPLOADS_AT_ONCE, NAME_MAX, SKIPPED, BUCKET_FOUND, KINDS, codeOf2, BATCH_BYTES, ENVELOPE_BYTES;
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 = 100;
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;
@@ -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 RETRY_DELAYS_MS = [300, 900];
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
- for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
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 < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[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
- if (res.status === 429 && retryAfterMs(res) === null) {
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 < RETRY_DELAYS_MS.length) {
222
- await wait(retryAfterMs(res) ?? RETRY_DELAYS_MS[attempt]);
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
- for (let attempt = 0; attempt <= PUT_DELAYS_MS.length; attempt += 1) {
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 seconds = retryAfter === null ? NaN : Number(retryAfter.trim());
249
- if (attempt < PUT_DELAYS_MS.length) {
250
- await wait(Number.isFinite(seconds) && seconds >= 0 ? Math.min(seconds, 60) * 1e3 : PUT_DELAYS_MS[attempt]);
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 < PUT_DELAYS_MS.length) await wait(PUT_DELAYS_MS[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 = 100;
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 = UPLOADS_AT_ONCE;
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.`);
@@ -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
- post<T>(path: string, body: unknown): Promise<ClientOutcome<T>>;
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`.
@@ -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
- post<T>(path: string, body: unknown): Promise<ClientOutcome<T>>;
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`.
@@ -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 RETRY_DELAYS_MS = [300, 900];
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
- for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
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 < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[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
- if (res.status === 429 && retryAfterMs(res) === null) {
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 < RETRY_DELAYS_MS.length) {
173
- await wait(retryAfterMs(res) ?? RETRY_DELAYS_MS[attempt]);
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
- for (let attempt = 0; attempt <= PUT_DELAYS_MS.length; attempt += 1) {
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 seconds = retryAfter === null ? NaN : Number(retryAfter.trim());
200
- if (attempt < PUT_DELAYS_MS.length) {
201
- await wait(Number.isFinite(seconds) && seconds >= 0 ? Math.min(seconds, 60) * 1e3 : PUT_DELAYS_MS[attempt]);
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 < PUT_DELAYS_MS.length) await wait(PUT_DELAYS_MS[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 = 100;
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 = UPLOADS_AT_ONCE;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "AI-powered assertion testing for LLM apps — write assertions, run them against any provider, get a pass/fail report (local, CI, or diff).",
5
5
  "keywords": [
6
6
  "ai",