@plune-ai/cli 0.20.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 +22 -1
- package/dist/cli.cjs +809 -659
- 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/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