@plune-ai/cli 0.16.0 → 0.17.1

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.
@@ -2,6 +2,7 @@
2
2
  import { createHash, randomUUID } from "crypto";
3
3
  import { promises as disk } from "fs";
4
4
  import { basename as basename2, extname } from "path";
5
+ import { TextDecoder } from "util";
5
6
 
6
7
  // src/cli/api-url.ts
7
8
  var DEFAULT_API_URL = "https://beta-api.plune.ai";
@@ -30,6 +31,8 @@ function loadToken() {
30
31
  }
31
32
 
32
33
  // src/reporter-core/client.ts
34
+ import { createReadStream, openAsBlob } from "fs";
35
+ import { pipeline } from "stream";
33
36
  import { request as httpRequest } from "http";
34
37
  import { request as httpsRequest } from "https";
35
38
  var RETRY_DELAYS_MS = [300, 900];
@@ -63,13 +66,49 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
63
66
  req.end(init.body);
64
67
  });
65
68
  }
69
+ var PUT_DELAYS_MS = [500, 1500, 4e3];
70
+ var withoutLength = (headers) => Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-length"));
71
+ function nodePut(url, headers, source, idleMs) {
72
+ return new Promise((resolve2, reject) => {
73
+ const target = new URL(url);
74
+ const send = target.protocol === "https:" ? httpsRequest : httpRequest;
75
+ const length = "body" in source ? source.body.length : source.size;
76
+ let answered;
77
+ const settle = (err) => answered !== void 0 ? resolve2(answered) : reject(err);
78
+ const req = send(
79
+ target,
80
+ { method: "PUT", headers: { ...withoutLength(headers), "content-length": String(length) } },
81
+ (res) => {
82
+ const retry = res.headers["retry-after"];
83
+ answered = { status: res.statusCode ?? 0, retryAfter: Array.isArray(retry) ? retry[0] ?? null : retry ?? null };
84
+ res.on("error", settle);
85
+ res.on("close", () => {
86
+ resolve2(answered);
87
+ req.destroy();
88
+ });
89
+ res.resume();
90
+ }
91
+ );
92
+ req.setTimeout(idleMs, () => req.destroy(Object.assign(new Error("idle"), { code: "ETIMEDOUT" })));
93
+ req.on("error", settle);
94
+ if ("body" in source) req.end(source.body);
95
+ else pipeline(createReadStream(source.path), req, (err) => err ? settle(err) : void 0);
96
+ });
97
+ }
98
+ var codeOfError = (err) => {
99
+ const code = err?.code;
100
+ return typeof code === "string" && /^[A-Z0-9_]{2,40}$/.test(code) ? code : "network";
101
+ };
66
102
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
67
103
  async function detailOf(res) {
68
104
  try {
69
105
  const body = await res.json();
70
- return typeof body.error === "string" ? body.error : "";
106
+ return {
107
+ detail: typeof body.error === "string" ? body.error : "",
108
+ ...typeof body.code === "string" && /^[a-z0-9_]{1,60}$/.test(body.code) ? { code: body.code } : {}
109
+ };
71
110
  } catch {
72
- return "";
111
+ return { detail: "" };
73
112
  }
74
113
  }
75
114
  function retryAfterMs(res) {
@@ -120,10 +159,16 @@ function createClient(opts) {
120
159
  return { ok: false, kind: "other", status: res.status, detail: "the answer was not JSON" };
121
160
  }
122
161
  }
123
- const detail = scrub(await detailOf(res));
162
+ const said = await detailOf(res);
124
163
  const kind = classify(res.status);
125
- last = { kind, status: res.status, detail };
164
+ last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
126
165
  if (kind !== "unavailable") break;
166
+ if (res.status === 429 && retryAfterMs(res) === null) {
167
+ last.kind = "invalid";
168
+ last.detail = "quota exceeded (no Retry-After)";
169
+ if (said.detail) last.detail += ` \u2014 ${said.detail}`;
170
+ break;
171
+ }
127
172
  if (attempt < RETRY_DELAYS_MS.length) {
128
173
  await wait(retryAfterMs(res) ?? RETRY_DELAYS_MS[attempt]);
129
174
  }
@@ -133,7 +178,36 @@ function createClient(opts) {
133
178
  return {
134
179
  post: (path3, body) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }),
135
180
  upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }),
136
- del: (path3) => send("DELETE", path3)
181
+ del: (path3) => send("DELETE", path3),
182
+ async put(url, headers, source) {
183
+ let last = { ok: false, status: null, code: "network" };
184
+ for (let attempt = 0; attempt <= PUT_DELAYS_MS.length; attempt += 1) {
185
+ try {
186
+ let status;
187
+ let retryAfter;
188
+ if (opts.fetchImpl === void 0) ({ status, retryAfter } = await nodePut(url, headers, source, IDLE_MS));
189
+ else {
190
+ const body = "body" in source ? source.body : await openAsBlob(source.path);
191
+ const res = await opts.fetchImpl(url, { method: "PUT", headers: withoutLength(headers), body });
192
+ await res.arrayBuffer().catch(() => void 0);
193
+ status = res.status;
194
+ retryAfter = res.headers.get("retry-after");
195
+ }
196
+ if (status >= 200 && status <= 299) return { ok: true };
197
+ last = { ok: false, status, code: `bucket_${status}` };
198
+ 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
+ }
203
+ continue;
204
+ } catch (err) {
205
+ last = { ok: false, status: null, code: codeOfError(err) };
206
+ }
207
+ if (attempt < PUT_DELAYS_MS.length) await wait(PUT_DELAYS_MS[attempt]);
208
+ }
209
+ return last;
210
+ }
137
211
  };
138
212
  }
139
213
 
@@ -222,6 +296,138 @@ function ciCommit(reported, env = process.env) {
222
296
  };
223
297
  }
224
298
 
299
+ // src/reporter-core/failure-detail.ts
300
+ import { existsSync as existsSync2 } from "fs";
301
+ import { homedir as homedir2 } from "os";
302
+ import { dirname as dirname2, join as join2, posix, resolve } from "path";
303
+ import { stripVTControlCharacters } from "util";
304
+ function failureOf(attempt, home = homedir2()) {
305
+ const error = chosen(attempt);
306
+ const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
307
+ const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
308
+ const steps = chainOf(attempt.steps);
309
+ const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
310
+ const artifacts = attempt.attachments.filter((a) => a.path !== void 0 && !a.name.startsWith("_") && a.contentType !== METADATA_TYPE).map(({ name, contentType }) => ({ name: fileNameOf(name), ...contentType ? { contentType } : {} })).filter((a) => a.name !== "");
311
+ const ciUrl = webLink(attempt.buildHref);
312
+ const failure = {
313
+ ...headline !== void 0 ? { headline } : {},
314
+ ...steps.length > 0 ? { steps } : {},
315
+ ...location !== void 0 ? { location } : {},
316
+ ...artifacts.length > 0 ? { artifacts } : {},
317
+ ...ciUrl !== void 0 ? { ciUrl } : {}
318
+ };
319
+ return Object.keys(failure).length === 0 ? void 0 : failure;
320
+ }
321
+ function repoRootOf(dir, stopAt) {
322
+ for (let here = resolve(dir); ; here = dirname2(here)) {
323
+ if (existsSync2(join2(here, ".git"))) return here;
324
+ if (here === stopAt || dirname2(here) === here) return void 0;
325
+ }
326
+ }
327
+ function errorContextOf(attempt, home = homedir2()) {
328
+ const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
329
+ return cut(machineless(text, attempt.repoRoot, home));
330
+ }
331
+ var printable = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
332
+ var mediaTypeOf = (contentType) => contentType?.split(";")[0]?.trim().toLowerCase() || void 0;
333
+ var TEXT_TYPES = /* @__PURE__ */ new Set(["application/json", "text/plain"]);
334
+ var isTextType = (contentType) => TEXT_TYPES.has(mediaTypeOf(contentType) ?? "");
335
+ function filesOf(attempt) {
336
+ return attempt.attachments.filter((a) => a.path !== void 0 || a.body !== void 0 && isTextType(a.contentType));
337
+ }
338
+ function machineless(raw, repoRoot, home) {
339
+ let text = stripVTControlCharacters(raw).replaceAll("\x1B", "");
340
+ const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
341
+ if (root !== void 0) text = text.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
342
+ const own = pathIn(home);
343
+ if (own !== void 0) text = text.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
344
+ if (repoRoot === void 0) text = text.replace(ANY_HOME, "~");
345
+ return text;
346
+ }
347
+ var PATH_START = "(?<![\\w.~%/\\\\-])";
348
+ var TEXT_LIMIT = 524288;
349
+ var HEADLINE_LIMIT = 8 * 1024;
350
+ var jsonBytes = (text) => Buffer.byteLength(JSON.stringify(text)) - 2;
351
+ var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:(?:file|webpack|webpack-internal):(?:\\*\/){2,3})?(?:\\*\/home\\*\/|\\*\/Users\\*\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
352
+ function pathIn(path3) {
353
+ const parts = path3.split(/[\\/]+/);
354
+ while (parts.length > 1 && parts.at(-1) === "") parts.pop();
355
+ if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
356
+ const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
357
+ const escaped = /^\/(?!\/)/.test(path3) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
358
+ const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
359
+ return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
360
+ }
361
+ var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
362
+ function cut(text) {
363
+ if (jsonBytes(text) <= TEXT_LIMIT) return text;
364
+ const lines = text.split("\n");
365
+ const size = lines.map((line) => jsonBytes(line) + 2);
366
+ const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
367
+ let used = 0;
368
+ let head = 0;
369
+ let tail = lines.length;
370
+ while (head < tail && used + size[head] <= budget / 2) used += size[head++];
371
+ while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
372
+ return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
373
+ }
374
+ var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
375
+ var METADATA_TYPE = "application/plune.metadata+json";
376
+ var REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
377
+ var FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
378
+ var FRAME_MAX = 8192;
379
+ var firstLine = (text) => stripVTControlCharacters(text).split("\n").map((line) => line.trim()).find((line) => line !== "");
380
+ function chosen({ status, errors }) {
381
+ if (status === "timedOut") {
382
+ const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
383
+ if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
384
+ }
385
+ return errors[0];
386
+ }
387
+ function chainOf(steps) {
388
+ const chain = [];
389
+ for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
390
+ chain.push(failed.title);
391
+ }
392
+ return chain;
393
+ }
394
+ function placeOf(error, testFile) {
395
+ const own = normal(testFile);
396
+ let first;
397
+ for (const line of stripVTControlCharacters(error.text).split("\n")) {
398
+ if (line.length > FRAME_MAX) continue;
399
+ const frame = FRAME.exec(line);
400
+ if (frame === null) continue;
401
+ const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
402
+ if (normal(place.file) === own) return place;
403
+ if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
404
+ }
405
+ return error.location ?? first;
406
+ }
407
+ function fileOf(raw) {
408
+ if (!raw.startsWith("file://")) return raw;
409
+ let path3 = raw.slice("file://".length);
410
+ try {
411
+ path3 = decodeURIComponent(path3);
412
+ } catch {
413
+ }
414
+ return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
415
+ }
416
+ var normal = (path3) => posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
417
+ function fromRoot(place, root) {
418
+ if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
419
+ const base = normal(root).replace(/\/+$/, "");
420
+ const file = normal(place.file);
421
+ if (!file.startsWith(`${base}/`)) return void 0;
422
+ const relative = file.slice(base.length + 1);
423
+ if (!REPO_RELATIVE.test(relative)) return void 0;
424
+ const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
425
+ return column === void 0 ? { file: relative, line: place.line } : { file: relative, line: place.line, column };
426
+ }
427
+ function webLink(href) {
428
+ return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
429
+ }
430
+
225
431
  // src/reporter-core/fallback.ts
226
432
  import * as fs from "fs";
227
433
  import * as path2 from "path";
@@ -252,17 +458,21 @@ var COUNTED = ["accepted", "duplicate", "conflict", "rejected"];
252
458
  var SCREENSHOT_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
253
459
  var SCREENSHOT_BYTES = 2 * 1024 * 1024;
254
460
  var SCREENSHOTS_PER_RESULT = 20;
461
+ var TEXT_BYTES = 512 * 1024;
462
+ var TEXTS_PER_RESULT = 10;
463
+ var UTF8 = new TextDecoder("utf-8", { fatal: true });
255
464
  var TYPE_BY_EXTENSION = {
256
465
  ".png": "image/png",
257
466
  ".jpg": "image/jpeg",
258
467
  ".jpeg": "image/jpeg",
259
468
  ".webp": "image/webp"
260
469
  };
470
+ var METADATA_TYPE2 = "application/plune.metadata+json";
261
471
  var UPLOADS_AT_ONCE = 4;
262
472
  var NAME_MAX = 200;
263
473
  function nameOf(file) {
264
- const own = file.name.split(/[\\/]/).pop()?.trim() || basename2(file.path);
265
- const fileExt = extname(file.path);
474
+ const own = file.name.split(/[\\/]/).pop()?.trim() || (file.path === void 0 ? "" : basename2(file.path));
475
+ const fileExt = file.path === void 0 ? "" : extname(file.path);
266
476
  const ownExt = extname(own);
267
477
  const typed = ownExt !== "" && (ownExt.toLowerCase() === fileExt.toLowerCase() || TYPE_BY_EXTENSION[ownExt.toLowerCase()] !== void 0);
268
478
  const ext = typed ? ownExt : fileExt;
@@ -278,8 +488,57 @@ var SKIPPED = {
278
488
  type: "Plune takes PNG, JPEG and WebP",
279
489
  limit: `a result holds at most ${SCREENSHOTS_PER_RESULT}`,
280
490
  missing: "the file cannot be read on this machine",
281
- size: "Plune takes a file of at most 2 MB"
491
+ size: "Plune takes a file of at most 2 MB",
492
+ textLimit: `a result holds at most ${TEXTS_PER_RESULT} text files`,
493
+ textSize: "Plune takes a text file of at most 512 KiB",
494
+ empty: "the file is empty",
495
+ binary: "the file holds a NUL byte, and Plune takes UTF-8 text without one",
496
+ encoding: "Plune takes UTF-8 text only",
497
+ noStorage: "no storage bucket is connected to this project"
498
+ };
499
+ var BUCKET_FOUND = "plune: storage bucket found for this project \u2014 screenshots, text, trace and video go straight to it over short-lived links.";
500
+ var KINDS = {
501
+ screenshot: { label: "screenshot", bytes: SCREENSHOT_BYTES, room: SCREENSHOTS_PER_RESULT, limit: "limit", size: "size" },
502
+ text: { label: "text file", bytes: TEXT_BYTES, room: TEXTS_PER_RESULT, limit: "textLimit", size: "textSize" },
503
+ // Only the label is used: a file of this kind has no home in the database, so no ceiling of its own is known here.
504
+ file: { label: "file", bytes: 0, room: 0, limit: "limit", size: "size" }
282
505
  };
506
+ function kindOf(type) {
507
+ if (type.startsWith("image/")) return "screenshot";
508
+ return TEXT_TYPES.has(type) ? "text" : "file";
509
+ }
510
+ var codeOf = (out) => out.code ?? (out.status === null ? "network" : `http_${out.status}`);
511
+ function linkOf(item) {
512
+ const link = item;
513
+ if (typeof link?.id !== "string" || typeof link.url !== "string") return void 0;
514
+ const headers = typeof link.headers === "object" && link.headers !== null ? link.headers : {};
515
+ return { id: link.id, url: link.url, headers };
516
+ }
517
+ function textTypeOf(contentType, type) {
518
+ const charset = /;\s*charset\s*=\s*"?([^\s";]+)"?/i.exec(contentType)?.[1];
519
+ return charset?.toLowerCase() === "utf-8" ? `${type}; charset=utf-8` : type;
520
+ }
521
+ function textRefusal(bytes) {
522
+ if (bytes.length > TEXT_BYTES) return "textSize";
523
+ if (bytes.length === 0) return "empty";
524
+ if (bytes.includes(0)) return "binary";
525
+ try {
526
+ UTF8.decode(bytes);
527
+ } catch {
528
+ return "encoding";
529
+ }
530
+ return void 0;
531
+ }
532
+ function onDisk(result) {
533
+ if (!("files" in result) || result.files === void 0) return result;
534
+ const { files, ...rest } = result;
535
+ const kept = files.filter((f) => f.path !== void 0).map(({ body: _body, ...file }) => file);
536
+ return kept.length === 0 ? rest : { ...rest, files: kept };
537
+ }
538
+ function tally(counts, noun) {
539
+ if (counts.uploaded + counts.skipped + counts.failed === 0) return void 0;
540
+ return `${counts.uploaded} ${noun}(s) uploaded` + (counts.skipped > 0 ? `, ${counts.skipped} skipped` : "") + (counts.failed > 0 ? `, ${counts.failed} failed to upload` : "");
541
+ }
283
542
  function boundKey(key) {
284
543
  if (key.value.length <= KEY_MAX) return key;
285
544
  const digest = createHash("sha256").update(key.value).digest("hex").slice(0, 16);
@@ -355,7 +614,9 @@ async function startRun(passed, declared) {
355
614
  created: 0,
356
615
  unoffered: 0,
357
616
  deferred: 0,
358
- screenshots: { uploaded: 0, skipped: 0, failed: 0 }
617
+ screenshots: { uploaded: 0, skipped: 0, failed: 0 },
618
+ textFiles: { uploaded: 0, skipped: 0, failed: 0 },
619
+ otherFiles: { uploaded: 0, skipped: 0, failed: 0 }
359
620
  };
360
621
  const resolved = /* @__PURE__ */ new Map();
361
622
  let buffer = [];
@@ -366,6 +627,13 @@ async function startRun(passed, declared) {
366
627
  let done = false;
367
628
  const shortened = /* @__PURE__ */ new Set();
368
629
  const skipsSaid = /* @__PURE__ */ new Set();
630
+ const countsOf = (kind) => kind === "text" ? stats.textFiles : kind === "file" ? stats.otherFiles : stats.screenshots;
631
+ let bucketSeen = false;
632
+ let noStorage = 0;
633
+ let uploadsMissing = false;
634
+ const failedSaid = /* @__PURE__ */ new Set();
635
+ const codesSaid = /* @__PURE__ */ new Set();
636
+ const rooms = /* @__PURE__ */ new Map();
369
637
  function bound(keys) {
370
638
  return keys.map((key) => {
371
639
  const fit = boundKey(key);
@@ -373,7 +641,7 @@ async function startRun(passed, declared) {
373
641
  shortened.add(key.value);
374
642
  if (shortened.size === 1) {
375
643
  log(
376
- `plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 "${key.value.slice(0, 80)}\u2026". Give the test a shorter name; until then this is its identity.`
644
+ `plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 ${JSON.stringify(printable(key.value.slice(0, 80)))}\u2026 Give the test a shorter name; until then this is its identity.`
377
645
  );
378
646
  }
379
647
  }
@@ -393,7 +661,7 @@ async function startRun(passed, declared) {
393
661
  externalKey,
394
662
  session: sessionMarker,
395
663
  ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
396
- results
664
+ results: results.map(onDisk)
397
665
  });
398
666
  stats.deferred += results.length;
399
667
  }
@@ -501,57 +769,173 @@ async function startRun(passed, declared) {
501
769
  stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
502
770
  }
503
771
  }
504
- function skip(name, why) {
505
- stats.screenshots.skipped += 1;
506
- if (skipsSaid.has(why)) return;
507
- skipsSaid.add(why);
508
- log(`plune: screenshot "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
772
+ function skip(kind, name, why) {
773
+ countsOf(kind).skipped += 1;
774
+ if (why === "noStorage") noStorage += 1;
775
+ const said = why === "noStorage" ? why : `${kind}/${why}`;
776
+ if (skipsSaid.has(said)) return;
777
+ skipsSaid.add(said);
778
+ if (why === "noStorage") log(`plune: "${name}" not stored \u2014 ${SKIPPED[why]}.`);
779
+ else log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
509
780
  }
510
- async function uploadOne(shot) {
781
+ function ask(job, size) {
782
+ return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
783
+ files: [{ name: job.name, contentType: job.type, size }]
784
+ });
785
+ }
786
+ function failed(job, why) {
787
+ countsOf(job.kind).failed += 1;
788
+ if (!failedSaid.has(job.kind)) {
789
+ failedSaid.add(job.kind);
790
+ log(`plune: could not upload a ${KINDS[job.kind].label} (${why}) \u2014 its result is reported without it.`);
791
+ }
792
+ }
793
+ function failedToBucket(job, code) {
794
+ countsOf(job.kind).failed += 1;
795
+ if (codesSaid.has(code)) return;
796
+ codesSaid.add(code);
797
+ log(`plune: "${job.name}" not uploaded to your bucket (${code}) \u2014 its result is reported without it.`);
798
+ }
799
+ function refusedHere(job, size) {
800
+ if (job.kind === "screenshot") {
801
+ if (!SCREENSHOT_TYPES.has(job.type)) return "type";
802
+ if (size > SCREENSHOT_BYTES) return "size";
803
+ }
804
+ if (job.kind === "text" && size > TEXT_BYTES) return "textSize";
805
+ return void 0;
806
+ }
807
+ async function uploadDirect(job, size) {
808
+ const { kind } = job;
809
+ if (kind === "file") return skip(kind, job.name, "noStorage");
810
+ const { bytes: ceiling, size: tooBig } = KINDS[kind];
811
+ if (size > ceiling) return skip(kind, job.name, tooBig);
511
812
  let bytes;
512
- try {
513
- if ((await disk.stat(shot.path)).size > SCREENSHOT_BYTES) return skip(shot.name, "size");
514
- bytes = await disk.readFile(shot.path);
515
- } catch {
516
- return skip(shot.name, "missing");
813
+ if (job.body !== void 0) bytes = job.body;
814
+ else {
815
+ try {
816
+ bytes = await disk.readFile(job.path);
817
+ } catch {
818
+ return skip(kind, job.name, "missing");
819
+ }
517
820
  }
821
+ const refusal = kind === "text" ? textRefusal(bytes) : void 0;
822
+ if (refusal !== void 0) return skip(kind, job.name, refusal);
518
823
  const out = await client.upload(
519
- `/v1/results/${encodeURIComponent(shot.id)}/files?name=${encodeURIComponent(shot.name)}`,
824
+ `/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
520
825
  bytes,
521
- shot.type
826
+ job.type
522
827
  );
523
- if (out.ok) {
524
- stats.screenshots.uploaded += 1;
525
- return;
828
+ if (out.ok) countsOf(kind).uploaded += 1;
829
+ else failed(job, out.detail || out.kind);
830
+ }
831
+ async function uploadToBucket(job, size, first) {
832
+ const source = job.body !== void 0 ? { body: job.body } : { path: job.path, size };
833
+ let link = first;
834
+ for (let renewed = false; ; ) {
835
+ const put = await client.put(link.url, link.headers, source);
836
+ if (put.ok) break;
837
+ if (put.status !== 403 || renewed) return failedToBucket(job, put.code);
838
+ renewed = true;
839
+ const again = await ask(job, size);
840
+ if (!again.ok) return failedToBucket(job, codeOf(again));
841
+ const fresh = linkOf(again.body?.files?.[0]);
842
+ if (fresh === void 0) return failedToBucket(job, "bad_answer");
843
+ link = fresh;
526
844
  }
527
- stats.screenshots.failed += 1;
528
- if (stats.screenshots.failed === 1) {
529
- log(`plune: could not upload a screenshot (${out.detail || out.kind}) \u2014 its result is reported without it.`);
845
+ const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {});
846
+ if (!done2.ok) return failedToBucket(job, codeOf(done2));
847
+ countsOf(job.kind).uploaded += 1;
848
+ }
849
+ let free = UPLOADS_AT_ONCE;
850
+ const waiting = [];
851
+ async function gated(work) {
852
+ if (free === 0) await new Promise((resolve2) => waiting.push(resolve2));
853
+ else free -= 1;
854
+ try {
855
+ return await work();
856
+ } finally {
857
+ const next = waiting.shift();
858
+ if (next !== void 0) next();
859
+ else free += 1;
530
860
  }
531
861
  }
862
+ async function plan(job) {
863
+ const { kind } = job;
864
+ let size;
865
+ if (job.body !== void 0) size = job.body.length;
866
+ else if (job.path === void 0) return void skip(kind, job.name, "missing");
867
+ else {
868
+ try {
869
+ size = (await disk.stat(job.path)).size;
870
+ } catch {
871
+ return void skip(kind, job.name, "missing");
872
+ }
873
+ }
874
+ if (size === 0) return void skip(kind, job.name, "empty");
875
+ const direct = () => {
876
+ if (kind === "file") return void skip(kind, job.name, "noStorage");
877
+ const roomKey = `${job.id}/${kind}`;
878
+ const left = rooms.get(roomKey) ?? KINDS[kind].room;
879
+ if (left === 0) return void skip(kind, job.name, KINDS[kind].limit);
880
+ rooms.set(roomKey, left - 1);
881
+ return () => uploadDirect(job, size);
882
+ };
883
+ if (uploadsMissing) return direct();
884
+ const out = await gated(() => ask(job, size));
885
+ if (!out.ok) {
886
+ if (out.status === 404 && out.detail === "" && out.code === void 0) {
887
+ uploadsMissing = true;
888
+ return direct();
889
+ }
890
+ if (out.code !== void 0) failedToBucket(job, out.code);
891
+ else failed(job, out.detail || out.kind);
892
+ return void 0;
893
+ }
894
+ const item = out.body?.files?.[0];
895
+ if (item?.skipped === "no_storage") return void skip(kind, job.name, refusedHere(job, size) ?? "noStorage");
896
+ if (item?.direct === true) return direct();
897
+ const link = linkOf(item);
898
+ if (link === void 0) return void failed(job, "bad_answer");
899
+ if (!bucketSeen) {
900
+ bucketSeen = true;
901
+ log(BUCKET_FOUND);
902
+ }
903
+ return () => uploadToBucket(job, size, link);
904
+ }
532
905
  async function upload(items, filesByKey) {
533
906
  if (filesByKey.size === 0 || !Array.isArray(items)) return;
534
- const shots = [];
907
+ const jobs = [];
535
908
  for (const item of items) {
536
909
  if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
537
- let room = SCREENSHOTS_PER_RESULT;
538
910
  for (const file of filesByKey.get(item.resultKey) ?? []) {
539
- const type = file.contentType?.split(";")[0]?.trim().toLowerCase() || TYPE_BY_EXTENSION[extname(file.path).toLowerCase()];
540
- if (!type?.startsWith("image/")) continue;
911
+ const type = mediaTypeOf(file.contentType) ?? (file.path === void 0 ? void 0 : TYPE_BY_EXTENSION[extname(file.path).toLowerCase()]);
912
+ if (type === void 0 || type === METADATA_TYPE2) continue;
913
+ const kind = kindOf(type);
914
+ if (kind !== "text" && file.path === void 0) continue;
541
915
  const name = nameOf(file);
542
- if (file.name.startsWith("_")) skip(name, "hidden");
543
- else if (!SCREENSHOT_TYPES.has(type)) skip(name, "type");
544
- else if (room === 0) skip(name, "limit");
916
+ if (file.name.startsWith("_")) skip(kind, name, "hidden");
545
917
  else {
546
- room -= 1;
547
- shots.push({ id: item.id, name, type, path: file.path });
918
+ jobs.push({
919
+ id: item.id,
920
+ kind,
921
+ name,
922
+ type: kind === "text" ? textTypeOf(file.contentType ?? "", type) : type,
923
+ ...file.body !== void 0 ? { body: file.body } : {},
924
+ ...file.path !== void 0 ? { path: file.path } : {}
925
+ });
548
926
  }
549
927
  }
550
928
  }
551
- let next = 0;
929
+ const byResult = /* @__PURE__ */ new Map();
930
+ for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
552
931
  await Promise.all(
553
- Array.from({ length: Math.min(UPLOADS_AT_ONCE, shots.length) }, async () => {
554
- while (next < shots.length) await uploadOne(shots[next++]);
932
+ [...byResult.values()].map(async (group) => {
933
+ const work = [];
934
+ for (const job of group) {
935
+ const act = await plan(job);
936
+ if (act !== void 0) work.push(gated(act));
937
+ }
938
+ await Promise.all(work);
555
939
  })
556
940
  );
557
941
  }
@@ -629,11 +1013,20 @@ async function startRun(passed, declared) {
629
1013
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
630
1014
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
631
1015
  if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
632
- const shots = stats.screenshots;
633
- if (shots.uploaded + shots.skipped + shots.failed > 0) {
1016
+ const kinds = [stats.screenshots, stats.textFiles, stats.otherFiles];
1017
+ const sum = (what) => kinds.reduce((n, counts) => n + counts[what], 0);
1018
+ if (bucketSeen) {
1019
+ const other = sum("skipped") - noStorage;
634
1020
  parts.push(
635
- `${shots.uploaded} screenshot(s) uploaded` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} failed to upload` : "")
1021
+ `${sum("uploaded")} file(s) uploaded to your bucket` + (noStorage > 0 ? `, ${noStorage} skipped (no bucket connected)` : "") + (other > 0 ? `, ${other} skipped` : "") + (sum("failed") > 0 ? `, ${sum("failed")} failed to upload` : "")
636
1022
  );
1023
+ } else {
1024
+ for (const said of [tally(stats.screenshots, "screenshot"), tally(stats.textFiles, "text file")]) {
1025
+ if (said !== void 0) parts.push(said);
1026
+ }
1027
+ const other = stats.otherFiles;
1028
+ if (other.skipped > 0) parts.push(`${other.skipped} file(s) not stored (no bucket connected)`);
1029
+ if (other.failed > 0) parts.push(`${other.failed} file(s) failed to upload`);
637
1030
  }
638
1031
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
639
1032
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
@@ -700,134 +1093,6 @@ function resultKey(testId, retry) {
700
1093
  const plain = `${testId}#${retry}`;
701
1094
  return plain.length <= RESULT_KEY_MAX ? plain : `${sha256(testId).slice(0, HASH_CHARS)}#${retry}`;
702
1095
  }
703
-
704
- // src/reporter-core/failure-detail.ts
705
- import { existsSync as existsSync2 } from "fs";
706
- import { homedir as homedir2 } from "os";
707
- import { dirname as dirname3, join as join3, posix, resolve } from "path";
708
- import { stripVTControlCharacters } from "util";
709
- function failureOf(attempt, home = homedir2()) {
710
- const error = chosen(attempt);
711
- const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
712
- const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
713
- const steps = chainOf(attempt.steps);
714
- const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
715
- const artifacts = attempt.attachments.filter((a) => a.path !== void 0 && !a.name.startsWith("_") && a.contentType !== METADATA_TYPE).map(({ name, contentType }) => ({ name: fileNameOf(name), ...contentType ? { contentType } : {} })).filter((a) => a.name !== "");
716
- const ciUrl = webLink(attempt.buildHref);
717
- const failure = {
718
- ...headline !== void 0 ? { headline } : {},
719
- ...steps.length > 0 ? { steps } : {},
720
- ...location !== void 0 ? { location } : {},
721
- ...artifacts.length > 0 ? { artifacts } : {},
722
- ...ciUrl !== void 0 ? { ciUrl } : {}
723
- };
724
- return Object.keys(failure).length === 0 ? void 0 : failure;
725
- }
726
- function repoRootOf(dir, stopAt) {
727
- for (let here = resolve(dir); ; here = dirname3(here)) {
728
- if (existsSync2(join3(here, ".git"))) return here;
729
- if (here === stopAt || dirname3(here) === here) return void 0;
730
- }
731
- }
732
- function errorContextOf(attempt, home = homedir2()) {
733
- const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
734
- return cut(machineless(text, attempt.repoRoot, home));
735
- }
736
- function filesOf(attempt) {
737
- return attempt.attachments.filter((a) => a.path !== void 0);
738
- }
739
- function machineless(raw, repoRoot, home) {
740
- let text = stripVTControlCharacters(raw).replaceAll("\x1B", "");
741
- const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
742
- if (root !== void 0) text = text.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
743
- const own = pathIn(home);
744
- if (own !== void 0) text = text.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
745
- if (repoRoot === void 0) text = text.replace(ANY_HOME, "~");
746
- return text;
747
- }
748
- var PATH_START = "(?<![\\w.~%/\\\\-])";
749
- var TEXT_LIMIT = 524288;
750
- var HEADLINE_LIMIT = 8 * 1024;
751
- var jsonBytes = (text) => Buffer.byteLength(JSON.stringify(text)) - 2;
752
- var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:(?:file|webpack|webpack-internal):(?:\\*\/){2,3})?(?:\\*\/home\\*\/|\\*\/Users\\*\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
753
- function pathIn(path3) {
754
- const parts = path3.split(/[\\/]+/);
755
- while (parts.length > 1 && parts.at(-1) === "") parts.pop();
756
- if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
757
- const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
758
- const escaped = /^\/(?!\/)/.test(path3) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
759
- const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
760
- return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
761
- }
762
- var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
763
- function cut(text) {
764
- if (jsonBytes(text) <= TEXT_LIMIT) return text;
765
- const lines = text.split("\n");
766
- const size = lines.map((line) => jsonBytes(line) + 2);
767
- const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
768
- let used = 0;
769
- let head = 0;
770
- let tail = lines.length;
771
- while (head < tail && used + size[head] <= budget / 2) used += size[head++];
772
- while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
773
- return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
774
- }
775
- var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
776
- var METADATA_TYPE = "application/plune.metadata+json";
777
- var REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
778
- var FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
779
- var FRAME_MAX = 8192;
780
- var firstLine = (text) => stripVTControlCharacters(text).split("\n").map((line) => line.trim()).find((line) => line !== "");
781
- function chosen({ status, errors }) {
782
- if (status === "timedOut") {
783
- const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
784
- if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
785
- }
786
- return errors[0];
787
- }
788
- function chainOf(steps) {
789
- const chain = [];
790
- for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
791
- chain.push(failed.title);
792
- }
793
- return chain;
794
- }
795
- function placeOf(error, testFile) {
796
- const own = normal(testFile);
797
- let first;
798
- for (const line of stripVTControlCharacters(error.text).split("\n")) {
799
- if (line.length > FRAME_MAX) continue;
800
- const frame = FRAME.exec(line);
801
- if (frame === null) continue;
802
- const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
803
- if (normal(place.file) === own) return place;
804
- if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
805
- }
806
- return error.location ?? first;
807
- }
808
- function fileOf(raw) {
809
- if (!raw.startsWith("file://")) return raw;
810
- let path3 = raw.slice("file://".length);
811
- try {
812
- path3 = decodeURIComponent(path3);
813
- } catch {
814
- }
815
- return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
816
- }
817
- var normal = (path3) => posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
818
- function fromRoot(place, root) {
819
- if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
820
- const base = normal(root).replace(/\/+$/, "");
821
- const file = normal(place.file);
822
- if (!file.startsWith(`${base}/`)) return void 0;
823
- const relative = file.slice(base.length + 1);
824
- if (!REPO_RELATIVE.test(relative)) return void 0;
825
- const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
826
- return column === void 0 ? { file: relative, line: place.line } : { file: relative, line: place.line, column };
827
- }
828
- function webLink(href) {
829
- return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
830
- }
831
1096
  export {
832
1097
  DEFAULT_FALLBACK_PATH,
833
1098
  RESULT_KEY_MAX,